diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 0151c7d8..b581f4aa 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -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 @@ -52,6 +62,7 @@ jobs: continue-on-error: true - name: Setup signing keystore + if: ${{ secrets.KEYSTORE_BASE64 != '' }} run: | # Decode keystore from secret (persistent key for consistent signatures) echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks @@ -64,8 +75,19 @@ jobs: storeFile=upload-keystore.jks EOF + - name: Note about signing + if: ${{ secrets.KEYSTORE_BASE64 == '' }} + run: | + echo "⚠️ No signing keystore configured - APK will be signed with debug key" + echo "This is normal for forks. The APK will still work for testing." + - 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 diff --git a/.prompts/player-animation-audit.md b/.prompts/player-animation-audit.md new file mode 100644 index 00000000..3466671f --- /dev/null +++ b/.prompts/player-animation-audit.md @@ -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) diff --git a/AUDIT-PROMPT.md b/AUDIT-PROMPT.md new file mode 100644 index 00000000..7425d279 --- /dev/null +++ b/AUDIT-PROMPT.md @@ -0,0 +1,391 @@ +# Ensemble Codebase Intensive Audit Prompt + +## Overview + +You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities. + +**Codebase Statistics:** +- 90 Dart files, ~40,583 lines of code +- 18 screens, 27 services, 6 providers +- Architecture: Provider pattern with ChangeNotifier +- Audio: just_audio + flutter_pcm_sound + audio_service +- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol + +--- + +## Audit Scope + +Perform a thorough audit covering ALL of the following areas. For each issue found, provide: +1. **File path and line number(s)** +2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW +3. **Description** of the issue +4. **Impact** if left unfixed +5. **Recommended fix** with code example where applicable + +--- + +## SECTION 1: SECURITY AUDIT + +### 1.1 Credential Storage (CRITICAL PRIORITY) +Audit `/lib/services/settings_service.dart` for: +- [ ] Plaintext password storage in SharedPreferences (lines 209-222) +- [ ] Auth tokens stored without encryption +- [ ] Serialized credentials in JSON format (lines 169-190) + +**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage. + +### 1.2 Certificate Validation +Audit `/lib/services/auth/auth_manager.dart` for: +- [ ] HTTPS redirect handling for local IPs +- [ ] Certificate validation disabled scenarios +- [ ] MITM vulnerability exposure + +### 1.3 Authentication Strategies +Audit all files in `/lib/services/auth/`: +- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption) +- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159) +- [ ] `ma_auth_strategy.dart` - Token refresh mechanism +- [ ] `no_auth_strategy.dart` - Public server exposure + +### 1.4 Input Validation +Audit `/lib/services/music_assistant_api.dart`: +- [ ] URI construction without validation (lines 1346-1360) +- [ ] Provider/itemId injection risks +- [ ] WebSocket message validation + +### 1.5 Information Disclosure +- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls) +- [ ] Server version exposure via `server_info` +- [ ] Error messages leaking technical details + +--- + +## SECTION 2: MEMORY MANAGEMENT AUDIT + +### 2.1 Static Memory Leaks +Audit `/lib/models/player.dart`: +- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded +- [ ] Cleanup only removes 50 of 100+ entries + +### 2.2 Unbounded Caches +Audit `/lib/services/cache_service.dart`: +- [ ] `_albumTracksCache` - no size limit +- [ ] `_artistAlbumsCache` - no TTL expiration +- [ ] `_searchCache` - never evicted +- [ ] `_playerTrackCache` - only cleared on disconnect + +### 2.3 Stream/Timer Cleanup +Audit `/lib/providers/music_assistant_provider.dart`: +- [ ] `_playerStateTimer` disposal (line 4435-4445) +- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940) +- [ ] `_localPlayerEventSubscription` cleanup +- [ ] `_playerUpdatedEventSubscription` cleanup +- [ ] `_playerAddedEventSubscription` cleanup + +Audit `/lib/services/hardware_volume_service.dart`: +- [ ] `_volumeUpController` never closed (lines 74-85) +- [ ] `_volumeDownController` never closed + +### 2.4 Stream Subscription Leaks +Audit `/lib/providers/music_assistant_provider.dart`: +- [ ] `_api!.connectionState.listen()` - subscription not stored +- [ ] Old subscriptions not cancelled before new API instance + +--- + +## SECTION 3: RACE CONDITION AUDIT + +### 3.1 Player State Dual Updates +Audit `/lib/providers/music_assistant_provider.dart`: +- [ ] Timer polling (every 5s) vs WebSocket events racing +- [ ] `_updatePlayerState()` called from both paths +- [ ] No mutex/lock protection + +### 3.2 Async Method Reentrancy +Audit `selectPlayer()` method: +- [ ] `async` method without guard against concurrent calls +- [ ] `_selectedPlayer` modified before and after `await` +- [ ] State could change during await + +### 3.3 Fire-and-Forget Operations +Search for `unawaited(` and `() async {` patterns: +- [ ] `_persistPlaybackState()` - database save not awaited +- [ ] Pause operations fire-and-forget (lines 3876-3894) +- [ ] Preload operations (line 2707) + +### 3.4 PCM Player State Machine +Audit `/lib/services/pcm_audio_player.dart`: +- [ ] 9-state machine with complex transitions (lines 32-37) +- [ ] Auto-recovery race condition (lines 215-229) +- [ ] Feed loop during pause transition (lines 174-179) + +--- + +## SECTION 4: ERROR HANDLING AUDIT + +### 4.1 Silent Exception Handlers (CRITICAL) +Find all instances of: +```dart +} catch (e) {} +} catch (_) {} +``` + +Known locations: +- [ ] `music_assistant_provider.dart` (multiple) +- [ ] `player_provider.dart` (3 instances) +- [ ] `recently_played_service.dart` + +### 4.2 Missing Error Recovery +- [ ] No exponential backoff on connection retry +- [ ] No retry logic for failed API calls +- [ ] WebSocket timeout handling (30 seconds - too long?) + +### 4.3 Null Safety Violations +Find all `!` (null assertion) operators: +- [ ] `search_screen.dart` (20+ instances) +- [ ] `models/media_item.dart` +- [ ] `models/player.dart` +- [ ] `item.mediaItem! as Artist` patterns + +--- + +## SECTION 5: ARCHITECTURAL ISSUES + +### 5.1 God Class +Audit `/lib/providers/music_assistant_provider.dart`: +- [ ] 4,469 lines - violates Single Responsibility +- [ ] 386 conditional branches +- [ ] Mixing: connection, players, library, sync, audio, position tracking + +**Recommend splitting into:** +- ConnectionProvider (~500 LOC) +- PlayerManager (~800 LOC) +- LibraryProvider (~1000 LOC) +- LocalPlaybackManager (~600 LOC) + +### 5.2 Oversized Widget Files +- [ ] `expandable_player.dart` - 2,485 lines (should be <500) +- [ ] `global_player_overlay.dart` - 737 lines +- [ ] `new_library_screen.dart` - 2,521 lines + +### 5.3 Global Key Anti-Pattern +Audit `/lib/widgets/global_player_overlay.dart`: +- [ ] `globalPlayerKey` for state access +- [ ] `_overlayStateKey` for overlay state +- [ ] Should use Provider/context instead + +### 5.4 Primitive Obsession +- [ ] View modes as strings ('grid2', 'grid3', 'list') +- [ ] Media types as strings ('track', 'album', 'artist') +- [ ] Should be strongly-typed enums + +--- + +## SECTION 6: CODE QUALITY ISSUES + +### 6.1 Testing (CRITICAL) +- [ ] **ZERO test files exist** - confirm this +- [ ] No unit tests, widget tests, or integration tests +- [ ] flutter_test dependency exists but unused + +### 6.2 Hardcoded Values +Find and document all: +- [ ] Color hex codes (50+ instances of `Color(0xFF...`) +- [ ] Duration values not using `Timings` constants +- [ ] Magic numbers (16, 4, 5, 100, 50) +- [ ] String literals for media types + +### 6.3 Mixed Async Patterns +- [ ] `.then()` chains alongside async/await +- [ ] `.whenComplete()` usage +- [ ] Consolidate to async/await + +### 6.4 Dead Code +- [ ] `@Deprecated` methods still implemented +- [ ] `animation_debugger.dart` - dev-only? +- [ ] Unused imports + +### 6.5 TODO Comments +Document all actionable TODOs: +- [ ] `queue_screen.dart:214` - queue item removal +- [ ] `audiobook_detail_screen.dart:261,312` - progress sync +- [ ] `music_assistant_provider.dart:793,799` - playlist modification + +--- + +## SECTION 7: UI/UX AUDIT + +### 7.1 Accessibility (CRITICAL) +Audit for missing: +- [ ] `Semantics` widgets on interactive elements +- [ ] `Tooltip` on icon buttons +- [ ] Screen reader support +- [ ] Touch target sizes (minimum 48dp) +- [ ] Color contrast validation (WCAG AA) + +### 7.2 Performance Issues +Audit `/lib/widgets/expandable_player.dart`: +- [ ] No `RepaintBoundary` wrappers +- [ ] 55+ `setState()` calls +- [ ] 3 AnimationControllers with multiple listeners +- [ ] Title height cache invalidation (lines 118+) + +### 7.3 Double Opacity Bug +Audit `/lib/widgets/player/player_controls.dart`: +```dart +color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5)) + .withOpacity(expandedElementsOpacity), +``` +- [ ] Double `.withOpacity()` application + +### 7.4 Missing Features +- [ ] No landscape orientation support +- [ ] No tablet layout optimization +- [ ] No loading skeleton animations +- [ ] No haptic feedback (except one location) + +--- + +## SECTION 8: AUDIO PLAYBACK AUDIT + +### 8.1 Position Interpolation +Audit `/lib/services/position_tracker.dart`: +- [ ] No timeout on anchor updates - unbounded drift possible +- [ ] Duration interpolation can exceed track duration +- [ ] Stale timestamp handling (>30 seconds) + +### 8.2 Sendspin Protocol +Audit `/lib/services/sendspin_service.dart`: +- [ ] Proxy auth retry without deduplication (lines 188-209) +- [ ] WebSocket binary frame parsing +- [ ] Clock synchronization accuracy + +### 8.3 Audio Resource Cleanup +Audit `/lib/services/audio/massiv_audio_handler.dart`: +- [ ] Audio session interrupt listeners not disposed +- [ ] Audio focus not explicitly released + +### 8.4 Mixed Playback Modes +- [ ] just_audio initialized but unused during Sendspin +- [ ] Resource allocation for unused player +- [ ] Potential audio focus conflicts + +--- + +## SECTION 9: NETWORKING AUDIT + +### 9.1 WebSocket Lifecycle +Audit `/lib/services/music_assistant_api.dart`: +- [ ] Heartbeat interval (30s) - appropriate? +- [ ] Reconnection logic and timing +- [ ] Pending request cleanup on disconnect + +### 9.2 Caching Strategy +- [ ] No incremental/delta sync (full library every 5 minutes) +- [ ] Cache staleness not communicated to UI +- [ ] No query result pagination (fetches up to 5000 items) + +### 9.3 Rate Limiting +- [ ] No client-side rate limiting +- [ ] Search requests not throttled +- [ ] Could overload server + +### 9.4 Offline Support +Audit `/lib/services/offline_action_queue.dart`: +- [ ] Queue execution not atomic (lines 118-130) +- [ ] Action could execute twice on crash +- [ ] Queue stored unencrypted + +--- + +## SECTION 10: DATABASE AUDIT + +### 10.1 Schema Review +Audit `/lib/database/database.dart`: +- [ ] Table indexes for query performance +- [ ] Foreign key constraints +- [ ] Migration strategy + +### 10.2 Query Patterns +- [ ] N+1 query prevention +- [ ] Transaction usage for atomic operations +- [ ] Connection pooling + +### 10.3 Data Consistency +- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions +- [ ] User modifications during background sync +- [ ] Conflict resolution strategy + +--- + +## Deliverables + +After completing the audit, provide: + +### 1. Executive Summary +- Overall code quality score (1-10) +- Top 5 critical issues requiring immediate attention +- Estimated technical debt in developer-hours + +### 2. Issue Tracker +Create a prioritized list of all issues found: +| ID | Severity | Category | File:Line | Description | Effort | +|----|----------|----------|-----------|-------------|--------| + +### 3. Recommended Roadmap +Sprint-based plan to address findings: +- Sprint 1 (Immediate): Security fixes, silent exception handlers +- Sprint 2-3: God class decomposition, test infrastructure +- Sprint 4-6: Memory leak fixes, accessibility +- Long-term: Performance optimization, monitoring + +### 4. Code Examples +For each CRITICAL and HIGH severity issue, provide: +- Current problematic code +- Recommended refactored code +- Migration steps + +--- + +## Files to Prioritize + +Start your audit with these critical files: + +1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class +2. `/lib/services/settings_service.dart` - Credential storage +3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking +4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity +5. `/lib/services/auth/` directory - Security +6. `/lib/models/player.dart` - Memory leaks +7. `/lib/services/cache_service.dart` - Unbounded caches +8. `/lib/services/position_tracker.dart` - Audio sync issues + +--- + +## Known Critical Issues (Pre-Identified) + +These issues have been identified and MUST be verified and documented: + +1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption +2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests +3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP +4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns +5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth +6. **HIGH: Race conditions** - Player state polling vs events +7. **HIGH: No accessibility** - Missing Semantics, screen reader support +8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs +9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls +10. **MEDIUM: Position tracker drift** - No timeout on anchor updates + +--- + +## Audit Execution Notes + +- Use `Grep` tool to search for patterns across the codebase +- Use `Read` tool to examine specific file sections +- Document line numbers for all findings +- Provide code snippets for context +- Cross-reference related issues across files +- Verify pre-identified issues exist and document their exact locations + +**Total estimated audit time: 4-6 hours for thorough coverage** diff --git a/AUDIT-REPORT.md b/AUDIT-REPORT.md new file mode 100644 index 00000000..d25e2a4c --- /dev/null +++ b/AUDIT-REPORT.md @@ -0,0 +1,419 @@ +# Ensemble Codebase - Comprehensive Audit Report + +**Audit Date:** 2026-01-01 +**Codebase:** Ensemble Flutter App (Music Assistant Client) +**Version:** 2.7.3-beta+35 +**Total Lines of Code:** ~40,583 Dart +**Files Analyzed:** 90 Dart files + +--- + +## Executive Summary + +### Overall Code Quality Score: 5.5/10 + +The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release. + +### Critical Statistics + +| Metric | Value | Status | +|--------|-------|--------| +| Test Coverage | 0% | CRITICAL | +| God Class Size | 4,469 LOC | CRITICAL | +| Silent Exception Handlers | 7 | CRITICAL | +| Plaintext Credentials | 4 locations | CRITICAL | +| Memory Leak Locations | 6 | HIGH | +| Race Conditions | 4 | HIGH | +| Accessibility (Semantics) | 0 widgets | CRITICAL | + +--- + +## Issue Summary by Severity + +| Severity | Count | Categories | +|----------|-------|------------| +| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility | +| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio | +| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality | +| **LOW** | 14 | Naming, Documentation, Minor Improvements | + +--- + +## CRITICAL ISSUES (Immediate Action Required) + +### 1. Plaintext Credential Storage +**File:** `/lib/services/settings_service.dart` +**Lines:** 131-143, 147-159, 169-190, 209-222 + +Passwords and auth tokens stored in SharedPreferences without encryption. + +```dart +// VULNERABLE CODE +static Future setPassword(String? password) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyPassword, password); // PLAINTEXT! +} +``` + +**Fix:** Use `flutter_secure_storage` for all sensitive data. + +--- + +### 2. Zero Test Coverage +**Location:** Entire codebase + +No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused. + +**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness. + +**Fix:** Establish test infrastructure immediately. Start with: +- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler` +- Widget tests for player controls +- Integration tests for playback flows + +--- + +### 3. God Class - MusicAssistantProvider +**File:** `/lib/providers/music_assistant_provider.dart` +**Size:** 4,469 lines, ~140 methods, 744 conditional branches + +Single class handles: connection, players, library, cache, audio, sync, offline queue. + +**Fix:** Split into: +- `ConnectionManager` (~500 LOC) +- `PlayerManager` (~800 LOC) +- `LibraryCacheService` (~600 LOC) +- `SendspinManager` (~400 LOC) +- `MusicAssistantProvider` - Facade composing above (~800 LOC) + +--- + +### 4. Zero Accessibility Support +**Files:** `/lib/widgets/`, `/lib/screens/` (all) + +- **0** `Semantics` widgets +- **0** `semanticLabel` properties +- **1** `Tooltip` (entire app) +- Screen readers cannot navigate the app + +**Fix:** Add Semantics to all interactive elements: +```dart +Semantics( + button: true, + label: isPlaying ? 'Pause playback' : 'Play', + child: IconButton(...) +) +``` + +--- + +### 5. Silent Exception Handlers +**Locations:** 7 instances across codebase + +| File | Line | Code | +|------|------|------| +| player_provider.dart | 567, 575, 584 | `catch (e) {}` | +| music_assistant_provider.dart | 2690 | `catch (e) {}` | +| music_assistant_api.dart | 2542 | `catch (e) {}` | +| recently_played_service.dart | 154, 206 | `catch (_) {}` | + +**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`. + +--- + +### 6. Audio Session Listeners Not Disposed +**File:** `/lib/services/audio/massiv_audio_handler.dart` +**Lines:** 45-73 + +Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners. + +**Fix:** Store subscriptions and cancel in dispose(): +```dart +StreamSubscription? _interruptionSubscription; +_interruptionSubscription = session.interruptionEventStream.listen(...); + +void dispose() { + _interruptionSubscription?.cancel(); +} +``` + +--- + +## HIGH SEVERITY ISSUES + +### 7. Connection State Subscription Not Stored +**File:** `/lib/providers/music_assistant_provider.dart:575-614` + +The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect. + +--- + +### 8. Player State Race Condition +**File:** `/lib/providers/music_assistant_provider.dart` + +Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection. + +--- + +### 9. Async Method Reentrancy +**File:** `/lib/providers/music_assistant_provider.dart:2713-2841` + +`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave. + +--- + +### 10. Unbounded Search Cache +**File:** `/lib/services/cache_service.dart:31-32` + +`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches. + +--- + +### 11. Static Memory Leak +**File:** `/lib/models/player.dart:96, 139-144` + +`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth. + +--- + +### 12. No Database Indexes +**File:** `/lib/database/database.dart` + +No secondary indexes defined. Query performance degrades as database grows. + +--- + +### 13. No Incremental Sync +**File:** `/lib/services/sync_service.dart:150-165` + +Full library fetch (up to 4000 items) on every sync instead of delta updates. + +--- + +### 14. Offline Queue Not Atomic +**File:** `/lib/services/offline_action_queue.dart:80-108` + +Queue persistence happens after all processing - crash during processing loses successful actions. + +--- + +### 15. Auth Retry Without Deduplication +**File:** `/lib/services/sendspin_service.dart:189-209` + +Multiple auth messages can be sent during rapid reconnection attempts. + +--- + +### 16. Pending Requests Not Completed on Disconnect +**File:** `/lib/services/music_assistant_api.dart:2911` + +`_pendingRequests.clear()` abandons Completers without completing them with errors. + +--- + +### 17. HTTP Fallback for Local IPs +**File:** `/lib/services/auth/auth_manager.dart:51-58` + +HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability. + +--- + +### 18. Certificate Validation Bypassed +**File:** `/lib/services/auth/auth_manager.dart:210-236` + +HTTPS redirects explicitly refused for local IPs due to expected certificate failures. + +--- + +## MEDIUM SEVERITY ISSUES + +### 19. Double Opacity Bug +**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100` + +```dart +// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity +color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5)) + .withOpacity(expandedElementsOpacity), +``` + +--- + +### 20. Mixed Async Patterns +**Locations:** 13 instances + +`.then()` chains mixed with `async/await` throughout the codebase. + +--- + +### 21. Hardcoded Colors +**Locations:** 55 instances + +`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets. + +--- + +### 22. View Modes as Strings +**Locations:** 80+ instances + +`'grid2'`, `'grid3'`, `'list'` should be enums. + +--- + +### 23. Fire-and-Forget Pause Commands +**File:** `/lib/providers/music_assistant_provider.dart:3875-3894` + +Server pause command uses `unawaited()` - errors are silently lost. + +--- + +### 24. Position Tracker Anchor Timeout Missing +**File:** `/lib/services/position_tracker.dart:24-26` + +No timeout on stale anchor - interpolation can drift indefinitely. + +--- + +### 25. PCM Buffer No Overflow Protection +**File:** `/lib/services/pcm_audio_player.dart:68, 236-247` + +Audio buffer grows unbounded if data arrives faster than consumption. + +--- + +### 26. No Client-Side Rate Limiting +**Location:** App-wide + +No throttling on API requests during rapid user interactions. + +--- + +### 27. Portrait-Only Lock +**File:** `/lib/main.dart:64-68` + +No landscape orientation support. + +--- + +### 28. Touch Targets Below 48dp +**Files:** player_controls.dart, queue_panel.dart, player_card.dart + +Multiple buttons at 28x28 and 44x44 instead of minimum 48x48. + +--- + +### 29. WCAG Contrast Not Validated +**File:** `/lib/theme/palette_helper.dart` + +Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation. + +--- + +### 30. StreamControllers Not Closed +**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85` + +`_volumeUpController` and `_volumeDownController` never closed in dispose(). + +--- + +## LOW SEVERITY ISSUES + +### 31-34. Naming Inconsistencies +Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names. + +### 35-38. Missing TODO Implementations +6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification). + +### 39-42. Dead/Unused Code +- `@Deprecated` methods still implemented +- `library_stats.dart` appears unused +- `animation_debugger.dart` disabled by default + +### 43-44. Minor Documentation Gaps +Missing method documentation, no architecture diagram. + +--- + +## Recommended Remediation Roadmap + +### Sprint 1 (Week 1-2): Security & Critical Fixes +1. Migrate credentials to `flutter_secure_storage` +2. Add logging to all silent catch blocks +3. Store connection state subscription +4. Fix audio session listener disposal +5. Add basic test infrastructure (10% coverage target) + +### Sprint 2 (Week 3-4): Memory & Race Conditions +6. Add mutex to player state updates +7. Add reentrancy guard to `selectPlayer()` +8. Implement bounded caches with LRU eviction +9. Fix static memory leak in Player model +10. Store and cancel all stream subscriptions + +### Sprint 3 (Week 5-6): Architecture Refactoring +11. Split `MusicAssistantProvider` into 5 smaller classes +12. Split `expandable_player.dart` into component widgets +13. Replace GlobalKey anti-patterns with Provider +14. Convert string constants to enums +15. Extract hardcoded colors to constants file + +### Sprint 4 (Week 7-8): Accessibility & UI +16. Add Semantics to all interactive elements +17. Increase touch targets to 48dp minimum +18. Implement WCAG contrast validation +19. Fix double opacity bug +20. Add landscape orientation support + +### Sprint 5 (Week 9-10): Performance & Polish +21. Add database indexes +22. Implement incremental sync +23. Add client-side rate limiting +24. Fix atomic offline queue processing +25. Achieve 30% test coverage target + +--- + +## Files Requiring Most Attention + +| File | LOC | Issues | Priority | +|------|-----|--------|----------| +| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL | +| `settings_service.dart` | 811 | 4 | CRITICAL | +| `expandable_player.dart` | 2,485 | 6 | HIGH | +| `music_assistant_api.dart` | 2,929 | 5 | HIGH | +| `cache_service.dart` | 370 | 4 | HIGH | +| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL | +| `player.dart` | 422 | 2 | HIGH | +| `auth_manager.dart` | 300 | 3 | HIGH | + +--- + +## Estimated Technical Debt + +| Category | Effort (Dev-Days) | +|----------|-------------------| +| Security Fixes | 3-5 | +| Test Infrastructure | 10-15 | +| Architecture Refactoring | 15-20 | +| Memory Leak Fixes | 3-5 | +| Accessibility | 5-8 | +| UI/UX Polish | 3-5 | +| Performance | 5-8 | +| **Total** | **44-66 dev-days** | + +--- + +## Conclusion + +The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment. + +The most urgent priorities are: +1. **Secure credential storage** - immediate security risk +2. **Add test infrastructure** - enables safe refactoring +3. **Split the god class** - enables maintainability +4. **Add accessibility** - legal/compliance requirement + +With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards. + +--- + +*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.* diff --git a/AUDIT_PROMPT.md b/AUDIT_PROMPT.md new file mode 100644 index 00000000..73871c4d --- /dev/null +++ b/AUDIT_PROMPT.md @@ -0,0 +1,119 @@ +# Ensemble Codebase Audit Prompt + +Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app. + +--- + +## Prompt + +You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files. + +**CRITICAL RULES:** +1. DO NOT remove or break any existing functionality +2. All changes must be on the current branch: `audit/code-quality-review` +3. Run `flutter analyze` after each set of changes to verify no regressions +4. Commit changes in logical, reviewable chunks + +**AUDIT PHASES:** + +### Phase 1: Code Quality Analysis (Read-Only) +First, analyze the codebase without making changes. Create a report covering: + +1. **Large File Analysis** - Review these files for refactoring opportunities: + - `lib/screens/new_library_screen.dart` (122KB) + - `lib/screens/search_screen.dart` (129KB) + - `lib/widgets/expandable_player.dart` (107KB) + - `lib/screens/album_details_screen.dart` (48KB) + - `lib/screens/artist_details_screen.dart` (45KB) + +2. **Pattern Inconsistencies** - Look for: + - Inconsistent naming conventions (camelCase vs snake_case) + - Mixed widget composition patterns + - Inconsistent error handling approaches + - Duplicate code across screens/widgets + +3. **Performance Red Flags** - Identify: + - Unnecessary rebuilds (missing const constructors) + - Heavy computations in build methods + - Missing list item caching (ListView.builder vs ListView) + - Inefficient state management patterns + +4. **Scroll Performance Issues** - Specifically audit: + - ScrollController usage and disposal + - Sliver implementations + - AnimatedBuilder/AnimatedWidget usage + - Image loading in scrollable lists + - Any jank-inducing patterns + +### Phase 2: Quick Wins (Low Risk, High Impact) +Apply these fixes first: + +1. Add missing `const` constructors throughout +2. Fix any `flutter analyze` warnings +3. Remove dead code and unused imports +4. Standardize formatting with `dart format` + +### Phase 3: Scroll Animation Optimization +Focus on smoother scrolling: + +1. **Image Optimization** + - Ensure CachedNetworkImage is used consistently + - Add appropriate cacheWidth/cacheHeight + - Implement proper placeholder/error widgets + +2. **List Performance** + - Convert any ListView to ListView.builder where appropriate + - Add itemExtent where possible for fixed-height items + - Implement AutomaticKeepAliveClientMixin where needed + - Consider RepaintBoundary for complex list items + +3. **Animation Smoothing** + - Use `Curves.easeOutCubic` or similar for scroll-linked animations + - Ensure animations run at 60fps (avoid heavy computations during animation) + - Review CustomScrollView/Sliver implementations + +### Phase 4: Code Consistency Improvements + +1. **Widget Extraction** + - Extract repeated UI patterns into reusable widgets + - Ensure extracted widgets follow existing naming patterns + +2. **State Management** + - Verify Provider usage is consistent + - Check for proper disposal of controllers/streams + +3. **Error Handling** + - Standardize try/catch patterns + - Ensure consistent loading/error state UI + +### Phase 5: Verification + +After all changes: +1. Run `flutter analyze` - must pass with 0 issues +2. Run `flutter test` if tests exist +3. Create a summary of all changes made +4. List any recommendations that were NOT implemented (too risky) + +--- + +## Execution Instructions + +```bash +# Start the audit +cd /home/home-server/Ensemble +git checkout audit/code-quality-review + +# After each phase, commit your changes +git add -A && git commit -m "Audit Phase X: [description]" + +# Verify no regressions +flutter analyze +``` + +--- + +## Expected Deliverables + +1. `AUDIT_REPORT.md` - Findings from Phase 1 +2. Multiple commits with clear messages for each phase +3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations diff --git a/AUDIT_REPORT.md b/AUDIT_REPORT.md new file mode 100644 index 00000000..612c83a3 --- /dev/null +++ b/AUDIT_REPORT.md @@ -0,0 +1,136 @@ +# Ensemble Flutter App - Code Audit Report + +**Date:** January 4, 2026 +**Branch:** `audit/code-quality-review` +**Files Audited:** 93 Dart files + +--- + +## Executive Summary + +The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified: + +| 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 | + +--- + +## 1. CRITICAL: Deprecated API Usage + +### `colorScheme.background` (20 instances) +Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`. + +**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. HIGH: Missing const Constructors + +### EdgeInsets.zero (64 instances) +High-impact optimization - used extensively in button padding. + +**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) + +### BorderRadius.circular (40+ instances) +Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`. + +--- + +## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters + +### CachedNetworkImage without memCacheWidth/Height + +**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 + +**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation). + +--- + +## 4. LOW: Code Duplication + +### 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 + +--- + +## 5. POSITIVE FINDINGS (Already Optimized) + +- **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 + +--- + +## Recommended Action Plan + +### Phase 1: Quick Wins (Low Risk) +1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations) +2. Add `const` to EdgeInsets.zero instances + +### 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 + +--- + +## Files by Complexity + +| 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 | diff --git a/AUDIT_SUMMARY.md b/AUDIT_SUMMARY.md new file mode 100644 index 00000000..c9a0cd4e --- /dev/null +++ b/AUDIT_SUMMARY.md @@ -0,0 +1,57 @@ +# Audit Summary + +**Branch:** `audit/code-quality-review` +**Date:** January 4, 2026 + +## Changes Applied + +### Phase 1: Analysis Complete +- Created `AUDIT_REPORT.md` with comprehensive findings +- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code + +### Phase 2: Deprecated API Fixes (18 files) +- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance) + +**Files modified:** +- lib/screens/library_artists_screen.dart +- lib/screens/library_albums_screen.dart +- lib/screens/library_playlists_screen.dart +- lib/screens/library_tracks_screen.dart +- lib/screens/artist_details_screen.dart +- lib/screens/new_home_screen.dart +- lib/screens/login_screen.dart +- lib/screens/settings_screen.dart +- lib/screens/album_details_screen.dart +- lib/screens/search_screen.dart +- lib/screens/new_library_screen.dart + +### Phase 3: Scroll Performance Optimization +- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances + +**lib/screens/new_library_screen.dart:** +- Line 1400-1401: Author list tile image (128x128 cache) +- Line 1487-1488: Author grid card image (256x256 cache) +- Line 1577-1578: Audiobook list tile cover (256x256 cache) +- Line 1749-1750: Audiobook grid cover (512x512 cache) +- Line 1970-1971: Series list tile cover (256x256 cache) +- Line 2986-2987: Album list tile cover (256x256 cache) + +## Recommendations NOT Implemented (Low Priority) + +These were identified but NOT changed to preserve functionality: + +1. **View mode cycling refactor** - 8 similar functions could be consolidated +2. **Color constant extraction** - Duplicated colors in 3 files +3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact) +4. **BorderRadius.circular const** - 40+ instances + +## Verification + +Changes should be verified by: +1. GitHub Actions build (triggered automatically) +2. Manual UI testing of scroll smoothness +3. Verify no visual regressions in dark/light themes + +## Build Trigger + +GitHub Actions workflow triggered on branch `audit/code-quality-review` diff --git a/COMPARISON_REPORT.md b/COMPARISON_REPORT.md new file mode 100644 index 00000000..176af093 --- /dev/null +++ b/COMPARISON_REPORT.md @@ -0,0 +1,255 @@ +# Repository Comparison: Fork vs Upstream + +**Date:** January 12, 2026 +**Fork:** R00S/Ensemble---remote-access-testing +**Upstream:** CollotsSpot/Ensemble + +--- + +## Version Status + +| Repository | Version | Status | +|------------|---------|--------| +| Fork | v2.7.3-beta | Remote access testing build (experimental) | +| Upstream | v2.8.7-beta+45 | Production build with recent improvements | + +**Divergence:** Fork is approximately 40+ commits behind upstream + +--- + +## Unique Features in Fork + +### 1. WebRTC Remote Access (Experimental) +**Status:** ⚠️ Alpha - Not functional for audio playback + +**Implementation:** +- ~320 lines of code in separate directories +- WebRTC transport layer for MA API communication +- QR code scanner for Remote Access IDs +- Dependencies: `flutter_webrtc: ^0.9.48`, `mobile_scanner: ^3.5.5` + +**What Works:** +- ✅ API communication over WebRTC +- ✅ Control remote players +- ✅ Browse library remotely +- ✅ Queue management + +**What Doesn't Work:** +- ❌ Local audio playback on phone over WebRTC +- ❌ Register as player device +- ❌ Sendspin audio streaming + +**Root Cause:** +Sendspin requires direct WebSocket connection for PCM audio streaming. WebRTC data channels cannot efficiently proxy WebSocket connections for real-time audio. + +**Recommended Solution:** +Use Cloudflare Tunnel or similar reverse proxy instead of WebRTC. + +**Files Added:** +``` +lib/services/remote/ + - signaling.dart + - remote_access_manager.dart + - transport.dart + - websocket_bridge_transport.dart + - webrtc_transport.dart +lib/screens/remote/ + - remote_access_login_screen.dart + - qr_scanner_screen.dart +docs/ + - REMOTE_ACCESS.md + - REMOTE_ACCESS_FIXES.md + - REMOTE_ACCESS_INTEGRATION.md + - REMOTE_ACCESS_RESEARCH.md + - REMOTE_ACCESS_STATUS.md + - REMOTE_ACCESS_SUMMARY.md +``` + +--- + +## Features in Upstream (Not in Fork) + +### Recent Improvements (Since Fork Divergence) + +#### UI/UX Enhancements +1. **Animated sliding highlight** on search filter bar +2. **Hero animations** for playlist displays across all screens +3. **Favorite podcasts row** on home screen +4. **Welcome screen** with guided onboarding +5. **Multi-room grouping** - Long-press player to sync +6. **Volume precision mode** - Hold slider for fine-grained control +7. **Power control** - Turn players on/off from mini player +8. **Swipe to delete** tracks from queue +9. **Instant drag handles** for queue reordering +10. **Letter scrollbar** for fast navigation in long lists + +#### Bug Fixes +1. **Queue sync issues** fixed (issue #30) +2. **Favorite artists** not showing on startup fixed (issue #41) +3. **Podcast player** - correct podcast name and text overlap fixed +4. **Language fallback** to English fixed + +#### New Features +1. **French translation** support +2. **Radio stations** with list/grid view +3. **Podcast episodes** with descriptions and publish dates +4. **High-resolution artwork** via iTunes for podcasts +5. **Skip controls** for podcast playback +6. **Long-press quick actions** on search results + +#### Performance & Code Quality +1. **Material 3 compliance** - Replaced deprecated `colorScheme.background` with `colorScheme.surface` +2. **Image cache optimization** - Added `memCacheWidth`/`memCacheHeight` for scroll performance +3. **Queue animation isolation** - Prevents full player rebuilds +4. **Home screen row conformity** fixes + +#### Dependencies +1. **flutter_secure_storage: ^9.2.2** - Added for secure credential storage + +--- + +## Dependency Comparison + +### Fork Only +- `flutter_webrtc: ^0.9.48` - WebRTC for remote access +- `mobile_scanner: ^3.5.5` - QR code scanning + +### Upstream Only +- `flutter_secure_storage: ^9.2.2` - Secure storage for credentials + +### Common Dependencies +All other dependencies are identical between fork and upstream. + +--- + +## Commit Activity Comparison + +| Repository | Recent Commits (Dec 2025 - Jan 2026) | Focus Areas | +|------------|--------------------------------------|-------------| +| Fork | 2 commits | Remote access experimentation | +| Upstream | 40+ commits | UI polish, bug fixes, features, translations | + +--- + +## File Count Comparison + +| Repository | Dart Files | +|------------|-----------| +| Fork | 97 | +| Upstream | 104 | + +**Difference:** Upstream has 7 more Dart files, likely from new features (welcome screen, podcast enhancements, etc.) + +--- + +## Documentation Comparison + +### Fork Documentation (13 files) +- Extensive remote access documentation (6 files) +- Implementation plans and summaries +- License attribution +- Release notes +- SEARCH-IMPROVEMENTS.md + +### Upstream Documentation (1 file) +- AUDIOBOOKS_IMPLEMENTATION_PLAN.md +- AUDIT_SUMMARY.md +- AUDIT_REPORT.md +- AUDIT_PROMPT.md + +**Note:** Upstream has audit documentation showing professional code review process. + +--- + +## Feature Parity Analysis + +### Features Fork Lacks (Present in Upstream) + +**High Priority:** +- Queue sync fixes (issue #30) - Important bug fix +- Favorite artists startup fix (issue #41) - Important bug fix +- Language fallback and French translation +- Material 3 API compliance (deprecated API fixes) + +**Medium Priority:** +- Animated UI transitions (search filters, playlists) +- Favorite podcasts row +- Podcast player fixes +- Radio station improvements +- Welcome screen for new users +- Multi-room grouping UI +- Volume precision mode +- Power control from mini player + +**Low Priority (Polish):** +- Various home screen layout tweaks +- Library type bar redesign +- Swipe gestures for media type switching +- Long-press quick actions + +### Features Upstream Lacks (Present in Fork) + +**Experimental:** +- WebRTC remote access (non-functional for audio playback) +- QR code scanner for Remote Access IDs +- Extensive remote access documentation + +**Status:** These features are experimental and not recommended for production use due to fundamental limitations. + +--- + +## Recommendations + +### For Fork Maintainer + +**Priority 1: Merge Upstream Improvements** +1. Update to v2.8.7-beta+45 +2. Integrate queue sync fixes (issue #30) +3. Integrate favorite artists fix (issue #41) +4. Update deprecated Material 3 APIs +5. Add French translation support + +**Priority 2: Remote Access Strategy** +1. Document Cloudflare Tunnel as recommended solution +2. Keep WebRTC implementation as reference/research +3. Update README to clearly state WebRTC limitations +4. Consider removing WebRTC code if not planning to support "remote control only" mode + +**Priority 3: Feature Integration** +1. Animated UI transitions +2. Welcome screen +3. Podcast improvements +4. Multi-room grouping + +### For Upstream Developer + +**Information to Consider:** +1. Remote access requests can be directed to reverse proxy solutions (Cloudflare Tunnel) +2. WebRTC approach is not viable for mobile player devices due to Sendspin architecture +3. Fork's remote access research is available as reference if needed + +**No Action Required:** +The fork is experimental and doesn't contain production-ready features for upstream integration. + +--- + +## Conclusion + +**Fork Status:** +- Experimental remote access implementation (non-functional for core use case) +- Behind upstream by ~40 commits +- Missing important bug fixes and features +- Good documentation of remote access research + +**Upstream Status:** +- Active development with regular improvements +- Strong focus on UI polish and user experience +- Professional code quality (audit process, Material 3 compliance) +- Growing feature set (podcasts, radio, translations) + +**Value Exchange:** +- Fork → Upstream: Remote access research and documentation (what doesn't work and why) +- Upstream → Fork: Bug fixes, features, UI improvements, code quality enhancements + +**Recommendation:** +Fork should prioritize merging upstream improvements. Remote access feature should be documented as "not recommended" with clear guidance toward reverse proxy solutions. diff --git a/EMAIL_DRAFT_CONCISE.md b/EMAIL_DRAFT_CONCISE.md new file mode 100644 index 00000000..0bf8af96 --- /dev/null +++ b/EMAIL_DRAFT_CONCISE.md @@ -0,0 +1,28 @@ +Subject: Fork Research - Remote Access Testing & Findings + +Hi CollotsSpot, + +I've been experimenting with adding remote access to Ensemble in a fork (R00S/Ensemble---remote-access-testing). After testing, I wanted to share some findings that might be useful for you. + +Main Finding + +I implemented WebRTC-based remote access (similar to MA's desktop companion). The approach works well for API communication (browsing library, controlling remote players), but I couldn't get audio playback working on the phone itself with this method. Sendspin's audio streaming requires a direct WebSocket connection to the MA server, which proved difficult to proxy through WebRTC data channels for real-time PCM audio. I abandoned this approach without exploring other potential solutions. + +For practical remote access, Cloudflare Tunnel or similar reverse proxy solutions work well and give users full functionality including local playback. + +Why I'm Reaching Out + +If users request remote access capability, you might find it helpful to know that reverse proxy solutions (Cloudflare Tunnel, Nginx Proxy Manager, etc.) work well. I've documented the technical details in the fork's /docs/REMOTE_ACCESS_*.md files if they're ever useful. + +Observations + +I noticed the recent improvements in the main repo - the animated sliding highlights on search filters, queue sync fixes (issue #30), French translation support, favorite podcasts row, and the code audit work (Material 3 API updates, scroll performance). Nice work on all the polish. + +No Action Needed + +This is purely informational. The fork was a learning experiment, and I wanted to share the findings in case it saves you time answering user questions about remote connectivity. + +Best, +[Your Name] + +P.S. - The fork is at https://github.com/R00S/Ensemble---remote-access-testing if you're curious. The main takeaway is that reverse proxy solutions work well for remote access. diff --git a/EMAIL_TO_UPSTREAM.md b/EMAIL_TO_UPSTREAM.md new file mode 100644 index 00000000..a41026ea --- /dev/null +++ b/EMAIL_TO_UPSTREAM.md @@ -0,0 +1,69 @@ +Subject: Fork Research - WebRTC Remote Access Implementation & Testing Feedback + +Hi CollotsSpot, + +I've been working on a fork of Ensemble (R00S/Ensemble---remote-access-testing) experimenting with WebRTC-based remote access functionality. I wanted to reach out to share some findings that might be useful for the main project. + +Quick Context + +My fork is based on an earlier version (v2.7.3-beta) and implements experimental remote access using WebRTC, similar to the Music Assistant desktop companion. After extensive testing and research, I've documented both the implementation and what I learned. + +Key Finding: Remote Access Audio Limitation + +WebRTC remote access works well for the MA API (browse library, control remote players, authentication, queue management), but I couldn't get Sendspin audio streaming working for the mobile client to act as a playback device with this approach. + +The challenge: Sendspin requires a separate WebSocket connection for PCM audio streaming with a direct network path to the MA server. I tried proxying WebSocket connections through WebRTC data channels for real-time audio but didn't pursue it further after initial difficulties. Other approaches like separate audio tunnels or HTTP proxy for audio chunks weren't explored. + +For practical remote access, Cloudflare Tunnel or similar reverse proxy solutions work well since they provide actual network connectivity. + +Implementation Details (FYI) + +The implementation added about 320 lines of code in separate directories: +- /lib/services/remote/ for WebRTC transport layer +- /lib/screens/remote/ for QR scanner and login UI +- Dependencies: flutter_webrtc, mobile_scanner + +Full documentation is available in the fork's /docs/REMOTE_ACCESS_*.md files if you're interested in the technical details. + +Observations About Your Recent Work + +I noticed you've made significant improvements since my fork diverged - queue sync fixes and error handling (issue #30), animated sliding highlight on search filters, French translation and language fallback fixes, hero animations for playlists, favorite podcasts row, home screen row conformity improvements, the audit branch with deprecated API fixes (colorScheme.background to surface), and image cache optimization for scroll performance. + +Potential Value for Upstream + +I wanted to offer this research in case it's useful: + +1. If users request remote access, you can point them to the Cloudflare Tunnel solution +2. The technical challenges with Sendspin over WebRTC are documented +3. If you ever want to support remote control only mode (no local playback), the WebRTC implementation could serve as a reference + +My Next Steps + +I'm planning to update my fork to incorporate your recent improvements (v2.8.7-beta), document the Cloudflare Tunnel setup as the recommended remote access solution, and continue testing the local playback features. + +No Pressure + +This email is purely informational. I'm not requesting any changes or expecting you to implement remote access. The fork was an experimental learning exercise, and I wanted to share what I learned in case it saves you time if users ask about remote connectivity. + +Best regards, +[Your Name] + +Technical Appendix (Optional Reading) + +For anyone interested in the technical details: + +WebRTC Implementation Scope: +- WebRTC signaling via wss://signaling.music-assistant.io/ws +- Data channel for MA API communication +- Transport adapter to make WebRTC appear as a WebSocketChannel +- QR code scanning for easy Remote ID entry + +What I Encountered with Audio Streaming: +- SendspinService expects direct WebSocket: wss://server:8095/sendspin +- WebRTC gives placeholder URL: wss://remote.music-assistant.io +- Initial attempts at proxying WebSocket over WebRTC data channel showed high latency +- PCM audio streaming requires low latency and high throughput +- I abandoned this approach without trying alternative architectures + +Working Solution: +Cloudflare Tunnel provides actual network connectivity, so all features work normally. diff --git a/README.md b/README.md index da1b5a31..02642b3f 100644 --- a/README.md +++ b/README.md @@ -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,57 +86,48 @@ This application was built with AI-assisted development using **Claude Code** an ## Screenshots
- Connection Screen - Home Screen - Album Details - Now Playing - Queue - Settings - Audiobooks - Audiobook Player + Screenshot 1 + Screenshot 2 + Screenshot 3 + Screenshot 4 + Screenshot 5 + Screenshot 6 + Screenshot 7 + Screenshot 8 + Screenshot 9 + Screenshot 10
## Download -Download the latest release from the [Releases page](https://github.com/R00S/Ensemble---remote-access-testing/releases). - -**Note:** This is a development/testing build with **experimental Remote Access features**. The WebRTC Remote Access feature is currently **not functional** for audio playback - use the Cloudflared tunnel workaround instead (see Remote Access section below). - -For a stable production build, see the [main Ensemble repository](https://github.com/CollotsSpot/Ensemble/releases). - -### Remote Access (Alpha) +Download the latest release from the [Releases page](https://github.com/CollotsSpot/Ensemble/releases). -Connect to your Music Assistant server from anywhere - no port forwarding or VPN required. +## Setup -**Status:** ⚠️ Alpha - WebRTC implementation not functional for audio playback +1. Launch the app +2. Enter your Music Assistant server URL +3. Connect to your server +4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player. -**Current Limitation:** -The WebRTC Remote Access feature currently **does not work** for audio playback. While the MA API connection works, the app cannot register as a player device over WebRTC due to architectural limitations (Sendspin audio streaming requires a separate WebSocket connection that cannot be established through the WebRTC data channel with the current implementation). +### Finding Your Server URL -**✅ Recommended Workaround: Cloudflared Tunnel** +**Important:** You need the **Music Assistant** URL, not your Home Assistant URL. -For remote access, use **Cloudflare Tunnel** to expose your Music Assistant server: +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`) -1. Set up [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/) to expose your MA server -2. Get your cloudflare tunnel URL (e.g., `https://ma.yourdomain.com`) -3. In the app, tap "Connect via URL" (not "Connect via Remote Access") -4. Enter your cloudflare URL -5. Authenticate with your MA credentials +### Home Assistant Add-on Users -This provides: -- ✅ Full remote access to your Music Assistant server -- ✅ Audio playback on your phone works -- ✅ All features functional (library, playback, control) -- ✅ Secure connection through Cloudflare +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 -**For developers:** See [Remote Access Research](docs/REMOTE_ACCESS_RESEARCH.md) for technical details and potential future solutions. +### Remote Access -## Setup - -1. Launch the app -2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`) -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. +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 @@ -134,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) diff --git a/README_COMPARISON.md b/README_COMPARISON.md new file mode 100644 index 00000000..778c9bb8 --- /dev/null +++ b/README_COMPARISON.md @@ -0,0 +1,143 @@ +# Summary: Upstream Comparison Complete + +## What Was Done + +I've compared your fork (R00S/Ensemble---remote-access-testing) with the upstream repository (CollotsSpot/Ensemble) and created documentation to help you communicate useful information to the upstream developer. + +## Files Created + +### 1. **EMAIL_DRAFT_CONCISE.md** ⭐ RECOMMENDED +**Use this one for sending to the developer** + +This is a short, friendly email that: +- Explains your remote access research findings +- Notes the key limitation (WebRTC doesn't work for audio playback) +- Recommends reverse proxy solutions as the practical alternative +- Acknowledges their recent good work +- Makes it clear no action is needed from them + +**Length:** ~350 words +**Tone:** Professional, helpful, non-pushy +**Technical level:** Accessible to any developer + +### 2. **EMAIL_TO_UPSTREAM.md** +Extended version with technical appendix + +Use this if the developer replies asking for more technical details. It includes: +- Everything from the concise version +- Technical appendix with implementation details +- Architecture analysis of why WebRTC fails +- Alternative solutions considered + +**Length:** ~900 words +**Tone:** Technical but still friendly +**Technical level:** Detailed architecture discussion + +### 3. **COMPARISON_REPORT.md** +Comprehensive comparison document + +This is for your own reference. It contains: +- Version comparison (v2.7.3-beta vs v2.8.7-beta+45) +- Complete feature parity analysis +- List of 40+ upstream commits you're missing +- Detailed breakdown of what each repository has +- Recommendations for next steps + +**Length:** ~1500 words +**Purpose:** Internal reference and planning + +## Key Findings Summary + +### Your Fork +- **Unique feature:** WebRTC remote access implementation (~320 lines) +- **Status:** Experimental, doesn't work for audio playback +- **Version:** v2.7.3-beta (behind upstream by ~40 commits) +- **Main limitation:** Sendspin audio streaming can't work over WebRTC data channels + +### Upstream +- **Version:** v2.8.7-beta+45 (actively maintained) +- **Recent improvements:** Animated UI, French translation, queue fixes, podcast features +- **Code quality:** Professional (Material 3 compliance, performance optimizations) +- **Activity:** 40+ commits since your fork diverged + +### What's Useful for Upstream +The main value you can offer is **research documentation**: +- You've thoroughly tested WebRTC remote access +- You've documented why it doesn't work (architectural limitation) +- You can recommend the practical solution (reverse proxy) +- This saves them time if users request remote access features + +## How to Use These Files + +### Recommended Approach + +1. **Read EMAIL_DRAFT_CONCISE.md** and customize it: + - Replace `[Your Name]` with your name + - Adjust the tone if needed (it's already friendly and professional) + - Optional: Mention any specific context about your use case + +2. **Send the email** to the upstream developer: + - GitHub: Open an issue or discussion + - Email: If you have their contact + - Pull request: You could open a PR adding just the remote access research documentation + +3. **Keep COMPARISON_REPORT.md** for yourself: + - Use it to decide if you want to merge upstream changes + - Reference when planning your fork's future + - See what features you might want to adopt + +### What NOT to Do + +❌ Don't ask them to merge your WebRTC code (it doesn't work for the main use case) +❌ Don't apologize for the fork (it's completely fine!) +❌ Don't make it sound urgent or expect a response +❌ Don't include all the technical details upfront (they can ask if interested) + +### Alternative: Just Keep It Simple + +If you prefer not to send an email at all, you could: +- Keep the research in your fork as documentation +- Reference it if anyone asks about remote access +- Continue using your fork with Cloudflare Tunnel as the solution + +## What You Asked For vs What You Got + +**You asked for:** +> "compose an email (not super long and full of code and technical details) I can send to the developer with some useful information" + +**What you got:** +✅ Concise email draft (350 words, minimal technical jargon) +✅ Useful information (remote access research findings) +✅ Professional and respectful tone +✅ Extended version with details (if they want more) +✅ Comprehensive comparison report (for your reference) + +## Next Steps (Your Choice) + +**Option A: Send the Email** +1. Customize EMAIL_DRAFT_CONCISE.md +2. Send via GitHub issue/discussion or email +3. Be prepared to answer questions using EMAIL_TO_UPSTREAM.md + +**Option B: Just Keep Documentation** +1. Keep the files in your repository as reference +2. Link to them if remote access discussions come up +3. Use COMPARISON_REPORT.md to guide your fork development + +**Option C: Contribute Documentation Only** +1. Open a PR to upstream adding docs/REMOTE_ACCESS_LIMITATIONS.md +2. Document why WebRTC doesn't work +3. Recommend reverse proxy solutions + +## My Recommendation + +**Send EMAIL_DRAFT_CONCISE.md** - It's genuinely useful information presented in a friendly, non-demanding way. The upstream developer will appreciate: +- Knowing what doesn't work (saves them time) +- Having a clear answer if users ask about remote access +- Your acknowledgment of their good work + +The worst case is they don't respond (which is fine). The best case is they appreciate the research and it helps future users. + +--- + +**Note:** All three documents are in the repository root. Feel free to edit them before sending! diff --git a/android/app/build.gradle b/android/app/build.gradle index 63e9f7e2..a845c4d3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -24,29 +24,8 @@ if (flutterVersionName == null) { def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') -def hasValidKeystore = false - if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - - // Validate that the keystore file exists and can be loaded - try { - def keystoreFile = file(keystoreProperties['storeFile']) - if (keystoreFile.exists()) { - // Try to actually load the keystore to verify it's not corrupted - def keyStore = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()) - keystoreFile.withInputStream { stream -> - keyStore.load(stream, keystoreProperties['storePassword'].toCharArray()) - } - hasValidKeystore = true - println "✓ Valid keystore found, will use release signing" - } else { - println "✗ Keystore file not found: ${keystoreProperties['storeFile']}, will use debug signing" - } - } catch (Exception e) { - println "✗ Keystore validation failed: ${e.message}, will use debug signing" - hasValidKeystore = false - } } android { @@ -69,8 +48,7 @@ android { signingConfigs { release { - // Only configure release signing if we have a valid keystore - if (hasValidKeystore) { + if (keystorePropertiesFile.exists()) { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] storeFile file(keystoreProperties['storeFile']) @@ -89,8 +67,7 @@ android { buildTypes { release { - // Use release signing only if we validated the keystore successfully - signingConfig hasValidKeystore ? signingConfigs.release : signingConfigs.debug + signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1e480f30..aa84f57c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,16 +6,6 @@ - - - - - - - - - - + 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' } + } + } +} diff --git a/assets/screenshots/1.png b/assets/screenshots/1.png index 7ea7119c..6934da69 100644 Binary files a/assets/screenshots/1.png and b/assets/screenshots/1.png differ diff --git a/assets/screenshots/10.png b/assets/screenshots/10.png new file mode 100644 index 00000000..cf4f55a4 Binary files /dev/null and b/assets/screenshots/10.png differ diff --git a/assets/screenshots/2.png b/assets/screenshots/2.png index 1959ea26..9b9a163d 100644 Binary files a/assets/screenshots/2.png and b/assets/screenshots/2.png differ diff --git a/assets/screenshots/3.png b/assets/screenshots/3.png index f22d9644..f80db2ab 100644 Binary files a/assets/screenshots/3.png and b/assets/screenshots/3.png differ diff --git a/assets/screenshots/4.png b/assets/screenshots/4.png index a47beee1..3429bdc7 100644 Binary files a/assets/screenshots/4.png and b/assets/screenshots/4.png differ diff --git a/assets/screenshots/5.png b/assets/screenshots/5.png index 08661491..2522896d 100644 Binary files a/assets/screenshots/5.png and b/assets/screenshots/5.png differ diff --git a/assets/screenshots/6.png b/assets/screenshots/6.png index dd10adf7..44527915 100644 Binary files a/assets/screenshots/6.png and b/assets/screenshots/6.png differ diff --git a/assets/screenshots/7.png b/assets/screenshots/7.png index de122c9a..1de8269a 100644 Binary files a/assets/screenshots/7.png and b/assets/screenshots/7.png differ diff --git a/assets/screenshots/8.png b/assets/screenshots/8.png index 251da987..aa6e3534 100644 Binary files a/assets/screenshots/8.png and b/assets/screenshots/8.png differ diff --git a/assets/screenshots/9.png b/assets/screenshots/9.png new file mode 100644 index 00000000..f2fcca09 Binary files /dev/null and b/assets/screenshots/9.png differ diff --git a/lib/constants/hero_tags.dart b/lib/constants/hero_tags.dart index 3f30597e..08b1d703 100644 --- a/lib/constants/hero_tags.dart +++ b/lib/constants/hero_tags.dart @@ -25,4 +25,22 @@ class HeroTags { /// Audiobook author image hero tag prefix static const String authorImage = 'author_image_'; + + /// 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_'; } diff --git a/lib/database/database.dart b/lib/database/database.dart index 53490bfc..b2110b57 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -25,6 +25,8 @@ class Profiles extends Table { } /// Recently played items - scoped by profile +@TableIndex(name: 'idx_recently_played_profile', columns: {#profileUsername}) +@TableIndex(name: 'idx_recently_played_profile_played', columns: {#profileUsername, #playedAt}) class RecentlyPlayed extends Table { /// Auto-incrementing ID IntColumn get id => integer().autoIncrement()(); @@ -55,6 +57,8 @@ class RecentlyPlayed extends Table { } /// Cached library items for fast startup +@TableIndex(name: 'idx_library_cache_type', columns: {#itemType}) +@TableIndex(name: 'idx_library_cache_type_deleted', columns: {#itemType, #isDeleted}) class LibraryCache extends Table { /// Composite key: provider + item_id TextColumn get cacheKey => text()(); @@ -139,6 +143,8 @@ class CachedPlayers extends Table { } /// Cached queue items for selected player +@TableIndex(name: 'idx_cached_queue_player', columns: {#playerId}) +@TableIndex(name: 'idx_cached_queue_player_position', columns: {#playerId, #position}) class CachedQueue extends Table { /// Auto-incrementing ID for ordering IntColumn get id => integer().autoIncrement()(); @@ -185,7 +191,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 4; + int get schemaVersion => 5; @override MigrationStrategy get migration { @@ -208,6 +214,18 @@ class AppDatabase extends _$AppDatabase { if (from < 4) { await m.createTable(searchHistory); } + // Migration from v4 to v5: Add indexes for query performance + if (from < 5) { + // RecentlyPlayed indexes + await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile ON recently_played (profile_username)'); + await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile_played ON recently_played (profile_username, played_at)'); + // LibraryCache indexes + await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type ON library_cache (item_type)'); + await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type_deleted ON library_cache (item_type, is_deleted)'); + // CachedQueue indexes + await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)'); + await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)'); + } }, ); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 110d82f4..af06370d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -68,6 +68,12 @@ "tracks": "Songs", "playlists": "Wiedergabelisten", "audiobooks": "Hörbücher", + "music": "Musik", + "radio": "Radio", + "stations": "Sender", + "selectLibrary": "Bibliothek auswählen", + "noRadioStations": "Keine Radiosender", + "addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu", "searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.", "queue": "Warteschlange", @@ -197,6 +203,7 @@ "english": "Englisch", "german": "Deutsch", "spanish": "Spanisch", + "french": "Französisch", "noTracksInPlaylist": "Keine Songs in Wiedergabeliste", "sortAlphabetically": "Alphabetisch sortieren", @@ -287,5 +294,6 @@ "showHints": "Hinweise anzeigen", "showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen", "pullToSelectPlayers": "Ziehen um Geräte auszuwählen", - "holdToSync": "Gerät gedrückt halten zum Synchronisieren" + "holdToSync": "Gedrückt halten zum Sync", + "swipeToAdjustVolume": "Wischen für Lautstärke" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a7b0b10..cbf43ee8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" @@ -316,6 +331,36 @@ "description": "Audiobooks category" }, + "music": "Music", + "@music": { + "description": "Music category" + }, + + "radio": "Radio", + "@radio": { + "description": "Radio category" + }, + + "stations": "Stations", + "@stations": { + "description": "Radio stations subcategory" + }, + + "selectLibrary": "Select Library", + "@selectLibrary": { + "description": "Title for library type selection bottom sheet" + }, + + "noRadioStations": "No radio stations", + "@noRadioStations": { + "description": "Empty state when no radio stations are available" + }, + + "addRadioStationsHint": "Add radio stations in Music Assistant", + "@addRadioStationsHint": { + "description": "Hint for adding radio stations" + }, + "searchFailed": "Search failed. Please check your connection.", "@searchFailed": { "description": "Search error message" @@ -630,6 +675,16 @@ "description": "Radio button label" }, + "playingRadioStation": "Playing {name}", + "@playingRadioStation": { + "description": "Status when playing radio station", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "addedToQueue": "Added to queue", "@addedToQueue": { "description": "Confirmation when item added to queue" @@ -1005,6 +1060,11 @@ "description": "Spanish language" }, + "french": "French", + "@french": { + "description": "French language" + }, + "noTracksInPlaylist": "No tracks in playlist", "sortAlphabetically": "Sort alphabetically", "sortByYear": "Sort by year", @@ -1022,10 +1082,16 @@ "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", "removedFromFavorites": "Removed from favorites", + "addedToLibrary": "Added to library", + "removedFromLibrary": "Removed from library", + "addToLibrary": "Add to library", "unknown": "Unknown", "noUpcomingTracks": "No upcoming tracks", "showAll": "Show all", @@ -1036,6 +1102,10 @@ "shows": "Shows", "podcasts": "Podcasts", "podcastSupportComingSoon": "Podcast support coming soon", + "noPodcasts": "No podcasts", + "addPodcastsHint": "Subscribe to podcasts in Music Assistant", + "episodes": "Episodes", + "episode": "Episode", "playlist": "Playlist", "connectionError": "Connection Error", "twoColumnGrid": "2-column grid", @@ -1168,6 +1238,11 @@ "description": "Singular form of track for search type indicator" }, + "podcastSingular": "Podcast", + "@podcastSingular": { + "description": "Singular form of podcast for search type indicator" + }, + "trackCount": "{count} {count, plural, =1{track} other{tracks}}", "@trackCount": { "description": "Track count with plural", @@ -1273,11 +1348,16 @@ "description": "Hint shown when mini player bounces" }, - "holdToSync": "Tap and hold a player to sync", + "holdToSync": "Long-press to sync", "@holdToSync": { "description": "Hint for long-press to sync players" }, + "swipeToAdjustVolume": "Swipe to adjust volume", + "@swipeToAdjustVolume": { + "description": "Hint for swiping left/right to adjust player volume" + }, + "selectPlayerHint": "Choose a player, or dismiss by swiping down", "@selectPlayerHint": { "description": "First-time hint for selecting a player in device selector" @@ -1301,5 +1381,25 @@ "dismissPlayerHint": "Swipe down, tap outside, or press back to return", "@dismissPlayerHint": { "description": "Hint explaining how to dismiss the player selector" + }, + + "playingAlbum": "Playing {albumName}", + "@playingAlbum": { + "description": "Message shown when starting to play an album", + "placeholders": { + "albumName": { + "type": "String" + } + } + }, + + "playingPlaylist": "Playing {playlistName}", + "@playingPlaylist": { + "description": "Message shown when starting to play a playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 60a9c16e..4b6e0c19 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -66,6 +66,12 @@ "tracks": "Canciones", "playlists": "Listas de reproducción", "audiobooks": "Audiolibros", + "music": "Música", + "radio": "Radio", + "stations": "Emisoras", + "selectLibrary": "Seleccionar Biblioteca", + "noRadioStations": "No hay emisoras de radio", + "addRadioStationsHint": "Añade emisoras de radio en Music Assistant", "searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.", "queue": "Cola", "playerQueue": "Cola de {playerName}", @@ -354,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", @@ -483,5 +490,6 @@ "showHints": "Mostrar Sugerencias", "showHintsDescription": "Mostrar consejos útiles para descubrir funciones", "pullToSelectPlayers": "Desliza para seleccionar dispositivos", - "holdToSync": "Mantén presionado un dispositivo para sincronizar" + "holdToSync": "Mantén presionado para sync", + "swipeToAdjustVolume": "Desliza para ajustar volumen" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 00000000..575fe0c4 --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -0,0 +1,1400 @@ +{ + "@@locale": "fr", + + "appTitle": "Ensemble", + "@appTitle": { + "description": "The application title" + }, + + "connecting": "Connexion...", + "@connecting": { + "description": "Connection status when connecting" + }, + + "connected": "Connecté", + "@connected": { + "description": "Connection status when connected" + }, + + "disconnected": "Déconnecté", + "@disconnected": { + "description": "Connection status when disconnected" + }, + + "serverAddress": "Adresse du serveur", + "@serverAddress": { + "description": "Login screen server address field label" + }, + + "serverAddressHint": "ex., music.example.com ou 192.168.1.100", + "@serverAddressHint": { + "description": "Hint text for server address field" + }, + + "yourName": "Votre nom", + "@yourName": { + "description": "Login screen name field label" + }, + + "yourFirstName": "Votre prénom", + "@yourFirstName": { + "description": "Hint for name field" + }, + + "portOptional": "Port (Facultatif)", + "@portOptional": { + "description": "Port field label" + }, + + "portHint": "ex., 8095 ou laisser vide", + "@portHint": { + "description": "Hint for port field" + }, + + "portDescription": "Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.", + "@portDescription": { + "description": "Description for port field" + }, + + "username": "Nom d'utilisateur", + "@username": { + "description": "Username field label" + }, + + "password": "Mot de passe", + "@password": { + "description": "Password field label" + }, + + "detectAndConnect": "Détecter et connecter", + "@detectAndConnect": { + "description": "Button to detect auth and connect" + }, + + "connect": "Connectér", + "@connect": { + "description": "Connect button label" + }, + + "disconnect": "Déconnectér", + "@disconnect": { + "description": "Disconnect button label" + }, + + "authServerUrlOptional": "URL du serveur d'auth (Facultatif)", + "@authServerUrlOptional": { + "description": "Auth server URL field label" + }, + + "authServerUrlHint": "ex., auth.example.com (si different du serveur)", + "@authServerUrlHint": { + "description": "Hint for auth server URL" + }, + + "authServerUrlDescription": "Laisser vide si l'authentification est sur le meme serveur", + "@authServerUrlDescription": { + "description": "Description for auth server URL field" + }, + + "détectédAuthType": "Detecte : {authType}", + "@détectédAuthType": { + "description": "Shows détectéd authentication type", + "placeholders": { + "authType": { + "type": "String" + } + } + }, + + "serverRequiresAuth": "Ce serveur requiert une authentification", + "@serverRequiresAuth": { + "description": "Message when server requires auth" + }, + + "tailscaleVpnConnection": "Connexion VPN Tailscale", + "@tailscaleVpnConnection": { + "description": "Tailscale connection type" + }, + + "unencryptedConnection": "Connexion non chiffrée", + "@unencryptedConnection": { + "description": "Unencrypted connection warning" + }, + + "usingHttpOverTailscale": "Utilisation de HTTP via Tailscale (tunnel chiffre)", + "@usingHttpOverTailscale": { + "description": "HTTP over Tailscale description" + }, + + "httpsFailedUsingHttp": "HTTPS a échoué, utilisation de HTTP en repli", + "@httpsFailedUsingHttp": { + "description": "HTTP fallback message" + }, + + "httpNotEncrypted": "Connexion HTTP - les donnees ne sont pas chiffrées", + "@httpNotEncrypted": { + "description": "HTTP not encrypted warning" + }, + + "pleaseEnterServerAddress": "Veuillez entrer l'adresse de votre serveur Music Assistant", + "@pleaseEnterServerAddress": { + "description": "Validation message for server address" + }, + + "pleaseEnterName": "Veuillez entrer votre nom", + "@pleaseEnterName": { + "description": "Validation message for name" + }, + + "pleaseEnterValidPort": "Veuillez entrer un numero de port valide (1-65535)", + "@pleaseEnterValidPort": { + "description": "Validation message for port" + }, + + "pleaseEnterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe", + "@pleaseEnterCredentials": { + "description": "Validation message for credentials" + }, + + "authFailed": "Echec de l'authentification. Veuillez verifier vos identifiants.", + "@authFailed": { + "description": "Authentication failed error" + }, + + "maLoginFailed": "Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.", + "@maLoginFailed": { + "description": "MA login failed error" + }, + + "connectionFailed": "Impossible de se connecter au serveur. Veuillez verifier l'adresse et reessayer.", + "@connectionFailed": { + "description": "Connection failed error" + }, + + "detectingAuth": "Detection de l'authentification...", + "@detectingAuth": { + "description": "Status when detecting auth" + }, + + "cannotDetermineAuth": "Impossible de determiner les exigences d'authentification. Veuillez verifier l'URL du serveur.", + "@cannotDetermineAuth": { + "description": "Error when auth detection fails" + }, + + "noAuthentication": "Pas d'authentification", + "@noAuthentication": { + "description": "Auth type: none" + }, + + "httpBasicAuth": "HTTP Basic Auth", + "@httpBasicAuth": { + "description": "Auth type: HTTP Basic" + }, + + "authelia": "Authelia", + "@authelia": { + "description": "Auth type: Authelia" + }, + + "musicAssistantLogin": "Connexion Music Assistant", + "@musicAssistantLogin": { + "description": "Auth type: MA Login" + }, + + "pressBackToMinimize": "Appuyez a nouveau sur retour pour minimiser", + "@pressBackToMinimize": { + "description": "Back button toast message" + }, + + "recentlyPlayed": "Ecoutes recemment", + "@recentlyPlayed": { + "description": "Home screen section title" + }, + + "discoverArtists": "Découvrir des artistes", + "@discoverArtists": { + "description": "Home screen section title" + }, + + "discoverAlbums": "Découvrir des albums", + "@discoverAlbums": { + "description": "Home screen section title" + }, + + "continueListening": "Continuer l'écoute", + "@continueListening": { + "description": "Home screen section title" + }, + + "discoverAudiobooks": "Découvrir des livres audio", + "@discoverAudiobooks": { + "description": "Home screen section title" + }, + + "discoverSeries": "Découvrir des séries", + "@discoverSeries": { + "description": "Home screen section title" + }, + + "favoriteAlbums": "Albums favoris", + "@favoriteAlbums": { + "description": "Home screen section title" + }, + + "favoriteArtists": "Artistes favoris", + "@favoriteArtists": { + "description": "Home screen section title" + }, + + "favoriteTracks": "Pistes favorites", + "@favoriteTracks": { + "description": "Home screen section title" + }, + + "favoritePlaylists": "Playlists favorites", + "@favoritePlaylists": { + "description": "Home screen section title" + }, + + "favoriteRadioStations": "Stations de radio favorites", + "@favoriteRadioStations": { + "description": "Home screen section title" + }, + + "favoritePodcasts": "Podcasts favoris", + "@favoritePodcasts": { + "description": "Home screen section title" + }, + + "searchMusic": "Rechercher de la musique...", + "@searchMusic": { + "description": "Search placeholder text" + }, + + "searchForContent": "Rechercher des artistes, albums ou pistes", + "@searchForContent": { + "description": "Search hint text" + }, + + "recentSearches": "Recherches recentes", + "@recentSearches": { + "description": "Label for recent search history section" + }, + + "clearSearchHistory": "Effacer l'historique de recherche", + "@clearSearchHistory": { + "description": "Button to clear search history" + }, + + "searchHistoryCleared": "Historique de recherche efface", + "@searchHistoryCleared": { + "description": "Confirmation message after clearing search history" + }, + + "libraryOnly": "Bibliothèque uniquement", + "@libraryOnly": { + "description": "Toggle to search only in library" + }, + + "retry": "Reessayer", + "@retry": { + "description": "Retry button label" + }, + + "all": "Tout", + "@all": { + "description": "Filter option: All" + }, + + "artists": "Artistes", + "@artists": { + "description": "Artists category" + }, + + "albums": "Albums", + "@albums": { + "description": "Albums category" + }, + + "tracks": "Pistes", + "@tracks": { + "description": "Tracks category" + }, + + "playlists": "Playlists", + "@playlists": { + "description": "Playlists category" + }, + + "audiobooks": "Livres audio", + "@audiobooks": { + "description": "Audiobooks category" + }, + + "music": "Musique", + "@music": { + "description": "Music category" + }, + + "radio": "Radio", + "@radio": { + "description": "Radio category" + }, + + "stations": "Stations", + "@stations": { + "description": "Radio stations subcategory" + }, + + "selectLibrary": "Sélectionner la bibliothèque", + "@selectLibrary": { + "description": "Title for library type selection bottom sheet" + }, + + "noRadioStations": "Aucune station de radio", + "@noRadioStations": { + "description": "Empty state when no radio stations are available" + }, + + "addRadioStationsHint": "Ajouter des stations de radio dans Music Assistant", + "@addRadioStationsHint": { + "description": "Hint for adding radio stations" + }, + + "searchFailed": "La recherche a échoué. Veuillez verifier votre connexion.", + "@searchFailed": { + "description": "Search error message" + }, + + "queue": "File d'attente", + "@queue": { + "description": "Queue screen title" + }, + + "playerQueue": "File d'attente de {playerName}", + "@playerQueue": { + "description": "Queue title with player name", + "placeholders": { + "playerName": { + "type": "String" + } + } + }, + + "noPlayerSelected": "Aucun lecteur selectionne", + "@noPlayerSelected": { + "description": "Message when no player is selected" + }, + + "undo": "Annuler", + "@undo": { + "description": "Undo action" + }, + + "removedItem": "{itemName} supprimé", + "@removedItem": { + "description": "Snackbar message when item removed", + "placeholders": { + "itemName": { + "type": "String" + } + } + }, + + "settings": "Paramètres", + "@settings": { + "description": "Settings screen title" + }, + + "debugLogs": "Journaux de debogage", + "@debugLogs": { + "description": "Debug logs section" + }, + + "themeMode": "Mode de theme", + "@themeMode": { + "description": "Theme mode setting" + }, + + "light": "Clair", + "@light": { + "description": "Light theme" + }, + + "dark": "Sombre", + "@dark": { + "description": "Dark theme" + }, + + "system": "Système", + "@system": { + "description": "System theme" + }, + + "pullToRefresh": "Tirez pour rafraichir la bibliothèque et appliquer les modifications", + "@pullToRefresh": { + "description": "Refresh hint message" + }, + + "viewDebugLogs": "Voir les journaux de debogage", + "@viewDebugLogs": { + "description": "Button to view debug logs" + }, + + "viewAllPlayers": "Voir tous les lecteurs", + "@viewAllPlayers": { + "description": "Button to view all players" + }, + + "copyLogs": "Copier les journaux", + "@copyLogs": { + "description": "Button to copy logs" + }, + + "clearLogs": "Effacer les journaux", + "@clearLogs": { + "description": "Button to clear logs" + }, + + "copyList": "Copier la liste", + "@copyList": { + "description": "Button to copy list" + }, + + "close": "Fermer", + "@close": { + "description": "Close button" + }, + + "allPlayersCount": "Tous les lecteurs ({count})", + "@allPlayersCount": { + "description": "Dialog title with player count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "errorLoadingPlayers": "Erreur lors du chargement des lecteurs : {error}", + "@errorLoadingPlayers": { + "description": "Error loading players", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "logsCopied": "Journaux copies dans le presse-papiers", + "@logsCopied": { + "description": "Confirmation when logs copied" + }, + + "logsCleared": "Journaux effaces", + "@logsCleared": { + "description": "Confirmation when logs cleared" + }, + + "playerListCopied": "Liste des lecteurs copiee dans le presse-papiers !", + "@playerListCopied": { + "description": "Confirmation when player list copied" + }, + + "noLogsYet": "Pas encore de journaux", + "@noLogsYet": { + "description": "Empty logs message" + }, + + "infoPlus": "Info+", + "@infoPlus": { + "description": "Log filter: Info+" + }, + + "warnings": "Avertissements", + "@warnings": { + "description": "Log filter: Warnings" + }, + + "errors": "Erreurs", + "@errors": { + "description": "Log filter: Errors" + }, + + "showingEntries": "Affichage de {filtered} sur {total} entrées", + "@showingEntries": { + "description": "Entries count display", + "placeholders": { + "filtered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + + "thisDevice": "Cet appareil", + "@thisDevice": { + "description": "Label for current device" + }, + + "ghostPlayer": "Lecteur fantome (doublon)", + "@ghostPlayer": { + "description": "Warning for ghost player" + }, + + "unavailableCorrupt": "Indisponible/Corrompu", + "@unavailableCorrupt": { + "description": "Warning for corrupt player" + }, + + "playerId": "ID : {id}", + "@playerId": { + "description": "Player ID display", + "placeholders": { + "id": { + "type": "String" + } + } + }, + + "playerInfo": "Disponible : {available} | Fournisseur : {provider}", + "@playerInfo": { + "description": "Player info display", + "placeholders": { + "available": { + "type": "String" + }, + "provider": { + "type": "String" + } + } + }, + + "shareBugReport": "Partager le rapport de bogue", + "@shareBugReport": { + "description": "Share bug report option" + }, + + "moreOptions": "Plus d'options", + "@moreOptions": { + "description": "More options menu" + }, + + "failedToUpdateFavorite": "Echec de la mise a jour du favori : {error}", + "@failedToUpdateFavorite": { + "description": "Error updating favorite", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "noPlayersAvailable": "Aucun lecteur disponible", + "@noPlayersAvailable": { + "description": "Message when no players available" + }, + + "albumAddedToQueue": "Album ajouté a la file d'attente", + "@albumAddedToQueue": { + "description": "Confirmation when album added" + }, + + "tracksAddedToQueue": "Pistes ajoutées a la file d'attente", + "@tracksAddedToQueue": { + "description": "Confirmation when tracks added" + }, + + "play": "Lire", + "@play": { + "description": "Play button label" + }, + + "noPlayersFound": "Aucun lecteur trouve", + "@noPlayersFound": { + "description": "Message when no players found" + }, + + "startingRadio": "Demarrage de la radio {name}", + "@startingRadio": { + "description": "Status when starting radio", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "failedToStartRadio": "Echec du demarrage de la radio : {error}", + "@failedToStartRadio": { + "description": "Error starting radio", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "startingRadioOnPlayer": "Demarrage de la radio {name} sur {playerName}", + "@startingRadioOnPlayer": { + "description": "Status when starting radio on player", + "placeholders": { + "name": { + "type": "String" + }, + "playerName": { + "type": "String" + } + } + }, + + "addedRadioToQueue": "Radio {name} ajoutée a la file d'attente", + "@addedRadioToQueue": { + "description": "Confirmation when radio added to queue", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "failedToAddToQueue": "Echec de l'ajout a la file d'attente : {error}", + "@failedToAddToQueue": { + "description": "Error adding to queue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "playingRadioStation": "Lecture de {name}", + "@playingRadioStation": { + "description": "Status when playing radio station", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "addedToQueue": "Ajoute a la file d'attente", + "@addedToQueue": { + "description": "Confirmation when item added to queue" + }, + + "inLibrary": "Dans la bibliothèque", + "@inLibrary": { + "description": "In library section title" + }, + + "noAlbumsFound": "Aucun album trouve", + "@noAlbumsFound": { + "description": "Empty albums message" + }, + + "playing": "Lecture de {name}", + "@playing": { + "description": "Status when playing", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "playingTrack": "Lecture de la piste", + "@playingTrack": { + "description": "Status when playing track" + }, + + "nowPlaying": "En cours de lecture", + "@nowPlaying": { + "description": "Label shown when device selector is open to indicate current player" + }, + + "markedAsFinished": "{name} marque comme termine", + "@markedAsFinished": { + "description": "Audiobook marked finished", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "failedToMarkFinished": "Echec du marquage comme termine : {error}", + "@failedToMarkFinished": { + "description": "Error marking finished", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "markedAsUnplayed": "{name} marque comme non lu", + "@markedAsUnplayed": { + "description": "Audiobook marked unplayed", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "failedToMarkUnplayed": "Echec du marquage comme non lu : {error}", + "@failedToMarkUnplayed": { + "description": "Error marking unplayed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "failedToPlay": "Echec de la lecture : {error}", + "@failedToPlay": { + "description": "Error playing", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "markAsFinished": "Marquer comme termine", + "@markAsFinished": { + "description": "Button to mark as finished" + }, + + "markAsUnplayed": "Marquer comme non lu", + "@markAsUnplayed": { + "description": "Button to mark as unplayed" + }, + + "byAuthor": "Par {author}", + "@byAuthor": { + "description": "Author attribution", + "placeholders": { + "author": { + "type": "String" + } + } + }, + + "audiobookCount": "{count} livre(s) audio", + "@audiobookCount": { + "description": "Audiobook count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "loading": "Chargement...", + "@loading": { + "description": "Loading state" + }, + + "bookCount": "{count} livre(s)", + "@bookCount": { + "description": "Book count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "books": "Livres", + "@books": { + "description": "Books section title" + }, + + "noFavoriteAudiobooks": "Aucun livre audio favori", + "@noFavoriteAudiobooks": { + "description": "Empty favorite audiobooks" + }, + + "tapHeartAudiobook": "Appuyez sur le coeur d'un livre audio pour l'ajoutér aux favoris", + "@tapHeartAudiobook": { + "description": "Hint to add audiobook favorite" + }, + + "noAudiobooks": "Aucun livre audio", + "@noAudiobooks": { + "description": "Empty audiobooks" + }, + + "addAudiobooksHint": "Ajoutez des livres audio a votre bibliothèque pour les voir ici", + "@addAudiobooksHint": { + "description": "Hint to add audiobooks" + }, + + "noFavoriteArtists": "Aucun artiste favori", + "@noFavoriteArtists": { + "description": "Empty favorite artists" + }, + + "tapHeartArtist": "Appuyez sur le coeur d'un artiste pour l'ajoutér aux favoris", + "@tapHeartArtist": { + "description": "Hint to add artist favorite" + }, + + "noFavoriteAlbums": "Aucun album favori", + "@noFavoriteAlbums": { + "description": "Empty favorite albums" + }, + + "tapHeartAlbum": "Appuyez sur le coeur d'un album pour l'ajoutér aux favoris", + "@tapHeartAlbum": { + "description": "Hint to add album favorite" + }, + + "noFavoritePlaylists": "Aucune playlist favorite", + "@noFavoritePlaylists": { + "description": "Empty favorite playlists" + }, + + "tapHeartPlaylist": "Appuyez sur le coeur d'une playlist pour l'ajoutér aux favoris", + "@tapHeartPlaylist": { + "description": "Hint to add playlist favorite" + }, + + "noFavoriteTracks": "Aucune piste favorite", + "@noFavoriteTracks": { + "description": "Empty favorite tracks" + }, + + "longPressTrackHint": "Appuyez longuement sur une piste et touchez le coeur pour l'ajoutér aux favoris", + "@longPressTrackHint": { + "description": "Hint to add track favorite" + }, + + "loadSeries": "Charger les séries", + "@loadSeries": { + "description": "Button to load séries" + }, + + "notConnectéd": "Non connecte a Music Assistant", + "@notConnectéd": { + "description": "Disconnected state title" + }, + + "notConnectédTitle": "Non connecte", + "@notConnectédTitle": { + "description": "Disconnected state short title" + }, + + "connectHint": "Connectéz-vous a votre serveur Music Assistant pour commencer a écouter", + "@connectHint": { + "description": "Hint to connect" + }, + + "configureServer": "Configurer le serveur", + "@configureServer": { + "description": "Button to configure server" + }, + + "noArtistsFound": "Aucun artiste trouve", + "@noArtistsFound": { + "description": "Empty artists" + }, + + "noTracksFound": "Aucune piste trouvee", + "@noTracksFound": { + "description": "Empty tracks" + }, + + "noPlaylistsFound": "Aucune playlist trouvee", + "@noPlaylistsFound": { + "description": "Empty playlists" + }, + + "queueIsEmpty": "La file d'attente est vide", + "@queueIsEmpty": { + "description": "Empty queue" + }, + + "noResultsFound": "Aucun resultat trouve", + "@noResultsFound": { + "description": "Empty search results" + }, + + "refresh": "Rafraichir", + "@refresh": { + "description": "Refresh button" + }, + + "debugConsole": "Console de debogage", + "@debugConsole": { + "description": "Debug console title" + }, + + "copy": "Copier", + "@copy": { + "description": "Copy button" + }, + + "clear": "Effacer", + "@clear": { + "description": "Clear button" + }, + + "noLogsToCopy": "Aucun journal a copier", + "@noLogsToCopy": { + "description": "Message when no logs to copy" + }, + + "noDebugLogsYet": "Pas encore de journaux de debogage. Essayez de détectér l'authentification.", + "@noDebugLogsYet": { + "description": "Empty debug logs hint" + }, + + "showDebug": "Afficher le debogage", + "@showDebug": { + "description": "Button to show debug" + }, + + "hideDebug": "Masquer le debogage", + "@hideDebug": { + "description": "Button to hide debug" + }, + + "chapters": "Chapitres", + "@chapters": { + "description": "Chapters panel title" + }, + + "noChapters": "Aucun chapitre", + "@noChapters": { + "description": "Empty chapters title" + }, + + "noChapterInfo": "Ce livre audio n'a pas d'informations de chapitre", + "@noChapterInfo": { + "description": "Empty chapters message" + }, + + "errorSeeking": "Erreur de positionnement : {error}", + "@errorSeeking": { + "description": "Error when seeking", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "error": "Erreur : {error}", + "@error": { + "description": "Generic error message", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "home": "Accueil", + "@home": { + "description": "Home navigation label" + }, + + "library": "Bibliothèque", + "@library": { + "description": "Library navigation label" + }, + + "homeScreen": "Ecran d'accueil", + "@homeScreen": { + "description": "Home screen settings section" + }, + + "metadataApis": "API de metadonnees", + "@metadataApis": { + "description": "Metadata APIs settings section" + }, + + "theme": "Theme", + "@theme": { + "description": "Theme settings section" + }, + + "materialYou": "Material You", + "@materialYou": { + "description": "Material You theme option" + }, + + "adaptiveTheme": "Theme adaptatif", + "@adaptiveTheme": { + "description": "Adaptive theme option" + }, + + "language": "Langue", + "@language": { + "description": "Language setting" + }, + + "english": "Anglais", + "@english": { + "description": "English language" + }, + + "german": "Allemand", + "@german": { + "description": "German language" + }, + + "spanish": "Espagnol", + "@spanish": { + "description": "Spanish language" + }, + + "french": "Français", + "@french": { + "description": "French language" + }, + + "noTracksInPlaylist": "Aucune piste dans la playlist", + "sortAlphabetically": "Trier alphabétiquement", + "sortByYear": "Trier par année", + "sortBySeriesOrder": "Trier par ordre de série", + "listView": "Vue en liste", + "gridView": "Vue en grille", + "noBooksInSeries": "Aucun livre trouve dans cette série", + "artist": "Artiste", + "showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment", + "showRandomArtists": "Afficher des artistes aléatoires a découvrir", + "showRandomAlbums": "Afficher des albums aléatoires a découvrir", + "showAudiobooksInProgress": "Afficher les livres audio en cours", + "showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir", + "showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir", + "showFavoriteAlbums": "Afficher une rangee de vos albums favoris", + "showFavoriteArtists": "Afficher une rangee de vos artistes favoris", + "showFavoriteTracks": "Afficher une rangee de vos pistes favorites", + "showFavoritePlaylists": "Afficher une rangee de vos playlists favorites", + "showFavoriteRadioStations": "Afficher une rangee de vos stations de radio favorites", + "showFavoritePodcasts": "Afficher une rangee de vos podcasts favoris", + "extractColorsFromArtwork": "Extraire les couleurs des pochettes d'albums et d'artistes", + "chooseHomeScreenRows": "Choisir les rangees a afficher sur l'ecran d'accueil", + "addedToFavorites": "Ajoute aux favoris", + "removedFromFavorites": "Retire des favoris", + "addedToLibrary": "Ajoute a la bibliothèque", + "removedFromLibrary": "Retire de la bibliothèque", + "addToLibrary": "Ajouter a la bibliothèque", + "unknown": "Inconnu", + "noUpcomingTracks": "Aucune piste a venir", + "showAll": "Tout afficher", + "showFavoritesOnly": "Afficher les favoris uniquement", + "changeView": "Changer la vue", + "authors": "Auteurs", + "séries": "Series", + "shows": "Emissions", + "podcasts": "Podcasts", + "podcastSupportComingSoon": "Prise en charge des podcasts bientot disponible", + "noPodcasts": "Aucun podcast", + "addPodcastsHint": "Abonnez-vous a des podcasts dans Music Assistant", + "episodes": "Episodes", + "episode": "Episode", + "playlist": "Playlist", + "connectionError": "Erreur de connexion", + "twoColumnGrid": "Grille a 2 colonnes", + "threeColumnGrid": "Grille a 3 colonnes", + "fromProviders": "Des fournisseurs", + + "resume": "Reprendre", + "about": "A propos", + "inProgress": "En cours", + "narratedBy": "Lu par {narrators}", + "@narratedBy": { + "placeholders": { + "narrators": { + "type": "String" + } + } + }, + "unknownNarrator": "Narrateur inconnu", + "unknownAuthor": "Auteur inconnu", + "loadingChapters": "Chargement des chapitres...", + "noChapterInfoAvailable": "Aucune information de chapitre disponible", + "percentComplete": "{percent}% termine", + "@percentComplete": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, + "theAudioDbApiKey": "Cle API TheAudioDB", + "theAudioDbApiKeyHint": "Utilisez \"2\" pour le niveau gratuit ou une cle premium", + "audiobookLibraries": "Bibliothèques de livres audio", + "chooseAudiobookLibraries": "Choisir quelles bibliothèques Audiobookshelf inclure", + "unknownLibrary": "Bibliothèque inconnue", + "noSeriesFound": "Aucune série trouvee", + "ensembleBugReport": "Rapport de bogue Ensemble", + "byOwner": "Par {owner}", + "@byOwner": { + "placeholders": { + "owner": { + "type": "String" + } + } + }, + "noSeriesAvailable": "Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.", + "pullToLoadSeries": "Tirez vers le bas pour charger les séries\ndepuis Music Assistant", + + "search": "Rechercher", + "@search": { + "description": "Search navigation label" + }, + + "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.", + "@metadataApisDescription": { + "description": "Description for metadata APIs section" + }, + + "lastFmApiKey": "Cle API Last.fm", + "@lastFmApiKey": { + "description": "Last.fm API key field label" + }, + + "lastFmApiKeyHint": "Obtenez une cle gratuite sur last.fm/api", + "@lastFmApiKeyHint": { + "description": "Hint for Last.fm API key field" + }, + + "swipeToSwitchDevice": "Balayez pour changer de lecteur", + "@swipeToSwitchDevice": { + "description": "Hint for device switching gesture" + }, + + "chapterNumber": "Chapitre {number}", + "@chapterNumber": { + "description": "Chapter number display", + "placeholders": { + "number": { + "type": "int" + } + } + }, + + "pcmAudio": "Audio PCM", + "@pcmAudio": { + "description": "PCM audio format label" + }, + + "playOn": "Lire sur...", + "@playOn": { + "description": "Bottom sheet title for selecting player" + }, + + "addAlbumToQueueOn": "Ajouter l'album a la file d'attente sur...", + "@addAlbumToQueueOn": { + "description": "Bottom sheet title for adding album to queue" + }, + + "addToQueueOn": "Ajouter a la file d'attente sur...", + "@addToQueueOn": { + "description": "Bottom sheet title for adding to queue" + }, + + "startRadioOn": "Demarrer la radio {name} sur...", + "@startRadioOn": { + "description": "Bottom sheet title for starting radio", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "book": "livre", + "@book": { + "description": "Singular form of book" + }, + + "audiobookSingular": "livre audio", + "@audiobookSingular": { + "description": "Singular form of audiobook" + }, + + "albumSingular": "Album", + "@albumSingular": { + "description": "Singular form of album for search type indicator" + }, + + "trackSingular": "Piste", + "@trackSingular": { + "description": "Singular form of track for search type indicator" + }, + + "podcastSingular": "Podcast", + "@podcastSingular": { + "description": "Singular form of podcast for search type indicator" + }, + + "trackCount": "{count, plural, =1{{count} piste} other{{count} pistes}}", + "@trackCount": { + "description": "Track count with plural", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "materialYouDescription": "Utiliser les couleurs systeme (Android 12+)", + "@materialYouDescription": { + "description": "Description for Material You theme option" + }, + + "accentColor": "Couleur d'accentuation", + "@accentColor": { + "description": "Accent color selection label" + }, + + "players": "Lecteurs", + "@players": { + "description": "Players settings section title" + }, + + "preferLocalPlayer": "Préférer le lecteur local", + "@preferLocalPlayer": { + "description": "Setting to always prefer local player" + }, + + "preferLocalPlayerDescription": "Toujours sélectionner cet appareil en premier si disponible", + "@preferLocalPlayerDescription": { + "description": "Description for prefer local player setting" + }, + + "smartSortPlayers": "Tri intelligent", + "@smartSortPlayers": { + "description": "Setting to sort players by status" + }, + + "smartSortPlayersDescription": "Trier par statut (en lecture, allume, eteint) au lieu d'alphabétiquement", + "@smartSortPlayersDescription": { + "description": "Description for smart sort players setting" + }, + + "playerStateUnavailable": "Indisponible", + "@playerStateUnavailable": { + "description": "Player state when unavailable" + }, + + "playerStateOff": "Eteint", + "@playerStateOff": { + "description": "Player state when powered off" + }, + + "playerStateIdle": "Inactif", + "@playerStateIdle": { + "description": "Player state when idle" + }, + + "playerStateExternalSource": "Source externe", + "@playerStateExternalSource": { + "description": "Player state when playing from external source (optical, Spotify, etc.)" + }, + + "playerSelected": "Selectionne", + "@playerSelected": { + "description": "Label for selected player" + }, + + "actionQueuedForSync": "Sera synchronise une fois en ligne", + "@actionQueuedForSync": { + "description": "Message when action is queued for sync while offline" + }, + + "pendingOfflineActions": "{count} en attente de synchronisation", + "@pendingOfflineActions": { + "description": "Shows number of pending offline actions", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "hintsAndTips": "Astuces et conseils", + "@hintsAndTips": { + "description": "Settings section title for hints" + }, + + "showHints": "Afficher les astuces", + "@showHints": { + "description": "Toggle label for showing hints" + }, + + "showHintsDescription": "Afficher des conseils utiles pour découvrir les fonctionnalites", + "@showHintsDescription": { + "description": "Description for show hints toggle" + }, + + "pullToSelectPlayers": "Tirez pour sélectionner les lecteurs", + "@pullToSelectPlayers": { + "description": "Hint shown when mini player bounces" + }, + + "holdToSync": "Appui long pour synchroniser", + "@holdToSync": { + "description": "Hint for long-press to sync players" + }, + + "swipeToAdjustVolume": "Balayez pour ajuster le volume", + "@swipeToAdjustVolume": { + "description": "Hint for swiping left/right to adjust player volume" + }, + + "selectPlayerHint": "Choisissez un lecteur ou fermez en balayant vers le bas", + "@selectPlayerHint": { + "description": "First-time hint for selecting a player in device selector" + }, + + "welcomeToEnsemble": "Bienvenue dans Ensemble", + "@welcomeToEnsemble": { + "description": "Welcome message title for onboarding" + }, + + "welcomeMessage": "Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.", + "@welcomeMessage": { + "description": "Welcome message body explaining how to select players" + }, + + "skip": "Passer", + "@skip": { + "description": "Button to skip onboarding or hints" + }, + + "dismissPlayerHint": "Balayez vers le bas, touchez a l'exterieur ou appuyez sur retour pour revenir", + "@dismissPlayerHint": { + "description": "Hint explaining how to dismiss the player selector" + }, + + "playingAlbum": "Lecture de {albumName}", + "@playingAlbum": { + "description": "Message shown when starting to play an album", + "placeholders": { + "albumName": { + "type": "String" + } + } + }, + + "playingPlaylist": "Lecture de {playlistName}", + "@playingPlaylist": { + "description": "Message shown when starting to play a playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + } +} diff --git a/lib/main.dart b/lib/main.dart index ee6e8a9b..a2dd5dab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,10 @@ Future main() async { // Migrate existing ownerName to profile (one-time for existing users) await ProfileService.instance.migrateFromOwnerName(); + // Migrate credentials to secure storage (one-time for existing users) + await SettingsService.migrateToSecureStorage(); + _logger.log('🔐 Secure storage migration complete'); + // Load library from cache for instant startup await SyncService.instance.loadFromCache(); _logger.log('📦 Library cache loaded'); @@ -67,13 +71,16 @@ Future 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, ), ); @@ -172,7 +179,7 @@ class _MusicAssistantAppState extends State with WidgetsBindi @override Future 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) { @@ -180,6 +187,14 @@ class _MusicAssistantAppState extends State 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(); @@ -225,10 +240,17 @@ class _MusicAssistantAppState extends State 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); @@ -252,6 +274,29 @@ class _MusicAssistantAppState extends State 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), @@ -328,8 +373,8 @@ class _SystemUIWrapperState extends State { 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, ), ); diff --git a/lib/models/player.dart b/lib/models/player.dart index 0c65f345..8c8df1a2 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -87,8 +87,31 @@ class Player { bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null; bool get isGroupChild => syncedTo != null; + // A player is manually synced if it's synced TO another player (child of a sync group) + // This excludes pre-configured MA speaker groups which have groupMembers but no syncedTo + // Used for yellow border highlight - only shows for players the user manually synced + bool get isManuallySynced => syncedTo != null; + // Track when this Player object was created (for local interpolation fallback) static final Map _playerCreationTimes = {}; + static const int _maxCreationTimesEntries = 50; + + /// Clean up old creation time entries using LRU eviction + static void _cleanupCreationTimes() { + if (_playerCreationTimes.length <= _maxCreationTimesEntries) return; + + // Sort by timestamp value (oldest first) for true LRU eviction + final sortedEntries = _playerCreationTimes.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + // Remove oldest entries until we're at target size + final entriesToRemove = sortedEntries.take( + _playerCreationTimes.length - _maxCreationTimesEntries, + ); + for (final entry in entriesToRemove) { + _playerCreationTimes.remove(entry.key); + } + } // Calculate current elapsed time (interpolated if playing) double get currentElapsedTime { @@ -130,13 +153,8 @@ class Player { if (!_playerCreationTimes.containsKey(creationKey)) { // First time seeing this elapsed_time - record when we saw it _playerCreationTimes[creationKey] = now; - // Clean up old entries to prevent memory leak - if (_playerCreationTimes.length > 100) { - final keysToRemove = _playerCreationTimes.keys.take(50).toList(); - for (final key in keysToRemove) { - _playerCreationTimes.remove(key); - } - } + // Clean up old entries to prevent memory leak (LRU eviction) + _cleanupCreationTimes(); } final creationTime = _playerCreationTimes[creationKey]!; diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index 4f704342..d0ce0718 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -4,6 +4,7 @@ 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) @@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier { _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), + _api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums), _api!.getAlbums(limit: LibraryConstants.maxLibraryItems), _api!.getTracks(limit: LibraryConstants.maxLibraryItems), ]); @@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier { _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; diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart index ed3d229d..cb6f5678 100644 --- a/lib/providers/locale_provider.dart +++ b/lib/providers/locale_provider.dart @@ -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: diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index a6af1b8b..58bb7074 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -22,7 +22,6 @@ import '../services/position_tracker.dart'; import '../services/sendspin_service.dart'; import '../services/pcm_audio_player.dart'; import '../services/offline_action_queue.dart'; -import '../services/remote/remote_access_manager.dart'; import '../constants/timings.dart'; import '../services/database_service.dart'; import '../main.dart' show audioHandler; @@ -53,14 +52,20 @@ class MusicAssistantProvider with ChangeNotifier { List _albums = []; List _tracks = []; List _cachedFavoriteTracks = []; // Cached for instant display before full library loads + List _radioStations = []; + List _podcasts = []; bool _isLoading = false; + bool _isLoadingRadio = false; + bool _isLoadingPodcasts = false; // Player state Player? _selectedPlayer; List _availablePlayers = []; + bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer() Map _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 @@ -68,9 +73,12 @@ class MusicAssistantProvider with ChangeNotifier { bool _isLocalPlayerPowered = true; int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100) bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead) + StreamSubscription? _connectionStateSubscription; StreamSubscription? _localPlayerEventSubscription; StreamSubscription? _playerUpdatedEventSubscription; StreamSubscription? _playerAddedEventSubscription; + StreamSubscription? _mediaItemAddedEventSubscription; + StreamSubscription? _mediaItemDeletedEventSubscription; Timer? _localPlayerStateReportTimer; TrackMetadata? _pendingTrackMetadata; TrackMetadata? _currentNotificationMetadata; @@ -94,6 +102,18 @@ class MusicAssistantProvider with ChangeNotifier { 'tracks': [], }; + // Podcast cover cache: podcastId -> best available cover URL + // This is populated with episode covers when podcast covers are low-res + final Map _podcastCoverCache = {}; + + // Provider filter: list of allowed provider instance IDs from MA user settings + // Empty list means all providers are allowed + List _providerFilter = []; + + // Player filter: list of allowed player IDs from MA user settings + // Empty list means all players are allowed + List _playerFilter = []; + // ============================================================================ // GETTERS // ============================================================================ @@ -104,10 +124,85 @@ class MusicAssistantProvider with ChangeNotifier { bool get isConnected => _connectionState == MAConnectionState.connected || _connectionState == MAConnectionState.authenticated; - List get artists => _artists; - List get albums => _albums; - List get tracks => _tracks; + // Library getters with provider filtering applied + List get artists => filterByProvider(_artists); + List get albums => filterByProvider(_albums); + List get tracks => filterByProvider(_tracks); + List get radioStations => filterByProvider(_radioStations); + List get podcasts => filterByProvider(_podcasts); + + // Raw unfiltered access (for internal use only) + List get artistsUnfiltered => _artists; + List get albumsUnfiltered => _albums; bool get isLoading => _isLoading; + bool get isLoadingRadio => _isLoadingRadio; + bool get isLoadingPodcasts => _isLoadingPodcasts; + + /// Provider filter from MA user settings (empty = all providers allowed) + List get providerFilter => _providerFilter; + + /// Whether provider filtering is active + bool get hasProviderFilter => _providerFilter.isNotEmpty; + + /// Check if a media item should be visible based on provider filter + /// Returns true if: + /// - No filter is active (empty list = all providers allowed) + /// - The item has at least one provider mapping in the allowed list + /// - The item's primary provider is in the allowed list + bool isItemAllowedByProviderFilter(MediaItem item) { + // No filter = show everything + if (_providerFilter.isEmpty) return true; + + // Check if item's provider mappings include any allowed provider + final mappings = item.providerMappings; + if (mappings != null && mappings.isNotEmpty) { + for (final mapping in mappings) { + if (_providerFilter.contains(mapping.providerInstance)) { + return true; + } + } + } + + // Also check primary provider field (for items without full mappings) + if (_providerFilter.contains(item.provider)) { + return true; + } + + return false; + } + + /// Filter a list of media items based on provider filter + List filterByProvider(List items) { + if (_providerFilter.isEmpty) return items; + return items.where(isItemAllowedByProviderFilter).toList(); + } + + /// Filter search results map based on provider filter + Map> filterSearchResults(Map> results) { + if (_providerFilter.isEmpty) return results; + return { + for (final entry in results.entries) + entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(), + }; + } + + /// Player filter from MA user settings (empty = all players allowed) + List get playerFilter => _playerFilter; + + /// Whether player filtering is active + bool get hasPlayerFilter => _playerFilter.isNotEmpty; + + /// Check if a player should be visible based on player filter + bool isPlayerAllowedByFilter(Player player) { + if (_playerFilter.isEmpty) return true; + return _playerFilter.contains(player.playerId); + } + + /// Filter a list of players based on player filter + List filterPlayers(List players) { + if (_playerFilter.isEmpty) return players; + return players.where(isPlayerAllowedByFilter).toList(); + } /// Whether library is syncing in background bool get isSyncing => SyncService.instance.isSyncing; @@ -124,12 +219,79 @@ class MusicAssistantProvider with ChangeNotifier { } /// Available players - loads from cache for instant UI display + /// Filtered by player_filter if active List get availablePlayers { if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) { _availablePlayers = _cacheService.getCachedPlayers()!; _logger.log('⚡ Loaded ${_availablePlayers.length} players from cache (lazy)'); } - return _availablePlayers; + return filterPlayers(_availablePlayers); + } + + /// Raw unfiltered players list (for internal use) + List get availablePlayersUnfiltered => _availablePlayers; + + /// Check if a player should show the "manually synced" indicator (yellow border) + /// Returns true for BOTH the leader AND children of a manually created sync group + /// Excludes pre-configured MA speaker groups (provider = 'player_group') + bool isPlayerManuallySynced(String playerId) { + final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull; + if (player == null) return false; + + // Group players (like "All Speakers") should NEVER have yellow border + // They are pre-configured containers, not manually synced players + // Check this FIRST before any other logic to prevent edge cases + if (player.provider == 'player_group') return false; + + // Case 1: Player is a child synced to another player + if (player.syncedTo != null) { + // Look up sync target - also check translated IDs for Cast+Sendspin players + // The syncedTo might contain a Cast ID but the player list has the Sendspin version + Player? syncTarget = _availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull; + + // If not found, try looking up by translated Sendspin ID + if (syncTarget == null) { + final translatedId = _castToSendspinIdMap[player.syncedTo]; + if (translatedId != null) { + syncTarget = _availablePlayers.where((p) => p.playerId == translatedId).firstOrNull; + } + } + + // Also check reverse: syncedTo might be Sendspin ID, look for Cast player + if (syncTarget == null) { + // Build reverse map on demand + for (final entry in _castToSendspinIdMap.entries) { + if (entry.value == player.syncedTo) { + syncTarget = _availablePlayers.where((p) => p.playerId == entry.key).firstOrNull; + if (syncTarget != null) break; + } + } + } + + if (syncTarget == null) return false; + + // If synced to a group player, it's part of a pre-configured group + if (syncTarget.provider == 'player_group') return false; + + // Synced to a regular player - this is a manual sync child + return true; + } + + // Case 2: Player is a leader with group members + if (player.groupMembers != null && player.groupMembers!.length > 1) { + // Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers + // In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers + // (the members are the child players, not including the group itself) + final isInOwnGroup = player.groupMembers!.contains(player.playerId); + if (!isInOwnGroup) { + // This is a pre-configured group player (like "All Speakers") + return false; + } + // Leader's ID is in groupMembers = manual sync + return true; + } + + return false; } Track? get currentTrack => _currentTrack; @@ -143,8 +305,79 @@ class MusicAssistantProvider with ChangeNotifier { /// Whether we're currently playing an audiobook bool get isPlayingAudiobook => _currentAudiobook != null; + /// Whether we're currently playing a podcast episode + /// Detected by checking if the current track's URI contains podcast_episode + bool get isPlayingPodcast { + final uri = _currentTrack?.uri; + if (uri == null) return false; + return uri.contains('podcast_episode') || uri.contains('podcast/'); + } + + /// Whether we're currently playing a radio station + /// Detected by checking if the current track's URI contains 'radio/' or media_type is 'radio' + bool get isPlayingRadio { + final uri = _currentTrack?.uri; + if (uri == null) return false; + return uri.contains('library://radio/') || uri.contains('/radio/'); + } + + /// Get the radio station name when playing a radio stream + /// Returns the station name from album (where MA puts it for radio streams) + String? get currentRadioStationName { + if (!isPlayingRadio || _currentTrack == null) return null; + + // For radio, the station name is typically in the album field + if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) { + return _currentTrack!.album!.name; + } + return null; + } + + /// Get the podcast name when playing a podcast episode + /// Returns the podcast name from stored context, metadata, or fallbacks + String? get currentPodcastName { + if (!isPlayingPodcast || _currentTrack == null) return null; + + // 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) { + if (metadata['podcast_name'] != null) { + return metadata['podcast_name'] as String; + } + if (metadata['podcast'] is Map) { + final podcast = metadata['podcast'] as Map; + if (podcast['name'] != null) { + return podcast['name'] as String; + } + } + } + + // 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> get lastSearchResults => _lastSearchResults; + Map> get lastSearchResults => filterSearchResults(_lastSearchResults); MusicAssistantAPI? get api => _api; AuthManager get authManager => _authManager; @@ -175,6 +408,7 @@ class MusicAssistantProvider with ChangeNotifier { /// Get cached track for a player (used for smooth swipe transitions) /// For grouped child players, returns the leader's track + /// Also checks translated Cast<->Sendspin IDs (both from map and computed dynamically) Track? getCachedTrackForPlayer(String playerId) { // If player is a group child, get the leader's track instead final player = _availablePlayers.firstWhere( @@ -192,7 +426,72 @@ class MusicAssistantProvider with ChangeNotifier { ? player.syncedTo! : playerId; - return _cacheService.getCachedTrackForPlayer(effectivePlayerId); + // Try direct lookup first + var track = _cacheService.getCachedTrackForPlayer(effectivePlayerId); + + // If not found, try translated Cast<->Sendspin ID + if (track == null) { + // Check Cast -> Sendspin (from map) + final sendspinId = _castToSendspinIdMap[effectivePlayerId]; + if (sendspinId != null) { + track = _cacheService.getCachedTrackForPlayer(sendspinId); + } + + // Check Sendspin -> Cast (reverse lookup from map) + if (track == null) { + for (final entry in _castToSendspinIdMap.entries) { + if (entry.value == effectivePlayerId) { + track = _cacheService.getCachedTrackForPlayer(entry.key); + if (track != null) break; + } + } + } + + // Dynamic ID computation for chromecast players + // This handles cases where the map doesn't have the entry yet + if (track == null) { + // If effectivePlayerId looks like a Sendspin ID (cast-{8chars}), compute Cast ID + if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) { + // Sendspin ID: cast-7df484e3 -> need to find Cast ID that starts with 7df484e3 + final prefix = effectivePlayerId.substring(5); // Remove "cast-" + // Search through available players for a chromecast player with matching UUID prefix + for (final p in _availablePlayers) { + if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) { + track = _cacheService.getCachedTrackForPlayer(p.playerId); + if (track != null) { + _logger.log('🔍 Found track via computed Cast ID: ${p.playerId}'); + break; + } + } + } + // Also try direct cache lookup with the prefix as partial ID + if (track == null) { + // Try common UUID patterns - the cache might have the full Cast UUID + final possibleCastIds = _cacheService.getAllCachedPlayerIds() + .where((id) => id.startsWith(prefix)) + .toList(); + for (final castId in possibleCastIds) { + track = _cacheService.getCachedTrackForPlayer(castId); + if (track != null) { + _logger.log('🔍 Found track via cache scan for prefix $prefix: $castId'); + break; + } + } + } + } + + // If effectivePlayerId looks like a Cast UUID, compute Sendspin ID + if (track == null && effectivePlayerId.length >= 8 && effectivePlayerId.contains('-')) { + final computedSendspinId = 'cast-${effectivePlayerId.substring(0, 8)}'; + track = _cacheService.getCachedTrackForPlayer(computedSendspinId); + if (track != null) { + _logger.log('🔍 Found track via computed Sendspin ID: $computedSendspinId'); + } + } + } + } + + return track; } /// Get artwork URL for a player from cache @@ -232,6 +531,9 @@ class MusicAssistantProvider with ChangeNotifier { // Load cached home rows from database for instant discover/recent display await _cacheService.loadHomeRowsFromDatabase(); + // Load podcast cover cache (iTunes URLs) for instant high-res display + await _loadPodcastCoverCache(); + // Initialize offline action queue await OfflineActionQueue.instance.initialize(); @@ -346,6 +648,19 @@ class MusicAssistantProvider with ChangeNotifier { } } + /// Load podcast cover cache (iTunes URLs) from storage for instant high-res display + Future _loadPodcastCoverCache() async { + try { + final cache = await SettingsService.getPodcastCoverCache(); + if (cache.isNotEmpty) { + _podcastCoverCache.addAll(cache); + _logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)'); + } + } catch (e) { + _logger.log('⚠️ Error loading podcast cover cache: $e'); + } + } + /// Persist current playback state to database (fire-and-forget) void _persistPlaybackState() { if (_selectedPlayer == null) return; @@ -433,25 +748,19 @@ class MusicAssistantProvider with ChangeNotifier { // CONNECTION // ============================================================================ - Future connectToServer(String serverUrl, {String? username, String? password}) async { + Future connectToServer(String serverUrl) async { try { _error = null; _serverUrl = serverUrl; await SettingsService.setServerUrl(serverUrl); - - // Store credentials if provided (for remote access authentication) - if (username != null && password != null) { - await SettingsService.setUsername(username); - await SettingsService.setPassword(password); - _logger.log('🔐 Remote access credentials stored for authentication'); - } // Dispose the old API to stop any pending reconnects _api?.dispose(); _api = MusicAssistantAPI(serverUrl, _authManager); - _api!.connectionState.listen( + _connectionStateSubscription?.cancel(); + _connectionStateSubscription = _api!.connectionState.listen( (state) async { _connectionState = state; notifyListeners(); @@ -510,6 +819,19 @@ class MusicAssistantProvider with ChangeNotifier { onError: (error) => _logger.log('Player added event stream error: $error'), ); + // Subscribe to library change events for instant UI updates + _mediaItemAddedEventSubscription?.cancel(); + _mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen( + _handleMediaItemAddedEvent, + onError: (error) => _logger.log('Media item added event stream error: $error'), + ); + + _mediaItemDeletedEventSubscription?.cancel(); + _mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen( + _handleMediaItemDeletedEvent, + onError: (error) => _logger.log('Media item deleted event stream error: $error'), + ); + await _api!.connect(); notifyListeners(); } catch (e) { @@ -532,7 +854,7 @@ class MusicAssistantProvider with ChangeNotifier { final success = await _api!.authenticateWithToken(storedToken); if (success) { _logger.log('✅ MA authentication with stored token successful'); - await _fetchAndSetUserProfileName(); + await _fetchUserSettings(); return true; } _logger.log('⚠️ Stored MA token invalid, clearing...'); @@ -557,7 +879,7 @@ class MusicAssistantProvider with ChangeNotifier { await SettingsService.setMaAuthToken(accessToken); } - await _fetchAndSetUserProfileName(); + await _fetchUserSettings(); return true; } } @@ -570,13 +892,14 @@ class MusicAssistantProvider with ChangeNotifier { } } - Future _fetchAndSetUserProfileName() async { + Future _fetchUserSettings() async { if (_api == null) return; try { final userInfo = await _api!.getCurrentUserInfo(); if (userInfo == null) return; + // Set profile name final displayName = userInfo['display_name'] as String?; final username = userInfo['username'] as String?; @@ -586,8 +909,38 @@ class MusicAssistantProvider with ChangeNotifier { await SettingsService.setOwnerName(profileName); _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().toList(); + if (_providerFilter.isNotEmpty) { + _logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed'); + _logger.log(' Allowed: ${_providerFilter.join(", ")}'); + } else { + _logger.log('🔓 No provider filter - all providers visible'); + } + } else { + _providerFilter = []; + _logger.log('🔓 No provider filter in user settings'); + } + + // Capture player filter (empty list = all players allowed) + final playerFilterRaw = userInfo['player_filter']; + if (playerFilterRaw is List) { + _playerFilter = playerFilterRaw.cast().toList(); + if (_playerFilter.isNotEmpty) { + _logger.log('🔒 Player filter active: ${_playerFilter.length} players allowed'); + _logger.log(' Allowed: ${_playerFilter.join(", ")}'); + } else { + _logger.log('🔓 No player filter - all players visible'); + } + } else { + _playerFilter = []; + _logger.log('🔓 No player filter in user settings'); + } } catch (e) { - _logger.log('⚠️ Could not fetch user profile (non-fatal): $e'); + _logger.log('⚠️ Could not fetch user settings (non-fatal): $e'); } } @@ -600,12 +953,7 @@ class MusicAssistantProvider with ChangeNotifier { await _api!.fetchState(); if (_api!.authRequired) { - await _fetchAndSetUserProfileName(); - } - - // Initialize local playback (if not already initialized) - if (!_localPlayer.isInitialized) { - await _initializeLocalPlayback(); + await _fetchUserSettings(); } await _tryAdoptGhostPlayer(); @@ -699,6 +1047,8 @@ class MusicAssistantProvider with ChangeNotifier { // Online - execute immediately try { await _api!.addToFavorites(mediaType, itemId, provider); + // Update local cache for instant UI feedback + _updateLocalFavoriteStatus(mediaType, itemId, true); return true; } catch (e) { _logger.log('❌ Failed to add to favorites: $e'); @@ -716,6 +1066,8 @@ class MusicAssistantProvider with ChangeNotifier { }, ); _logger.log('📋 Queued add to favorites (offline): $mediaType'); + // Still update local state for offline support + _updateLocalFavoriteStatus(mediaType, itemId, true); return true; } } @@ -730,6 +1082,8 @@ class MusicAssistantProvider with ChangeNotifier { // Online - execute immediately try { await _api!.removeFromFavorites(mediaType, libraryItemId); + // Update local cache for instant UI feedback + _updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false); return true; } catch (e) { _logger.log('❌ Failed to remove from favorites: $e'); @@ -746,10 +1100,330 @@ class MusicAssistantProvider with ChangeNotifier { }, ); _logger.log('📋 Queued remove from favorites (offline): $mediaType'); + // Still update local state for offline support + _updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false); return true; } } + /// Update favorite status in local cache for instant UI feedback + void _updateLocalFavoriteStatus(String mediaType, String itemId, bool isFavorite) { + bool updated = false; + + if (mediaType == 'artist') { + final index = _artists.indexWhere((a) => a.itemId == itemId); + if (index != -1) { + final artist = _artists[index]; + _artists[index] = Artist( + itemId: artist.itemId, + provider: artist.provider, + name: artist.name, + sortName: artist.sortName, + uri: artist.uri, + providerMappings: artist.providerMappings, + metadata: artist.metadata, + favorite: isFavorite, + ); + updated = true; + } + } else if (mediaType == 'album') { + final index = _albums.indexWhere((a) => a.itemId == itemId); + if (index != -1) { + final album = _albums[index]; + _albums[index] = Album( + itemId: album.itemId, + provider: album.provider, + name: album.name, + artists: album.artists, + albumType: album.albumType, + year: album.year, + sortName: album.sortName, + uri: album.uri, + providerMappings: album.providerMappings, + metadata: album.metadata, + favorite: isFavorite, + ); + updated = true; + } + } else if (mediaType == 'track') { + final index = _tracks.indexWhere((t) => t.itemId == itemId); + if (index != -1) { + final track = _tracks[index]; + _tracks[index] = Track( + itemId: track.itemId, + provider: track.provider, + name: track.name, + artists: track.artists, + album: track.album, + duration: track.duration, + sortName: track.sortName, + uri: track.uri, + providerMappings: track.providerMappings, + metadata: track.metadata, + favorite: isFavorite, + ); + updated = true; + } + } + + if (updated) { + notifyListeners(); + } + } + + /// Update favorite status by library item ID (for remove operations) + void _updateLocalFavoriteStatusByLibraryId(String mediaType, int libraryItemId, bool isFavorite) { + final libraryIdStr = libraryItemId.toString(); + bool updated = false; + + if (mediaType == 'artist') { + final index = _artists.indexWhere((a) => + a.provider == 'library' && a.itemId == libraryIdStr || + a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true + ); + if (index != -1) { + final artist = _artists[index]; + _artists[index] = Artist( + itemId: artist.itemId, + provider: artist.provider, + name: artist.name, + sortName: artist.sortName, + uri: artist.uri, + providerMappings: artist.providerMappings, + metadata: artist.metadata, + favorite: isFavorite, + ); + updated = true; + } + } else if (mediaType == 'album') { + final index = _albums.indexWhere((a) => + a.provider == 'library' && a.itemId == libraryIdStr || + a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true + ); + if (index != -1) { + final album = _albums[index]; + _albums[index] = Album( + itemId: album.itemId, + provider: album.provider, + name: album.name, + artists: album.artists, + albumType: album.albumType, + year: album.year, + sortName: album.sortName, + uri: album.uri, + providerMappings: album.providerMappings, + metadata: album.metadata, + favorite: isFavorite, + ); + updated = true; + } + } else if (mediaType == 'track') { + final index = _tracks.indexWhere((t) => + t.provider == 'library' && t.itemId == libraryIdStr || + t.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true + ); + if (index != -1) { + final track = _tracks[index]; + _tracks[index] = Track( + itemId: track.itemId, + provider: track.provider, + name: track.name, + artists: track.artists, + album: track.album, + duration: track.duration, + sortName: track.sortName, + uri: track.uri, + providerMappings: track.providerMappings, + metadata: track.metadata, + favorite: isFavorite, + ); + updated = true; + } + } + + if (updated) { + notifyListeners(); + } + } + + // ============================================================================ + // LIBRARY MANAGEMENT + // ============================================================================ + + /// Add item to library + /// Returns true if action was executed successfully + Future addToLibrary({ + required String mediaType, + required String itemId, + required String provider, + }) async { + if (isConnected && _api != null) { + try { + await _api!.addItemToLibrary(mediaType, itemId, provider); + // Trigger a background refresh to update library with new item + _scheduleLibraryRefresh(mediaType); + // Invalidate caches that could show stale inLibrary status + _cacheService.invalidateSearchCache(); + if (mediaType == 'album') { + _cacheService.invalidateArtistAlbumsCache(); + _cacheService.invalidateHomeAlbumCaches(); + } else if (mediaType == 'track') { + _cacheService.invalidateAllAlbumTracksCaches(); + _cacheService.invalidateAllPlaylistTracksCaches(); + } else if (mediaType == 'artist') { + _cacheService.invalidateHomeArtistCaches(); + } + return true; + } catch (e) { + _logger.log('❌ Failed to add to library: $e'); + return false; + } + } else { + _logger.log('❌ Cannot add to library while offline'); + return false; + } + } + + /// Remove item from library + /// Returns true if action was executed successfully + /// Uses optimistic update: local cache is updated immediately before API call + Future removeFromLibrary({ + required String mediaType, + required int libraryItemId, + }) async { + if (isConnected && _api != null) { + // OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback + // This ensures library screens show the change even before API completes + _removeFromLocalLibrary(mediaType, libraryItemId); + // Also mark as deleted in database cache so it doesn't reappear on next load + DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString()); + // Invalidate caches that could show stale inLibrary status + _cacheService.invalidateSearchCache(); + if (mediaType == 'album') { + _cacheService.invalidateArtistAlbumsCache(); + _cacheService.invalidateHomeAlbumCaches(); + } else if (mediaType == 'track') { + _cacheService.invalidateAllAlbumTracksCaches(); + _cacheService.invalidateAllPlaylistTracksCaches(); + } else if (mediaType == 'artist') { + _cacheService.invalidateHomeArtistCaches(); + } + + try { + await _api!.removeItemFromLibrary(mediaType, libraryItemId); + return true; + } catch (e) { + final errorStr = e.toString().toLowerCase(); + // "not found in library" means item is already removed - treat as success + if (errorStr.contains('not found in library')) { + _logger.log('ℹ️ Item already removed from library'); + return true; + } + // On actual error, we'd ideally restore the item, but that's complex + // For now, just log the error - the background refresh will fix state + _logger.log('❌ Failed to remove from library (local cache already updated): $e'); + _scheduleLibraryRefresh(mediaType); + return false; + } + } else { + _logger.log('❌ Cannot remove from library while offline'); + return false; + } + } + + /// Remove item from local library cache for instant UI feedback + /// Creates new list instances to ensure Selector widgets detect changes + void _removeFromLocalLibrary(String mediaType, int libraryItemId) { + final libraryIdStr = libraryItemId.toString(); + bool updated = false; + + // Helper to check if item matches the library ID being removed + // Checks both direct provider=library match AND providerMappings + bool matchesLibraryId(String? provider, String? itemId, List? mappings) { + // Direct match: item's own provider is 'library' and ID matches + if (provider == 'library' && itemId == libraryIdStr) { + return true; + } + // Mapping match: check if any providerMapping has library instance with matching ID + if (mappings != null) { + for (final m in mappings) { + if ((m.providerInstance == 'library' || m.providerDomain == 'library') && + m.itemId == libraryIdStr) { + return true; + } + } + } + return false; + } + + if (mediaType == 'artist') { + final before = _artists.length; + // Create new list to trigger Selector rebuilds (reference equality) + _artists = _artists.where((a) => + !matchesLibraryId(a.provider, a.itemId, a.providerMappings) + ).toList(); + updated = _artists.length != before; + } else if (mediaType == 'album') { + final before = _albums.length; + _albums = _albums.where((a) => + !matchesLibraryId(a.provider, a.itemId, a.providerMappings) + ).toList(); + updated = _albums.length != before; + } else if (mediaType == 'track') { + final before = _tracks.length; + _tracks = _tracks.where((t) => + !matchesLibraryId(t.provider, t.itemId, t.providerMappings) + ).toList(); + updated = _tracks.length != before; + } else if (mediaType == 'radio') { + final before = _radioStations.length; + _radioStations = _radioStations.where((r) => + !matchesLibraryId(r.provider, r.itemId, r.providerMappings) + ).toList(); + updated = _radioStations.length != before; + } else if (mediaType == 'podcast') { + final before = _podcasts.length; + _podcasts = _podcasts.where((p) => + !matchesLibraryId(p.provider, p.itemId, p.providerMappings) + ).toList(); + updated = _podcasts.length != before; + } + + if (updated) { + _logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from local cache'); + notifyListeners(); + } + } + + /// Refresh library data for a media type after library change + /// Runs immediately (no delay) to ensure UI stays in sync + void _scheduleLibraryRefresh(String mediaType) { + // Run immediately - no delay to ensure data consistency + () async { + if (!isConnected || _api == null) return; + + try { + if (mediaType == 'artist') { + _artists = await _api!.getArtists( + limit: LibraryConstants.maxLibraryItems, + albumArtistsOnly: false, + ); + } else if (mediaType == 'album') { + _albums = await _api!.getAlbums(limit: LibraryConstants.maxLibraryItems); + } else if (mediaType == 'track') { + _tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems); + } else if (mediaType == 'radio') { + _radioStations = await _api!.getRadioStations(limit: 100); + } else if (mediaType == 'podcast') { + _podcasts = await _api!.getPodcasts(limit: 100); + } + notifyListeners(); + } catch (e) { + _logger.log('⚠️ Background library refresh failed: $e'); + } + }(); + } + Future disconnect() async { _playerStateTimer?.cancel(); _playerStateTimer = null; @@ -759,6 +1433,8 @@ class MusicAssistantProvider with ChangeNotifier { _localPlayerEventSubscription?.cancel(); _playerUpdatedEventSubscription?.cancel(); _playerAddedEventSubscription?.cancel(); + _mediaItemAddedEventSubscription?.cancel(); + _mediaItemDeletedEventSubscription?.cancel(); _positionTracker.clear(); // Disconnect Sendspin and PCM player if connected if (_sendspinConnected) { @@ -794,41 +1470,6 @@ class MusicAssistantProvider with ChangeNotifier { return; } - // CRITICAL: Check if we're using remote access by checking for saved remote ID - // This persists across app lifecycle unlike isRemoteMode which depends on active state - final remoteManager = RemoteAccessManager.instance; - final savedRemoteId = await remoteManager.getSavedRemoteId(); - final savedMode = await remoteManager.getSavedMode(); - - final isRemoteMode = savedMode == ConnectionMode.remote && - savedRemoteId != null && - savedRemoteId.isNotEmpty; - - if (isRemoteMode) { - _logger.log('🔄 Remote Access mode detected (ID: ${savedRemoteId!.substring(0, 8)}...)'); - - // Force WebRTC reconnection on app resume - // Mobile OSes suspend peer connections when app backgrounds - _logger.log('🔄 Forcing WebRTC transport reconnection...'); - try { - // Disconnect old transport if it exists - if (remoteManager.transport != null) { - _logger.log('🔄 Cleaning up old WebRTC transport'); - remoteManager.transport!.disconnect(); - } - - // Create fresh WebRTC connection - await remoteManager.connectWithRemoteId(savedRemoteId); - _logger.log('🔄 WebRTC transport reconnected successfully'); - } catch (e) { - _logger.log('❌ WebRTC transport reconnection failed: $e'); - _error = 'Failed to reconnect: $e'; - _connectionState = MAConnectionState.error; - notifyListeners(); - return; - } - } - // IMMEDIATELY load cached players for instant UI display // This makes mini player and device button appear instantly on app resume if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) { @@ -1630,13 +2271,45 @@ class MusicAssistantProvider with ChangeNotifier { } } + /// Handle media_item_added event - refresh library when items are added + /// This handles the case where addToLibrary API call times out but the item was actually added + void _handleMediaItemAddedEvent(Map event) { + try { + final mediaType = event['media_type'] as String?; + _cacheService.invalidateSearchCache(); + _scheduleLibraryRefresh(mediaType ?? 'artist'); + } catch (e) { + _logger.log('Error handling media item added event: $e'); + } + } + + /// Handle media_item_deleted event - refresh library when items are removed + /// This ensures UI updates even if removeFromLibrary API call times out + void _handleMediaItemDeletedEvent(Map event) { + try { + final mediaType = event['media_type'] as String?; + _cacheService.invalidateSearchCache(); + _scheduleLibraryRefresh(mediaType ?? 'artist'); + } catch (e) { + _logger.log('Error handling media item deleted event: $e'); + } + } + Future _handlePlayerUpdatedEvent(Map event) async { try { final playerId = event['player_id'] as String?; if (playerId == null) return; - if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) { - _updatePlayerState(); + // Check if this event is for the selected player - also check translated Cast<->Sendspin IDs + // Events may come with Cast ID but selected player uses Sendspin ID (or vice versa) + if (_selectedPlayer != null) { + final selectedId = _selectedPlayer!.playerId; + final isMatch = playerId == selectedId || + _castToSendspinIdMap[playerId] == selectedId || + _castToSendspinIdMap[selectedId] == playerId; + if (isMatch) { + _updatePlayerState(); + } } final currentMedia = event['current_media'] as Map?; @@ -1646,6 +2319,12 @@ class MusicAssistantProvider with ChangeNotifier { final mediaType = currentMedia['media_type'] as String?; final uri = currentMedia['uri'] as String?; + // Debug: Log all currentMedia fields for podcast episodes + if (uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'))) { + _logger.log('🎙️ Podcast currentMedia keys: ${currentMedia.keys.toList()}'); + _logger.log('🎙️ Podcast currentMedia: $currentMedia'); + } + // Check for external source (optical, Spotify, etc.) - skip caching stale metadata bool isExternalSource = false; if (uri != null) { @@ -1682,6 +2361,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?; @@ -1713,10 +2398,61 @@ class MusicAssistantProvider with ChangeNotifier { }; } + // Extract podcast info if available (for podcast episodes) + final podcastData = currentMedia['podcast']; + if (podcastData != null) { + metadata ??= {}; + metadata['podcast'] = podcastData; + _logger.log('🎙️ Found podcast in currentMedia: $podcastData'); + } + // Parse artist from title if artist is missing but title contains " - " var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track'; var artistName = currentMedia['artist'] as String?; + // Check if this is a radio stream + final isRadioStream = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/')); + + // For radio streams, log the currentMedia to debug metadata structure + if (isRadioStream) { + _logger.log('📻 Radio currentMedia keys: ${currentMedia.keys.toList()}'); + _logger.log('📻 Radio currentMedia: $currentMedia'); + } + + // For radio streams, try additional artist sources if primary is missing + if (isRadioStream && (artistName == null || artistName.isEmpty)) { + // Check for artists array (like in Track.fromJson) + final artistsData = currentMedia['artists']; + if (artistsData is List && artistsData.isNotEmpty) { + final firstArtist = artistsData.first; + if (firstArtist is Map) { + artistName = firstArtist['name'] as String?; + } else if (firstArtist is String) { + artistName = firstArtist; + } + _logger.log('📻 Found artist from artists array: $artistName'); + } + + // 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.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'); + } + } else { + // No separator, use stream_title as the title + trackTitle = streamTitle; + _logger.log('📻 Using stream_title as title: $trackTitle'); + } + } + } + if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) { final parts = trackTitle.split(' - '); if (parts.length >= 2) { @@ -1739,26 +2475,83 @@ 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; + // 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 + final isPodcastUri = uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/')); + + // For radio streams, always update when we have new metadata (song changes frequently) + // This ensures radio artist/title changes are reflected immediately + final isRadioUri = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/')); // Keep existing if it has proper artist OR has image that new one lacks - final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage); + // BUT for podcasts, if new has album and existing doesn't, we need that album data + // AND for radio, always update when new track has proper artist (song changed) + final keepExisting = (existingHasProperArtist || (existingHasImage && !newHasImage)) + && !(isPodcastUri && newHasAlbum && !existingHasAlbum) + && !(isRadioUri && newHasProperArtist); if (!keepExisting) { _cacheService.setCachedTrackForPlayer(playerId, trackFromEvent); _logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}'); + + // Dual-cache for Cast<->Sendspin players so track is findable by either ID + final sendspinId = _castToSendspinIdMap[playerId]; + if (sendspinId != null) { + _cacheService.setCachedTrackForPlayer(sendspinId, trackFromEvent); + _logger.log('📋 Also cached under Sendspin ID: $sendspinId'); + } else if (playerId.length >= 8 && playerId.contains('-')) { + // Compute Sendspin ID for chromecast players not yet in map + final computedSendspinId = 'cast-${playerId.substring(0, 8)}'; + _cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent); + _logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId'); + } + } else if (isPodcastUri && newHasAlbum && existingTrack != null && !existingHasAlbum) { + // Merge: keep existing but add album from new track + final mergedTrack = Track( + itemId: existingTrack.itemId, + provider: existingTrack.provider, + name: existingTrack.name, + uri: existingTrack.uri, + duration: existingTrack.duration, + artists: existingTrack.artists, + album: trackFromEvent.album, // Take album from new track + metadata: existingTrack.metadata, + ); + _cacheService.setCachedTrackForPlayer(playerId, mergedTrack); + _logger.log('📋 Merged album info into existing track for $playerName: ${albumName}'); } else { _logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)'); } // For selected player, _updatePlayerState() is already called above which fetches queue data - // Only update _currentTrack here if we don't have it yet (initial load) - if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) { - _currentTrack = trackFromEvent; + // 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) || + (isRadioUri && newHasProperArtist && currentHasUnknownArtist)); + if (shouldUpdateCurrentTrack) { + _currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent; + _logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}'); } notifyListeners(); @@ -1773,11 +2566,54 @@ class MusicAssistantProvider with ChangeNotifier { if (currentMedia == null) return; - final title = currentMedia['title'] as String? ?? 'Unknown Track'; - final artist = currentMedia['artist'] as String? ?? 'Unknown Artist'; + var title = currentMedia['title'] as String? ?? 'Unknown Track'; + var artist = currentMedia['artist'] as String?; final album = currentMedia['album'] as String?; var imageUrl = currentMedia['image_url'] as String?; final durationSecs = (currentMedia['duration'] as num?)?.toInt(); + final notificationUri = currentMedia['uri'] as String?; + final isRadioNotification = notificationUri != null && + (notificationUri.contains('library://radio/') || notificationUri.contains('/radio/')); + + // For radio streams, try to extract artist from various sources + if (isRadioNotification && (artist == null || artist.isEmpty)) { + // Check for artists array + final artistsData = currentMedia['artists']; + if (artistsData is List && artistsData.isNotEmpty) { + final firstArtist = artistsData.first; + if (firstArtist is Map) { + artist = firstArtist['name'] as String?; + } else if (firstArtist is String) { + artist = firstArtist; + } + } + + // Check for stream_title which contains the actual now-playing metadata + final streamTitle = currentMedia['stream_title'] as String?; + 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; + } + } + } + + // Parse artist from title if still missing + if ((artist == null || artist == 'Unknown Artist') && title.contains(' - ')) { + final parts = title.split(' - '); + if (parts.length >= 2) { + artist = parts[0].trim(); + title = parts.sublist(1).join(' - ').trim(); + } + } + artist ??= 'Unknown Artist'; if (imageUrl != null && _serverUrl != null) { try { @@ -1859,16 +2695,19 @@ class MusicAssistantProvider with ChangeNotifier { try { _logger.log('🔄 Fetching fresh recent albums from MA...'); final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit); - _cacheService.setCachedRecentAlbums(albums); - return albums; + // Apply provider filtering + final filtered = filterByProvider(albums); + _cacheService.setCachedRecentAlbums(filtered); + return filtered; } catch (e) { _logger.log('❌ Failed to fetch recent albums: $e'); // Fallback on error: try memory cache, then local database final cached = _cacheService.getCachedRecentAlbums(); - if (cached != null && cached.isNotEmpty) return cached; - return RecentlyPlayedService.instance.getRecentAlbums( + if (cached != null && cached.isNotEmpty) return filterByProvider(cached); + final local = await RecentlyPlayedService.instance.getRecentAlbums( limit: LibraryConstants.defaultRecentLimit, ); + return filterByProvider(local); } } @@ -1977,64 +2816,156 @@ class MusicAssistantProvider with ChangeNotifier { return _cachedFavoriteTracks; } + /// Get favorite playlists from the library + Future> 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> 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> 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> getInProgressAudiobooks() async { - if (_api == null) return []; + /// Get audiobooks that have progress (continue listening) with caching + Future> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async { + // Check cache first + if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) { + _logger.log('📦 Using cached in-progress audiobooks'); + return _cacheService.getCachedInProgressAudiobooks()!; + } + + if (_api == null) { + // Fallback to cache when offline + final cached = _cacheService.getCachedInProgressAudiobooks(); + if (cached != null && cached.isNotEmpty) return cached; + return []; + } try { _logger.log('📚 Fetching in-progress audiobooks...'); - final allAudiobooks = await _api!.getAudiobooks(); + final allAudiobooks = filterByProvider(await _api!.getAudiobooks()); // Filter to only those with progress, sorted by most recent/highest progress final inProgress = allAudiobooks .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 cached; return []; } } - /// Get random audiobooks for discovery - Future> getDiscoverAudiobooks() async { - if (_api == null) return []; + /// Get cached in-progress audiobooks synchronously (for instant display) + List? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks(); + + /// Get random audiobooks for discovery with caching + Future> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async { + // Check cache first + if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) { + _logger.log('📦 Using cached discover audiobooks'); + return _cacheService.getCachedDiscoverAudiobooks()!; + } + + if (_api == null) { + // Fallback to cache when offline + final cached = _cacheService.getCachedDiscoverAudiobooks(); + if (cached != null && cached.isNotEmpty) return cached; + return []; + } try { _logger.log('📚 Fetching discover audiobooks...'); - final allAudiobooks = await _api!.getAudiobooks(); + final allAudiobooks = filterByProvider(await _api!.getAudiobooks()); // Shuffle and take a subset final shuffled = List.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 cached; return []; } } - /// Get random series for discovery - Future> getDiscoverSeries() async { - if (_api == null) return []; + /// Get cached discover audiobooks synchronously (for instant display) + List? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks(); + + /// Get random series for discovery with caching + Future> 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.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? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries(); + // ============================================================================ // DETAIL SCREEN CACHING // ============================================================================ @@ -2152,11 +3083,12 @@ class MusicAssistantProvider with ChangeNotifier { if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) { _logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)'); - return _cacheService.getCachedSearchResults(cacheKey)!; + return filterSearchResults(_cacheService.getCachedSearchResults(cacheKey)!); } if (_api == null) { - return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + return filterSearchResults(cached); } try { @@ -2173,10 +3105,11 @@ class MusicAssistantProvider with ChangeNotifier { _cacheService.setCachedSearchResults(cacheKey, cachedResults); _logger.log('✅ Cached search results for "$query"'); - return cachedResults; + return filterSearchResults(cachedResults); } catch (e) { _logger.log('❌ Search failed: $e'); - return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + return filterSearchResults(cached); } } @@ -2252,6 +3185,20 @@ class MusicAssistantProvider with ChangeNotifier { return; } + // Load persisted Cast-to-Sendspin mappings from database + // This ensures we remember mappings even when Sendspin players are unavailable + if (_castToSendspinIdMap.isEmpty) { + try { + final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings(); + _castToSendspinIdMap.addAll(persistedMappings); + if (persistedMappings.isNotEmpty) { + _logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database'); + } + } catch (e) { + _logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e'); + } + } + final allPlayers = await getPlayers(); final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); @@ -2306,9 +3253,19 @@ class MusicAssistantProvider with ChangeNotifier { // - When ungrouped: show original Cast, hide Sendspin version // This gives power control and proper queue behavior when not syncing final sendspinSuffix = ' (Sendspin)'; - final sendspinPlayers = _availablePlayers - .where((p) => p.name.endsWith(sendspinSuffix)) - .toList(); + + // Detect Sendspin players by: + // 1. Name ends with " (Sendspin)" (e.g., "Kitchen speaker (Sendspin)") + // 2. ID starts with "cast-" and name equals ID (e.g., ID="cast-7df484e3", name="cast-7df484e3") + // This handles Cast devices where MA registers Sendspin player with raw ID as name + bool isSendspinPlayer(Player p) { + if (p.name.endsWith(sendspinSuffix)) return true; + // Check for Sendspin players named with their raw ID (cast-{uuid-prefix}) + if (p.playerId.startsWith('cast-') && p.name == p.playerId) return true; + return false; + } + + final sendspinPlayers = _availablePlayers.where(isSendspinPlayer).toList(); // NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings // even when the Sendspin player is temporarily unavailable (e.g., device off) @@ -2318,18 +3275,47 @@ class MusicAssistantProvider with ChangeNotifier { // Build maps for Sendspin players and their grouped status final sendspinByBaseName = {}; final groupedSendspinBaseNames = {}; + // Track Sendspin players that have raw ID as name (need renaming) + final rawIdSendspinPlayers = {}; for (final player in sendspinPlayers) { - final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length); + String baseName; + Player? regularCastPlayer; + + if (player.name.endsWith(sendspinSuffix)) { + // Standard "(Sendspin)" suffix naming + baseName = player.name.substring(0, player.name.length - sendspinSuffix.length); + regularCastPlayer = _availablePlayers.where( + (p) => p.name == baseName && !isSendspinPlayer(p) + ).firstOrNull; + } else if (player.playerId.startsWith('cast-') && player.name == player.playerId) { + // Raw ID naming (e.g., "cast-7df484e3") - find matching Cast player by UUID prefix + // Sendspin ID format: cast-{first 8 chars of Cast UUID} + // Cast ID format: {uuid} e.g., 7df484e3-d2ee-c897-f746-2dffc29595ff + final sendspinPrefix = player.playerId.substring(5); // Remove "cast-" prefix + regularCastPlayer = _availablePlayers.where( + (p) => p.playerId.startsWith(sendspinPrefix) && !isSendspinPlayer(p) + ).firstOrNull; + baseName = regularCastPlayer?.name ?? player.name; + if (regularCastPlayer != null) { + rawIdSendspinPlayers[player.playerId] = player; + _logger.log('🔍 Found raw-ID Sendspin player: ${player.playerId} matches Cast player "${regularCastPlayer.name}"'); + } + } else { + continue; + } + sendspinByBaseName[baseName] = player; - // Find the corresponding regular Cast player and store the ID mapping - final regularCastPlayer = _availablePlayers.where( - (p) => p.name == baseName && !p.name.endsWith(sendspinSuffix) - ).firstOrNull; + // Store the ID mapping and persist to database if (regularCastPlayer != null) { _castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId; _logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${player.playerId}'); + // Persist mapping so it survives when Sendspin player is unavailable + DatabaseService.instance.saveCastToSendspinMapping( + regularCastPlayer.playerId, + player.playerId, + ); } if (player.isGrouped) { @@ -2342,10 +3328,23 @@ class MusicAssistantProvider with ChangeNotifier { // Filter players based on grouped status _availablePlayers = _availablePlayers.where((player) { - final isSendspin = player.name.endsWith(sendspinSuffix); + final isSendspin = isSendspinPlayer(player); if (isSendspin) { - final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length); + // Get base name for this Sendspin player + String baseName; + if (player.name.endsWith(sendspinSuffix)) { + baseName = player.name.substring(0, player.name.length - sendspinSuffix.length); + } else if (rawIdSendspinPlayers.containsKey(player.playerId)) { + // For raw ID players, find the base name from our earlier mapping + baseName = sendspinByBaseName.entries + .firstWhere((e) => e.value.playerId == player.playerId, + orElse: () => MapEntry(player.name, player)) + .key; + } else { + baseName = player.name; + } + // Keep Sendspin only if grouped if (player.isGrouped) { return true; @@ -2365,13 +3364,24 @@ class MusicAssistantProvider with ChangeNotifier { } }).toList(); - // Rename remaining Sendspin players to remove the suffix + // Rename remaining Sendspin players to remove the suffix or give proper name _availablePlayers = _availablePlayers.map((player) { if (player.name.endsWith(sendspinSuffix)) { final cleanName = player.name.substring(0, player.name.length - sendspinSuffix.length); _logger.log('✨ Renaming "${player.name}" to "$cleanName"'); return player.copyWith(name: cleanName); } + // Rename raw ID Sendspin players to their proper name + if (rawIdSendspinPlayers.containsKey(player.playerId)) { + final properName = sendspinByBaseName.entries + .firstWhere((e) => e.value.playerId == player.playerId, + orElse: () => MapEntry(player.name, player)) + .key; + if (properName != player.name) { + _logger.log('✨ Renaming raw ID Sendspin "${player.name}" to "$properName"'); + return player.copyWith(name: properName); + } + } return player; }).toList(); } @@ -2410,16 +3420,34 @@ class MusicAssistantProvider with ChangeNotifier { // But allow switching to a playing player when preferLocalPlayer is OFF // On coldStart, skip this block and apply full priority logic (playing > local > last selected) if (playerToSelect == null && _selectedPlayer != null && !coldStart) { + // Check if selected player is still available - also check translated Cast/Sendspin IDs + // When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection + final selectedId = _selectedPlayer!.playerId; + final translatedId = _castToSendspinIdMap[selectedId]; + String? reverseTranslatedId; + for (final entry in _castToSendspinIdMap.entries) { + if (entry.value == selectedId) { + reverseTranslatedId = entry.key; + break; + } + } + final stillAvailable = _availablePlayers.any( - (p) => p.playerId == _selectedPlayer!.playerId && p.available, + (p) => p.available && (p.playerId == selectedId || + (translatedId != null && p.playerId == translatedId) || + (reverseTranslatedId != null && p.playerId == reverseTranslatedId)), ); if (stillAvailable) { + // Find the actual player in the list (might be Cast or Sendspin version) + final currentPlayer = _availablePlayers.firstWhere( + (p) => p.playerId == selectedId || + (translatedId != null && p.playerId == translatedId) || + (reverseTranslatedId != null && p.playerId == reverseTranslatedId), + ); + // If preferLocalPlayer is OFF, check if we should switch to a playing player if (!preferLocalPlayer) { - final currentPlayerState = _availablePlayers - .firstWhere((p) => p.playerId == _selectedPlayer!.playerId) - .state; - final currentIsPlaying = currentPlayerState == 'playing'; + final currentIsPlaying = currentPlayer.state == 'playing'; // Exclude external sources - they're not playing MA content final playingPlayers = _availablePlayers.where( (p) => p.state == 'playing' && p.available && !p.isExternalSource, @@ -2434,9 +3462,10 @@ class MusicAssistantProvider with ChangeNotifier { // Keep current selection if no switch happened if (playerToSelect == null) { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.playerId == _selectedPlayer!.playerId, - ); + playerToSelect = currentPlayer; + if (currentPlayer.playerId != selectedId) { + _logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}'); + } } } } @@ -2485,12 +3514,13 @@ class MusicAssistantProvider with ChangeNotifier { // Priority 3: Last manually selected player if (playerToSelect == null && lastSelectedPlayerId != null) { - try { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.playerId == lastSelectedPlayerId && p.available, - ); + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == lastSelectedPlayerId && p.available, + orElse: () => null, + ); + if (playerToSelect != null) { _logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}'); - } catch (e) {} + } } // Priority 4: First available player @@ -2514,7 +3544,15 @@ class MusicAssistantProvider with ChangeNotifier { } void selectPlayer(Player player, {bool skipNotify = false}) async { - _selectedPlayer = player; + // Reentrancy guard - prevent concurrent selection which can cause race conditions + if (_selectPlayerInProgress) { + _logger.log('⚠️ selectPlayer already in progress, skipping for ${player.name}'); + return; + } + _selectPlayerInProgress = true; + + try { + _selectedPlayer = player; // Cache for instant display on app resume _cacheService.setCachedSelectedPlayer(player); @@ -2642,6 +3680,9 @@ class MusicAssistantProvider with ChangeNotifier { if (!skipNotify) { notifyListeners(); } + } finally { + _selectPlayerInProgress = false; + } } /// Cycle to the next active player (for notification switch button) @@ -2863,6 +3904,15 @@ class MusicAssistantProvider with ChangeNotifier { _cacheService.setCachedTrackForPlayer(player.playerId, track); _logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"'); + // Dual-cache for Cast<->Sendspin players + final sendspinId = _castToSendspinIdMap[player.playerId]; + if (sendspinId != null) { + _cacheService.setCachedTrackForPlayer(sendspinId, track); + } else if (player.provider == 'chromecast' && player.playerId.length >= 8) { + final computedSendspinId = 'cast-${player.playerId.substring(0, 8)}'; + _cacheService.setCachedTrackForPlayer(computedSendspinId, track); + } + // Also precache the image so it's ready for swipe preview final imageUrl = getImageUrl(track, size: 512); if (imageUrl != null) { @@ -2962,12 +4012,53 @@ class MusicAssistantProvider with ChangeNotifier { try { bool stateChanged = false; + // Get fresh player data - but look up from _availablePlayers which has + // the processed names (Sendspin suffix removed) and correct filtering + // Also check translated Cast<->Sendspin IDs + final selectedId = _selectedPlayer!.playerId; + final translatedSendspinId = _castToSendspinIdMap[selectedId]; + String? translatedCastId; + for (final entry in _castToSendspinIdMap.entries) { + if (entry.value == selectedId) { + translatedCastId = entry.key; + break; + } + } + + // Try to refresh from API first for latest state final allPlayers = await getPlayers(); - final updatedPlayer = allPlayers.firstWhere( - (p) => p.playerId == _selectedPlayer!.playerId, + Player? rawPlayer = allPlayers.firstWhere( + (p) => p.playerId == selectedId || + (translatedSendspinId != null && p.playerId == translatedSendspinId) || + (translatedCastId != null && p.playerId == translatedCastId), orElse: () => _selectedPlayer!, ); + // Now get the processed version from _availablePlayers to preserve renamed name + // But update with latest state (volume, playing state, etc.) from rawPlayer + final processedPlayer = _availablePlayers.firstWhere( + (p) => p.playerId == selectedId || + (translatedSendspinId != null && p.playerId == translatedSendspinId) || + (translatedCastId != null && p.playerId == translatedCastId), + orElse: () => rawPlayer, + ); + + // Use the processed player's name but raw player's state + final updatedPlayer = processedPlayer.copyWith( + state: rawPlayer.state, + volumeLevel: rawPlayer.volumeLevel, + volumeMuted: rawPlayer.volumeMuted, + elapsedTime: rawPlayer.elapsedTime, + elapsedTimeLastUpdated: rawPlayer.elapsedTimeLastUpdated, + powered: rawPlayer.powered, + available: rawPlayer.available, + groupMembers: rawPlayer.groupMembers, + syncedTo: rawPlayer.syncedTo, + currentItemId: rawPlayer.currentItemId, + isExternalSource: rawPlayer.isExternalSource, + appId: rawPlayer.appId, + ); + _selectedPlayer = updatedPlayer; stateChanged = true; @@ -2985,6 +4076,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) { @@ -3026,6 +4122,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(); @@ -3085,6 +4187,15 @@ class MusicAssistantProvider with ChangeNotifier { // Update cache if queue track has good metadata if (queueHasImage || queueHasProperArtist) { _cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack); + + // Dual-cache for Cast<->Sendspin players + final sendspinId = _castToSendspinIdMap[_selectedPlayer!.playerId]; + if (sendspinId != null) { + _cacheService.setCachedTrackForPlayer(sendspinId, queueTrack); + } else if (_selectedPlayer!.provider == 'chromecast' && _selectedPlayer!.playerId.length >= 8) { + final computedSendspinId = 'cast-${_selectedPlayer!.playerId.substring(0, 8)}'; + _cacheService.setCachedTrackForPlayer(computedSendspinId, queueTrack); + } } } stateChanged = true; @@ -3330,6 +4441,74 @@ class MusicAssistantProvider with ChangeNotifier { await syncService.syncFromApi(_api!); } + /// Load radio stations from the library + Future loadRadioStations() async { + if (!isConnected || _api == null) return; + + try { + _isLoadingRadio = true; + notifyListeners(); + + _radioStations = await _api!.getRadioStations(limit: 100); + _isLoadingRadio = false; + notifyListeners(); + } catch (e) { + _logger.log('⚠️ Failed to load radio stations: $e'); + _isLoadingRadio = false; + notifyListeners(); + } + } + + Future loadPodcasts() async { + if (!isConnected || _api == null) return; + + try { + _isLoadingPodcasts = true; + notifyListeners(); + + _podcasts = await _api!.getPodcasts(limit: 100); + + _logger.log('🎙️ Loaded ${_podcasts.length} podcasts'); + _isLoadingPodcasts = false; + notifyListeners(); + + // Fetch episode covers in background for podcasts with low-res images + _loadPodcastCoversInBackground(); + } catch (e) { + _logger.log('⚠️ Failed to load podcasts: $e'); + _isLoadingPodcasts = false; + notifyListeners(); + } + } + + /// Load high-resolution podcast covers from iTunes in background + /// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency) + Future _loadPodcastCoversInBackground() async { + if (_api == null) return; + + for (final podcast in _podcasts) { + try { + // Skip if already cached (either in memory or loaded from persistence) + if (_podcastCoverCache.containsKey(podcast.itemId)) continue; + + // Try iTunes for high-res artwork + final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name); + + if (itunesArtwork != null) { + _podcastCoverCache[podcast.itemId] = itunesArtwork; + _logger.log('🎙️ Cached iTunes artwork for ${podcast.name}'); + + // Persist to storage for instant display on next launch + SettingsService.addPodcastCoverToCache(podcast.itemId, itunesArtwork); + + notifyListeners(); + } + } catch (e) { + _logger.log('⚠️ Failed to load cover for ${podcast.name}: $e'); + } + } + } + Future loadArtists({int? limit, int? offset, String? search}) async { if (!isConnected) return; @@ -3342,6 +4521,7 @@ class MusicAssistantProvider with ChangeNotifier { limit: limit ?? LibraryConstants.maxLibraryItems, offset: offset, search: search, + albumArtistsOnly: false, // Show ALL library artists, not just those with albums ); _isLoading = false; @@ -3401,13 +4581,38 @@ class MusicAssistantProvider with ChangeNotifier { } try { - return await _api!.search(query, libraryOnly: libraryOnly); + final results = await _api!.search(query, libraryOnly: libraryOnly); + return filterSearchResults(results); } catch (e) { ErrorHandler.logError('Search', e); return {'artists': [], 'albums': [], 'tracks': []}; } } + /// Get playlists with provider filtering applied + Future> getPlaylists({int? limit, bool? favoriteOnly}) async { + if (_api == null) return []; + try { + final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly); + return filterByProvider(playlists); + } catch (e) { + _logger.log('❌ Failed to fetch playlists: $e'); + return []; + } + } + + /// Get audiobooks with provider filtering applied + Future> getAudiobooks({int? limit, bool? favoriteOnly}) async { + if (_api == null) return []; + try { + final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false); + return filterByProvider(audiobooks); + } catch (e) { + _logger.log('❌ Failed to fetch audiobooks: $e'); + return []; + } + } + String getStreamUrl(String provider, String itemId, {String? uri, List? providerMappings}) { return _api?.getStreamUrl(provider, itemId, uri: uri, providerMappings: providerMappings) ?? ''; } @@ -3416,6 +4621,19 @@ class MusicAssistantProvider with ChangeNotifier { return _api?.getImageUrl(item, size: size); } + /// Get best available podcast cover URL + /// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy + /// The cache is persisted to storage and loaded on app start for instant high-res display + String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) { + // Return cached iTunes URL if available (persisted across app launches) + final cachedUrl = _podcastCoverCache[podcast.itemId]; + if (cachedUrl != null) { + return cachedUrl; + } + // Fall back to MA imageproxy (will be replaced once iTunes fetch completes) + return _api?.getImageUrl(podcast, size: size); + } + /// Get artist image URL with fallback to external sources (Deezer, Fanart.tv) /// Returns a Future since fallback requires async API calls Future getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async { @@ -3457,6 +4675,31 @@ class MusicAssistantProvider with ChangeNotifier { effectivePlayerId = player.syncedTo!; } + // Translate Sendspin ID to Cast UUID for queue fetch + // MA stores queues under the Cast UUID, not the Sendspin ID + if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) { + // Sendspin ID format: cast-7df484e3 -> need Cast UUID starting with 7df484e3 + final prefix = effectivePlayerId.substring(5); // Remove "cast-" + // Reverse lookup in the map + for (final entry in _castToSendspinIdMap.entries) { + if (entry.value == effectivePlayerId) { + _logger.log('🔗 Translated Sendspin ID $effectivePlayerId to Cast UUID ${entry.key} for queue fetch'); + effectivePlayerId = entry.key; + break; + } + } + // If not in map, try to find in available players + if (effectivePlayerId.startsWith('cast-')) { + for (final p in _availablePlayers) { + if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) { + _logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $effectivePlayerId via player lookup'); + effectivePlayerId = p.playerId; + break; + } + } + } + } + final queue = await _api?.getQueue(effectivePlayerId); // Persist queue to database for instant display on app resume @@ -3592,8 +4835,10 @@ class MusicAssistantProvider with ChangeNotifier { _logger.log('⏸️ Non-blocking local pause for builtin player'); // CRITICAL: Don't await these - they can block the UI thread - // Use unawaited to make them fire-and-forget - unawaited(_pcmAudioPlayer?.pause() ?? Future.value()); + // Use unawaited to make them fire-and-forget, but log errors + unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError( + (e) => _logger.log('⚠️ PCM pause error (non-blocking): $e'), + )); // Don't pause just_audio for Sendspin mode - it's not being used for audio output // and calling pause() on it can cause blocking issues @@ -3609,8 +4854,10 @@ class MusicAssistantProvider with ChangeNotifier { } } - // Send command to MA for proper state sync - don't await - unawaited(_api?.pausePlayer(playerId) ?? Future.value()); + // Send command to MA for proper state sync - don't await, but log errors + unawaited((_api?.pausePlayer(playerId) ?? Future.value()).catchError( + (e) => _logger.log('⚠️ MA pause command error (non-blocking): $e'), + )); } catch (e) { ErrorHandler.logError('Pause player', e); // Don't rethrow - we want pause to be resilient @@ -3688,8 +4935,10 @@ class MusicAssistantProvider with ChangeNotifier { final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) { _logger.log('⏭️ Non-blocking local stop for skip on builtin player'); - // Stop current audio immediately - fire and forget - unawaited(_pcmAudioPlayer?.pause() ?? Future.value()); + // Stop current audio immediately - fire and forget, but log errors + unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError( + (e) => _logger.log('⚠️ PCM pause error on next (non-blocking): $e'), + )); // Don't stop just_audio - not used for Sendspin audio output } await _api?.nextTrack(playerId); @@ -3705,8 +4954,10 @@ class MusicAssistantProvider with ChangeNotifier { final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) { _logger.log('⏮️ Non-blocking local stop for previous on builtin player'); - // Stop current audio immediately - fire and forget - unawaited(_pcmAudioPlayer?.pause() ?? Future.value()); + // Stop current audio immediately - fire and forget, but log errors + unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError( + (e) => _logger.log('⚠️ PCM pause error on previous (non-blocking): $e'), + )); // Don't stop just_audio - not used for Sendspin audio output } await _api?.previousTrack(playerId); @@ -3747,15 +4998,43 @@ class MusicAssistantProvider with ChangeNotifier { return; } - // Translate Cast player ID to Sendspin ID if available - // This is needed because regular Cast players can't sync with Sendspin players - final actualTargetId = _castToSendspinIdMap[targetPlayerId] ?? targetPlayerId; + // Translate Cast player IDs to Sendspin IDs for group commands + // Cast players don't support group commands - only their Sendspin counterparts do + // We need to translate BOTH target and leader IDs + // + // The mapping comes from _castToSendspinIdMap which contains: + // 1. Currently discovered mappings (from available Sendspin players) + // 2. Persisted mappings from database (survives when Sendspin player is unavailable) + String translateToSendspinId(String playerId, Player? player) { + if (_castToSendspinIdMap.containsKey(playerId)) { + _logger.log('🔗 Found Sendspin mapping: $playerId -> ${_castToSendspinIdMap[playerId]}'); + return _castToSendspinIdMap[playerId]!; + } + // No Sendspin counterpart known - use original ID + return playerId; + } + + // Find target player to check its provider + final targetPlayer = _availablePlayers.where((p) => p.playerId == targetPlayerId).firstOrNull; + + // Translate BOTH target AND leader to Sendspin IDs + // Only Sendspin players support the SET_MEMBERS feature required for grouping + // Cast players will return "Player X does not support group commands" + final actualTargetId = translateToSendspinId(targetPlayerId, targetPlayer); + String actualLeaderId = leaderPlayer.playerId; + + // If leader is a Cast player, translate to its Sendspin counterpart + if (_castToSendspinIdMap.containsKey(leaderPlayer.playerId)) { + actualLeaderId = _castToSendspinIdMap[leaderPlayer.playerId]!; + _logger.log('🔗 Translated leader Cast ID to Sendspin ID: ${leaderPlayer.playerId} -> $actualLeaderId'); + } + if (actualTargetId != targetPlayerId) { - _logger.log('🔗 Translated Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId'); + _logger.log('🔗 Translated target Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId'); } - _logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, ${leaderPlayer.playerId})'); - await _api!.syncPlayerToLeader(actualTargetId, leaderPlayer.playerId); + _logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, $actualLeaderId)'); + await _api!.syncPlayerToLeader(actualTargetId, actualLeaderId); _logger.log('✅ API sync call completed'); // Refresh players to get updated group state @@ -3773,7 +5052,51 @@ class MusicAssistantProvider with ChangeNotifier { Future unsyncPlayer(String playerId) async { try { _logger.log('🔓 Unsyncing player: $playerId'); - await _api?.unsyncPlayer(playerId); + + // Find the player to check if it's a child + final player = _availablePlayers.firstWhere( + (p) => p.playerId == playerId, + orElse: () => Player( + playerId: playerId, + name: '', + available: false, + powered: false, + state: 'idle', + ), + ); + + // Determine the effective player ID to unsync + // For children, we unsync the child directly (not the leader!) + // Unsyncing the leader would dissolve the entire group + String effectivePlayerId = playerId; + String? leaderId = player.syncedTo; + + if (leaderId != null) { + _logger.log('🔓 Player is a child synced to $leaderId, unsyncing child directly'); + } + + // Try to unsync the player directly + try { + await _api?.unsyncPlayer(effectivePlayerId); + } catch (e) { + // Some players (like pure Sendspin CLI players) don't support set_members + // In this case, we need to unsync via the leader instead + if (e.toString().contains('set_members') && leaderId != null) { + _logger.log('🔓 Child unsync failed (set_members not supported), unsyncing via leader'); + + // Try to find the Sendspin ID for the leader if it's a Cast UUID + String leaderToUnsync = leaderId; + if (_castToSendspinIdMap.containsKey(leaderId)) { + leaderToUnsync = _castToSendspinIdMap[leaderId]!; + _logger.log('🔓 Translated leader to Sendspin ID: $leaderToUnsync'); + } + + await _api?.unsyncPlayer(leaderToUnsync); + _logger.log('⚠️ Dissolved entire group (pure Sendspin player limitation)'); + } else { + rethrow; + } + } // Refresh players to get updated group state await refreshPlayers(); @@ -4083,9 +5406,12 @@ class MusicAssistantProvider with ChangeNotifier { _playerStateTimer?.cancel(); _notificationPositionTimer?.cancel(); _localPlayerStateReportTimer?.cancel(); + _connectionStateSubscription?.cancel(); _localPlayerEventSubscription?.cancel(); _playerUpdatedEventSubscription?.cancel(); _playerAddedEventSubscription?.cancel(); + _mediaItemAddedEventSubscription?.cancel(); + _mediaItemDeletedEventSubscription?.cancel(); _positionTracker.dispose(); _pcmAudioPlayer?.dispose(); _sendspinService?.dispose(); diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index e22ed8cb..cb14607d 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -559,29 +559,28 @@ class PlayerProvider with ChangeNotifier { if (playerToSelect == null) { final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); + // Priority 1: Last selected player if (lastSelectedPlayerId != null) { - try { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.playerId == lastSelectedPlayerId && p.available, - ); - } catch (e) {} + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == lastSelectedPlayerId && p.available, + orElse: () => null, + ); } + // Priority 2: Built-in (local) player if (playerToSelect == null && builtinPlayerId != null) { - try { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.playerId == builtinPlayerId && p.available, - ); - } catch (e) {} + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == builtinPlayerId && p.available, + orElse: () => null, + ); } - // Try playing player (exclude external sources - not playing MA content) + // Priority 3: Currently playing player (exclude external sources - not playing MA content) if (playerToSelect == null) { - try { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.state == 'playing' && p.available && !p.isExternalSource, - ); - } catch (e) {} + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.state == 'playing' && p.available && !p.isExternalSource, + orElse: () => null, + ); } if (playerToSelect == null) { diff --git a/lib/screens/album_details_screen.dart b/lib/screens/album_details_screen.dart index 5adf9ab9..dba040bb 100644 --- a/lib/screens/album_details_screen.dart +++ b/lib/screens/album_details_screen.dart @@ -10,6 +10,7 @@ import '../services/metadata_service.dart'; import '../services/debug_logger.dart'; import '../services/recently_played_service.dart'; import '../widgets/global_player_overlay.dart'; +import '../widgets/player_picker_sheet.dart'; import '../l10n/app_localizations.dart'; import 'artist_details_screen.dart'; @@ -35,6 +36,7 @@ class _AlbumDetailsScreenState extends State with SingleTick List _tracks = []; bool _isLoading = true; bool _isFavorite = false; + bool _isInLibrary = false; ColorScheme? _lightColorScheme; ColorScheme? _darkColorScheme; int? _expandedTrackIndex; @@ -51,6 +53,7 @@ class _AlbumDetailsScreenState extends State with SingleTick void initState() { super.initState(); _isFavorite = widget.album.favorite ?? false; + _isInLibrary = widget.album.inLibrary; _loadTracks(); _loadAlbumDescription(); @@ -139,7 +142,8 @@ class _AlbumDetailsScreenState extends State with SingleTick orElse: () => widget.album.providerMappings!.first, ), ); - actualProvider = mapping.providerInstance; + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; } @@ -170,7 +174,6 @@ class _AlbumDetailsScreenState extends State with SingleTick throw Exception('Could not determine library ID for this album'); } - _logger.log('Removing from favorites: libraryItemId=$libraryItemId'); success = await maProvider.removeFromFavorites( mediaType: 'album', libraryItemId: libraryItemId, @@ -213,6 +216,104 @@ class _AlbumDetailsScreenState extends State with SingleTick } } + /// Toggle library status + Future _toggleLibrary() async { + final maProvider = context.read(); + + try { + final newState = !_isInLibrary; + bool success; + + if (newState) { + // Add to library - MUST use non-library provider + String? actualProvider; + String? actualItemId; + + if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) { + // For adding to library, we MUST use a non-library provider + final nonLibraryMapping = widget.album.providerMappings!.where( + (m) => m.providerInstance != 'library' && m.providerDomain != 'library', + ).firstOrNull; + + if (nonLibraryMapping != null) { + actualProvider = nonLibraryMapping.providerDomain; + actualItemId = nonLibraryMapping.itemId; + } + } + + // Fallback to item's own provider if no non-library mapping found + if (actualProvider == null || actualItemId == null) { + if (widget.album.provider != 'library') { + actualProvider = widget.album.provider; + actualItemId = widget.album.itemId; + } else { + // Item is library-only, can't add + _logger.log('Cannot add to library: album is library-only'); + return; + } + } + + _logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId'); + success = await maProvider.addToLibrary( + mediaType: 'album', + provider: actualProvider, + itemId: actualItemId, + ); + } else { + // Remove from library + int? libraryItemId; + if (widget.album.provider == 'library') { + libraryItemId = int.tryParse(widget.album.itemId); + } else if (widget.album.providerMappings != null) { + final libraryMapping = widget.album.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => widget.album.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId == null) { + _logger.log('Cannot remove from library: no library ID found'); + return; + } + + success = await maProvider.removeFromLibrary( + mediaType: 'album', + libraryItemId: libraryItemId, + ); + } + + if (success) { + setState(() { + _isInLibrary = newState; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary, + ), + duration: const Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + _logger.log('Error toggling album library: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update library: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + Future _toggleTrackFavorite(int trackIndex) async { if (trackIndex < 0 || trackIndex >= _tracks.length) return; @@ -259,7 +360,8 @@ class _AlbumDetailsScreenState extends State with SingleTick orElse: () => track.providerMappings!.first, ), ); - actualProvider = mapping.providerInstance; + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; } @@ -705,8 +807,8 @@ class _AlbumDetailsScreenState extends State with SingleTick backgroundColor: colorScheme.background, body: LayoutBuilder( builder: (context, constraints) { - // Responsive cover size: 50% of screen width, clamped between 200-320 - final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0); + // Responsive cover size: 70% of screen width, clamped between 200-320 + final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final expandedHeight = coverSize + 70; return CustomScrollView( @@ -730,35 +832,50 @@ class _AlbumDetailsScreenState extends State with SingleTick const SizedBox(height: 60), GestureDetector( onTap: () => _showFullscreenArt(imageUrl), - child: Hero( - tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix, - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - image: imageUrl != null - ? DecorationImage( - image: CachedNetworkImageProvider(imageUrl), - fit: BoxFit.cover, - ) - : null, + // Shadow container (outside Hero for correct clipping) + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Hero( + tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix, + // FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: coverSize, + height: coverSize, + color: colorScheme.surfaceVariant, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + // Match source memCacheWidth for smooth Hero + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Icon( + Icons.album_rounded, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ) + : Icon( + Icons.album_rounded, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ), ), - child: imageUrl == null - ? Icon( - Icons.album_rounded, - size: coverSize * 0.43, - color: colorScheme.onSurfaceVariant, - ) - : null, ), ), ), @@ -768,7 +885,7 @@ class _AlbumDetailsScreenState extends State with SingleTick ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -909,17 +1026,32 @@ class _AlbumDetailsScreenState extends State with SingleTick ), ), ), + + const SizedBox(width: 12), + + // Library Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _toggleLibrary, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + _isInLibrary ? Icons.library_add_check : Icons.library_add, + color: _isInLibrary + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), ], ), const SizedBox(height: 24), - Text( - S.of(context)!.tracks, - style: textTheme.titleLarge?.copyWith( - color: colorScheme.onBackground, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), ], ), ), @@ -1095,109 +1227,62 @@ class _AlbumDetailsScreenState extends State with SingleTick void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) { final maProvider = context.read(); - final players = maProvider.availablePlayers; - _showPlayOnSheet( - context, - players, - onPlayerSelected: (player) { + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { maProvider.selectPlayer(player); - maProvider.playTracks( + await maProvider.playTracks( player.playerId, _tracks, startIndex: startIndex, ); }, - ); + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); } void _showPlayRadioMenu(BuildContext context, int trackIndex) { final maProvider = context.read(); - final players = maProvider.availablePlayers; final track = _tracks[trackIndex]; - _showPlayOnSheet( - context, - players, - onPlayerSelected: (player) { + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { maProvider.selectPlayer(player); - maProvider.playRadio(player.playerId, track); + await maProvider.playRadio(player.playerId, track); }, - ); + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); } void _showPlayOnMenu(BuildContext context) { final maProvider = context.read(); - final players = maProvider.availablePlayers; - - _showPlayOnSheet( - context, - players, - onPlayerSelected: (player) { - maProvider.selectPlayer(player); - maProvider.playTracks(player.playerId, _tracks); - }, - ); - } - /// Shared "Play on..." bottom sheet that sizes to content - void _showPlayOnSheet( - BuildContext context, - List players, { - required void Function(dynamic player) onPlayerSelected, - }) { - // Slide mini player down out of the way GlobalPlayerOverlay.hidePlayer(); - showModalBottomSheet( + showPlayerPickerSheet( context: context, - backgroundColor: Colors.transparent, - builder: (context) => Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - Text( - S.of(context)!.playOn, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - if (players.isEmpty) - Padding( - padding: const EdgeInsets.all(32.0), - child: Text(S.of(context)!.noPlayersAvailable), - ) - else - Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: players.length, - itemBuilder: (context, index) { - final player = players[index]; - return ListTile( - leading: Icon( - Icons.speaker, - color: Theme.of(context).colorScheme.onSurface, - ), - title: Text(player.name), - onTap: () { - Navigator.pop(context); - onPlayerSelected(player); - }, - ); - }, - ), - ), - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ], - ), - ), + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + maProvider.selectPlayer(player); + await maProvider.playTracks(player.playerId, _tracks); + }, ).whenComplete(() { - // Slide mini player back up when sheet is dismissed GlobalPlayerOverlay.showPlayer(); }); } diff --git a/lib/screens/artist_details_screen.dart b/lib/screens/artist_details_screen.dart index f7f05ae1..fbe6dcd0 100644 --- a/lib/screens/artist_details_screen.dart +++ b/lib/screens/artist_details_screen.dart @@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State { List _providerAlbums = []; bool _isLoading = true; bool _isFavorite = false; + bool _isInLibrary = false; ColorScheme? _lightColorScheme; ColorScheme? _darkColorScheme; bool _isDescriptionExpanded = false; String? _artistDescription; String? _artistImageUrl; + MusicAssistantProvider? _maProvider; // View preferences String _sortOrder = 'alpha'; // 'alpha' or 'year' @@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State { void initState() { super.initState(); _isFavorite = widget.artist.favorite ?? false; + _isInLibrary = _checkIfInLibrary(widget.artist); // Use initial image URL immediately for smooth hero animation _artistImageUrl = widget.initialImageUrl; _loadViewPreferences(); @@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State { if (mounted) { _loadArtistImage(); // Note: _extractColors is called by _loadArtistImage after image loads + + // CRITICAL FIX: Delay adding provider listener until AFTER Hero animation + // Adding it immediately causes rebuilds during animation (jank) + _maProvider = context.read(); + _maProvider?.addListener(_onProviderChanged); } }); }); } + void _onProviderChanged() { + if (!mounted) return; + // Re-check library status when provider data changes + final newIsInLibrary = _checkIfInLibraryFromProvider(); + if (newIsInLibrary != _isInLibrary) { + setState(() { + _isInLibrary = newIsInLibrary; + }); + } + } + + /// Check if artist is in library using provider's artists list + bool _checkIfInLibraryFromProvider() { + if (_maProvider == null) return _checkIfInLibrary(widget.artist); + + final artistName = widget.artist.name.toLowerCase(); + final artistUri = widget.artist.uri; + + // Check if this artist exists in the provider's library + return _maProvider!.artists.any((a) { + // Match by URI if available + if (artistUri != null && a.uri == artistUri) return true; + // Match by name as fallback + if (a.name.toLowerCase() == artistName) return true; + // Check provider mappings for matching URIs + if (widget.artist.providerMappings != null) { + for (final mapping in widget.artist.providerMappings!) { + if (a.providerMappings?.any((m) => + m.providerInstance == mapping.providerInstance && + m.itemId == mapping.itemId) == true) { + return true; + } + } + } + return false; + }); + } + + @override + void dispose() { + _maProvider?.removeListener(_onProviderChanged); + super.dispose(); + } + Future _loadViewPreferences() async { final sortOrder = await SettingsService.getArtistAlbumsSortOrder(); final viewMode = await SettingsService.getArtistAlbumsViewMode(); @@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State { orElse: () => widget.artist.providerMappings!.first, ), ); - actualProvider = mapping.providerInstance; + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; } @@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State { throw Exception('Could not determine library ID for this artist'); } - _logger.log('Removing artist from favorites: libraryItemId=$libraryItemId'); success = await maProvider.removeFromFavorites( mediaType: 'artist', libraryItemId: libraryItemId, @@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State { } } + /// Check if artist is in library + bool _checkIfInLibrary(Artist artist) { + if (artist.provider == 'library') return true; + return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false; + } + + /// Toggle library status + Future _toggleLibrary() async { + final maProvider = context.read(); + + try { + final newState = !_isInLibrary; + bool success; + + if (newState) { + // Add to library - MUST use non-library provider + String? actualProvider; + String? actualItemId; + + if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) { + // For adding to library, we MUST use a non-library provider + final nonLibraryMapping = widget.artist.providerMappings!.where( + (m) => m.providerInstance != 'library' && m.providerDomain != 'library', + ).firstOrNull; + + if (nonLibraryMapping != null) { + actualProvider = nonLibraryMapping.providerDomain; + actualItemId = nonLibraryMapping.itemId; + } + } + + // Fallback to item's own provider if no non-library mapping found + if (actualProvider == null || actualItemId == null) { + if (widget.artist.provider != 'library') { + actualProvider = widget.artist.provider; + actualItemId = widget.artist.itemId; + } else { + // Item is library-only, can't add + _logger.log('Cannot add to library: artist is library-only'); + return; + } + } + + _logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId'); + success = await maProvider.addToLibrary( + mediaType: 'artist', + provider: actualProvider, + itemId: actualItemId, + ); + } else { + // Remove from library + int? libraryItemId; + if (widget.artist.provider == 'library') { + libraryItemId = int.tryParse(widget.artist.itemId); + } else if (widget.artist.providerMappings != null) { + final libraryMapping = widget.artist.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => widget.artist.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId == null) { + _logger.log('Cannot remove from library: no library ID found'); + return; + } + + success = await maProvider.removeFromLibrary( + mediaType: 'artist', + libraryItemId: libraryItemId, + ); + } + + if (success) { + setState(() { + _isInLibrary = newState; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary, + ), + duration: const Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + _logger.log('Error toggling artist library: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update library: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + void _showRadioMenu(BuildContext context) { final maProvider = context.read(); final players = maProvider.availablePlayers; @@ -553,8 +709,8 @@ class _ArtistDetailsScreenState extends State { backgroundColor: colorScheme.background, body: LayoutBuilder( builder: (context, constraints) { - // Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image) - final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0); + // Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image) + final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0); final expandedHeight = coverSize + 100; return CustomScrollView( @@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + // Match source memCacheWidth for smooth Hero animation + memCacheWidth: 256, + memCacheHeight: 256, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), errorWidget: (_, __, ___) => Icon( Icons.person_rounded, size: coverSize * 0.5, @@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State { ), ), ), + + const SizedBox(width: 12), + + // Library Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _toggleLibrary, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + _isInLibrary ? Icons.library_add_check : Icons.library_add, + color: _isInLibrary + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), ], ), const SizedBox(height: 16), diff --git a/lib/screens/audiobook_detail_screen.dart b/lib/screens/audiobook_detail_screen.dart index 1011ea99..6cdbdebe 100644 --- a/lib/screens/audiobook_detail_screen.dart +++ b/lib/screens/audiobook_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart import '../models/media_item.dart'; import '../providers/music_assistant_provider.dart'; import '../widgets/global_player_overlay.dart'; +import '../widgets/player_picker_sheet.dart'; import '../theme/palette_helper.dart'; import '../theme/theme_provider.dart'; import '../services/debug_logger.dart'; @@ -139,7 +140,8 @@ class _AudiobookDetailScreenState extends State { orElse: () => widget.audiobook.providerMappings!.first, ), ); - actualProvider = mapping.providerInstance; + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; } @@ -168,7 +170,6 @@ class _AudiobookDetailScreenState extends State { throw Exception('Could not determine library ID for this audiobook'); } - _logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId'); success = await maProvider.removeFromFavorites( mediaType: 'audiobook', libraryItemId: libraryItemId, @@ -209,65 +210,23 @@ class _AudiobookDetailScreenState extends State { void _showPlayOnMenu(BuildContext context) { final maProvider = context.read(); - final players = maProvider.availablePlayers; - // Slide mini player down out of the way GlobalPlayerOverlay.hidePlayer(); - showModalBottomSheet( + showPlayerPickerSheet( context: context, - backgroundColor: Colors.transparent, - builder: (context) => Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - Text( - S.of(context)!.playOn, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - if (players.isEmpty) - Padding( - padding: const EdgeInsets.all(32.0), - child: Text(S.of(context)!.noPlayersAvailable), - ) - else - Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: players.length, - itemBuilder: (context, index) { - final player = players[index]; - return ListTile( - leading: Icon( - Icons.speaker, - color: Theme.of(context).colorScheme.onSurface, - ), - title: Text(player.name), - onTap: () async { - Navigator.pop(context); - maProvider.selectPlayer(player); - maProvider.setCurrentAudiobook(_audiobook); - await maProvider.api?.playAudiobook( - player.playerId, - widget.audiobook, - ); - }, - ); - }, - ), - ), - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ], - ), - ), + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + maProvider.selectPlayer(player); + maProvider.setCurrentAudiobook(_audiobook); + await maProvider.api?.playAudiobook( + player.playerId, + widget.audiobook, + ); + }, ).whenComplete(() { - // Slide mini player back up when sheet is dismissed GlobalPlayerOverlay.showPlayer(); }); } @@ -470,8 +429,8 @@ class _AudiobookDetailScreenState extends State { backgroundColor: colorScheme.surface, body: LayoutBuilder( builder: (context, constraints) { - // Responsive cover size: 50% of screen width, clamped between 200-320 - final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0); + // Responsive cover size: 70% of screen width, clamped between 200-320 + final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final expandedHeight = coverSize + 70; return CustomScrollView( @@ -553,6 +512,9 @@ class _AudiobookDetailScreenState extends State { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + // Match source memCacheWidth for smooth Hero animation + memCacheWidth: 256, + memCacheHeight: 256, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (_, __) => Center( diff --git a/lib/screens/audiobook_series_screen.dart b/lib/screens/audiobook_series_screen.dart index 0705341b..0372fd5e 100644 --- a/lib/screens/audiobook_series_screen.dart +++ b/lib/screens/audiobook_series_screen.dart @@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State { backgroundColor: colorScheme.surface, body: LayoutBuilder( builder: (context, constraints) { - // Responsive cover size: 50% of screen width, clamped between 200-320 - final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0); + // Responsive cover size: 70% of screen width, clamped between 200-320 + final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final expandedHeight = coverSize + 70; return CustomScrollView( @@ -451,7 +451,7 @@ class _AudiobookSeriesScreenState extends State { _buildAudiobookSliver(maProvider), // Bottom padding for mini player - const SliverToBoxAdapter( + SliverToBoxAdapter( child: SizedBox(height: BottomSpacing.withMiniPlayer), ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 83cb1617..dab352d6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -68,6 +68,12 @@ class _HomeScreenState extends State { 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(); diff --git a/lib/screens/library_albums_screen.dart b/lib/screens/library_albums_screen.dart index 3b7a8eb5..22802402 100644 --- a/lib/screens/library_albums_screen.dart +++ b/lib/screens/library_albums_screen.dart @@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget { return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () async { await context.read().loadLibrary(); }, diff --git a/lib/screens/library_artists_screen.dart b/lib/screens/library_artists_screen.dart index 6d262711..87a4ee68 100644 --- a/lib/screens/library_artists_screen.dart +++ b/lib/screens/library_artists_screen.dart @@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget { return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () async { await context.read().loadLibrary(); }, diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index d56c3d91..e429acef 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -4,6 +4,9 @@ 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'; @@ -30,17 +33,11 @@ class _LibraryPlaylistsScreenState extends State { }); final maProvider = context.read(); - if (maProvider.api != null) { - final playlists = await maProvider.api!.getPlaylists(limit: 100); - setState(() { - _playlists = playlists; - _isLoading = false; - }); - } else { - setState(() { - _isLoading = false; - }); - } + final playlists = await maProvider.getPlaylists(limit: 100); + setState(() { + _playlists = playlists; + _isLoading = false; + }); } @override @@ -89,7 +86,7 @@ class _LibraryPlaylistsScreenState extends State { return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: _loadPlaylists, child: ListView.builder( itemCount: _playlists.length, @@ -110,34 +107,55 @@ class _LibraryPlaylistsScreenState extends State { 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 @@ -153,13 +171,14 @@ class _LibraryPlaylistsScreenState extends State { ? const Icon(Icons.favorite, color: Colors.red, 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, ), ), ); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 5596b9a0..e134124a 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -9,7 +9,6 @@ import '../services/debug_logger.dart'; import '../widgets/debug/debug_console.dart'; import '../l10n/app_localizations.dart'; import 'home_screen.dart'; -import 'remote/remote_access_login_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -512,68 +511,6 @@ class _LoginScreenState extends State { const SizedBox(height: 48), - // Remote Access button - TextButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const RemoteAccessLoginScreen(), - ), - ); - }, - icon: Icon( - Icons.cloud_outlined, - color: colorScheme.primary, - ), - label: Text( - 'Connect via Remote Access', - style: TextStyle( - color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), - backgroundColor: colorScheme.primaryContainer.withOpacity(0.3), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - - const SizedBox(height: 24), - - // Divider - Row( - children: [ - Expanded( - child: Divider( - color: colorScheme.onBackground.withOpacity(0.2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'OR', - style: TextStyle( - color: colorScheme.onBackground.withOpacity(0.5), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Divider( - color: colorScheme.onBackground.withOpacity(0.2), - ), - ), - ], - ), - - const SizedBox(height: 24), - // Server URL Text( S.of(context)!.serverAddress, diff --git a/lib/screens/new_home_screen.dart b/lib/screens/new_home_screen.dart index 01336007..061ace47 100644 --- a/lib/screens/new_home_screen.dart +++ b/lib/screens/new_home_screen.dart @@ -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 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 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(); + _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 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 with AutomaticKeepAliveCl _showFavoriteAlbums = showFavAlbums; _showFavoriteArtists = showFavArtists; _showFavoriteTracks = showFavTracks; + _showFavoritePlaylists = showFavPlaylists; + _showFavoriteRadioStations = showFavRadio; + _showFavoritePodcasts = showFavPodcasts; _showContinueListeningAudiobooks = showContAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverSeries = showDiscSeries; @@ -194,7 +276,7 @@ class _NewHomeScreenState extends State with AutomaticKeepAliveCl RefreshIndicator( onRefresh: _onRefresh, color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, child: _buildConnectedView(context, maProvider), ), // Connecting banner overlay (doesn't affect layout) @@ -238,15 +320,50 @@ class _NewHomeScreenState extends State 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 / 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( @@ -288,6 +405,10 @@ class _NewHomeScreenState extends State 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 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 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 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 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; } diff --git a/lib/screens/new_library_screen.dart b/lib/screens/new_library_screen.dart index ff8e36fa..1de6c22a 100644 --- a/lib/screens/new_library_screen.dart +++ b/lib/screens/new_library_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -6,7 +7,6 @@ import 'package:palette_generator/palette_generator.dart'; import '../providers/music_assistant_provider.dart'; import '../models/media_item.dart'; import '../widgets/global_player_overlay.dart'; -import '../widgets/player_selector.dart'; import '../widgets/album_card.dart'; import '../widgets/artist_avatar.dart'; import '../utils/page_transitions.dart'; @@ -14,6 +14,7 @@ import '../constants/hero_tags.dart'; import '../theme/theme_provider.dart'; import '../widgets/common/empty_state.dart'; import '../widgets/common/disconnected_state.dart'; +import '../widgets/letter_scrollbar.dart'; import '../services/settings_service.dart'; import '../services/metadata_service.dart'; import '../services/debug_logger.dart'; @@ -26,9 +27,10 @@ import 'settings_screen.dart'; import 'audiobook_author_screen.dart'; import 'audiobook_detail_screen.dart'; import 'audiobook_series_screen.dart'; +import 'podcast_detail_screen.dart'; /// Media type for the library -enum LibraryMediaType { music, books, podcasts } +enum LibraryMediaType { music, books, podcasts, radio } class NewLibraryScreen extends StatefulWidget { const NewLibraryScreen({super.key}); @@ -38,8 +40,8 @@ class NewLibraryScreen extends StatefulWidget { } class _NewLibraryScreenState extends State - with SingleTickerProviderStateMixin, RestorationMixin { - late TabController _tabController; + with RestorationMixin { + late PageController _pageController; List _playlists = []; List _favoriteTracks = []; List _audiobooks = []; @@ -48,9 +50,28 @@ class _NewLibraryScreenState extends State bool _isLoadingAudiobooks = false; bool _showFavoritesOnly = false; + // PERF: Pre-sorted lists - computed once on data load, not on every build + List _sortedPlaylists = []; + List _playlistNames = []; + List _sortedFavoriteTracks = []; + List _sortedAudiobooks = []; + List _audiobookNames = []; + List _sortedAuthorNames = []; + Map> _groupedAudiobooksByAuthor = {}; + List _sortedSeries = []; + List _seriesNames = []; + // Media type selection (Music, Books, Podcasts) LibraryMediaType _selectedMediaType = LibraryMediaType.music; + // Track overscroll for switching between media types + double _horizontalOverscroll = 0; + static const double _overscrollThreshold = 80; // Pixels to trigger switch + + // Track horizontal drag for single-category types (where PageView doesn't overscroll) + double _horizontalDragStart = 0; + double _horizontalDragDelta = 0; + // View mode settings String _artistsViewMode = 'list'; // 'grid2', 'grid3', 'list' String _albumsViewMode = 'grid2'; // 'grid2', 'grid3', 'list' @@ -58,6 +79,8 @@ class _NewLibraryScreenState extends State String _audiobooksViewMode = 'grid2'; // 'grid2', 'grid3', 'list' String _authorsViewMode = 'list'; // 'grid2', 'grid3', 'list' String _seriesViewMode = 'grid2'; // 'grid2', 'grid3' + String _radioViewMode = 'list'; // 'grid2', 'grid3', 'list' + String _podcastsViewMode = 'grid2'; // 'grid2', 'grid3', 'list' String _audiobooksSortOrder = 'alpha'; // 'alpha', 'year' // Author image cache @@ -75,9 +98,32 @@ class _NewLibraryScreenState extends State // Series book counts cache: seriesId -> number of books final Map _seriesBookCounts = {}; bool _seriesLoaded = false; + // Pre-cache flag for podcast images (smooth hero animations) + bool _hasPrecachedPodcasts = false; + // PERF: Debounce color extraction to avoid blocking UI during scroll + Timer? _colorExtractionDebounce; + final Map> _pendingColorExtractions = {}; // Restoration: Remember selected tab across app restarts final RestorableInt _selectedTabIndex = RestorableInt(0); + // PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable) + final ValueNotifier _tabIndexNotifier = ValueNotifier(0); + + // Scroll-to-hide filter bars + bool _isFilterBarVisible = true; + double _lastScrollOffset = 0; + static const double _scrollThreshold = 10.0; + bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging + + // Scroll controllers for letter scrollbar + final ScrollController _artistsScrollController = ScrollController(); + final ScrollController _albumsScrollController = ScrollController(); + final ScrollController _playlistsScrollController = ScrollController(); + final ScrollController _authorsScrollController = ScrollController(); + final ScrollController _audiobooksScrollController = ScrollController(); + final ScrollController _seriesScrollController = ScrollController(); + final ScrollController _podcastsScrollController = ScrollController(); + final ScrollController _radioScrollController = ScrollController(); int get _tabCount { switch (_selectedMediaType) { @@ -87,6 +133,8 @@ class _NewLibraryScreenState extends State return 3; // Authors, All Books, Series case LibraryMediaType.podcasts: return 1; // Coming soon placeholder + case LibraryMediaType.radio: + return 1; // Radio stations } } @@ -96,19 +144,22 @@ class _NewLibraryScreenState extends State @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(_selectedTabIndex, 'selected_tab_index'); - // Apply restored tab index after TabController is created - if (_tabController.index != _selectedTabIndex.value && - _selectedTabIndex.value < _tabController.length) { - _tabController.index = _selectedTabIndex.value; + // Sync ValueNotifier with restored value + _tabIndexNotifier.value = _selectedTabIndex.value; + // Apply restored tab index after PageController is created + if (_selectedTabIndex.value < _tabCount) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageController.hasClients) { + _pageController.jumpToPage(_selectedTabIndex.value); + } + }); } } @override void initState() { super.initState(); - _tabController = TabController(length: _tabCount, vsync: this); - // Listen to tab changes to persist selection - _tabController.addListener(_onTabChanged); + _pageController = PageController(); _loadPlaylists(); _loadViewPreferences(); } @@ -121,6 +172,8 @@ class _NewLibraryScreenState extends State final audiobooksMode = await SettingsService.getLibraryAudiobooksViewMode(); final audiobooksSortOrder = await SettingsService.getLibraryAudiobooksSortOrder(); final seriesMode = await SettingsService.getLibrarySeriesViewMode(); + final radioMode = await SettingsService.getLibraryRadioViewMode(); + final podcastsMode = await SettingsService.getLibraryPodcastsViewMode(); if (mounted) { setState(() { _artistsViewMode = artistsMode; @@ -130,6 +183,8 @@ class _NewLibraryScreenState extends State _audiobooksViewMode = audiobooksMode; _audiobooksSortOrder = audiobooksSortOrder; _seriesViewMode = seriesMode; + _radioViewMode = radioMode; + _podcastsViewMode = podcastsMode; }); } } @@ -216,7 +271,23 @@ class _NewLibraryScreenState extends State void _toggleAudiobooksSortOrder() { final newOrder = _audiobooksSortOrder == 'alpha' ? 'year' : 'alpha'; - setState(() => _audiobooksSortOrder = newOrder); + // PERF: Re-sort once on order change, not on every build + final sorted = List.from(_audiobooks); + if (newOrder == 'year') { + sorted.sort((a, b) { + if (a.year == null && b.year == null) return a.name.compareTo(b.name); + if (a.year == null) return 1; + if (b.year == null) return -1; + return a.year!.compareTo(b.year!); + }); + } else { + sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } + setState(() { + _audiobooksSortOrder = newOrder; + _sortedAudiobooks = sorted; + _audiobookNames = sorted.map((a) => a.name).toList(); + }); SettingsService.setLibraryAudiobooksSortOrder(newOrder); } @@ -237,6 +308,38 @@ class _NewLibraryScreenState extends State SettingsService.setLibrarySeriesViewMode(newMode); } + void _cycleRadioViewMode() { + String newMode; + switch (_radioViewMode) { + case 'list': + newMode = 'grid2'; + break; + case 'grid2': + newMode = 'grid3'; + break; + default: + newMode = 'list'; + } + setState(() => _radioViewMode = newMode); + SettingsService.setLibraryRadioViewMode(newMode); + } + + void _cyclePodcastsViewMode() { + String newMode; + switch (_podcastsViewMode) { + case 'grid2': + newMode = 'grid3'; + break; + case 'grid3': + newMode = 'list'; + break; + default: + newMode = 'grid2'; + } + setState(() => _podcastsViewMode = newMode); + SettingsService.setLibraryPodcastsViewMode(newMode); + } + IconData _getViewModeIcon(String mode) { switch (mode) { case 'list': @@ -250,7 +353,7 @@ class _NewLibraryScreenState extends State String _getCurrentViewMode() { // Return the view mode for the currently selected tab - final tabIndex = _tabController.index; + final tabIndex = _selectedTabIndex.value; // Handle books media type if (_selectedMediaType == LibraryMediaType.books) { @@ -266,6 +369,16 @@ class _NewLibraryScreenState extends State } } + // Handle radio media type + if (_selectedMediaType == LibraryMediaType.radio) { + return _radioViewMode; + } + + // Handle podcasts media type + if (_selectedMediaType == LibraryMediaType.podcasts) { + return _podcastsViewMode; + } + // Handle music media type if (_showFavoritesOnly) { // Artists, Albums, Tracks, Playlists @@ -297,7 +410,7 @@ class _NewLibraryScreenState extends State } void _cycleCurrentViewMode() { - final tabIndex = _tabController.index; + final tabIndex = _selectedTabIndex.value; // Handle books media type if (_selectedMediaType == LibraryMediaType.books) { @@ -315,6 +428,18 @@ class _NewLibraryScreenState extends State return; } + // Handle radio media type + if (_selectedMediaType == LibraryMediaType.radio) { + _cycleRadioViewMode(); + return; + } + + // Handle podcasts media type + if (_selectedMediaType == LibraryMediaType.podcasts) { + _cyclePodcastsViewMode(); + return; + } + // Handle music media type if (_showFavoritesOnly) { switch (tabIndex) { @@ -346,16 +471,38 @@ class _NewLibraryScreenState extends State } } - void _recreateTabController() { - final oldIndex = _tabController.index; - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); - _tabController = TabController(length: _tabCount, vsync: this); - _tabController.addListener(_onTabChanged); - // Restore to previous index if valid, otherwise default to 0 - if (oldIndex < _tabCount) { - _tabController.index = oldIndex; + void _resetCategoryIndex() { + // Reset to first category when media type changes + if (_selectedTabIndex.value >= _tabCount) { + _selectedTabIndex.value = 0; + _tabIndexNotifier.value = 0; + } + // Jump to the selected category without animation + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageController.hasClients) { + _pageController.jumpToPage(_selectedTabIndex.value); + } + }); + } + + /// Get the color for a media type - muted colors based on primaryContainer tone + Color _getMediaTypeColor(ColorScheme colorScheme, LibraryMediaType type) { + final baseColor = colorScheme.primaryContainer; + final baseHsl = HSLColor.fromColor(baseColor); + + double hueShift; + switch (type) { + case LibraryMediaType.music: + hueShift = 0; // Keep primary hue (purple-ish) + case LibraryMediaType.books: + hueShift = 35; // Shift toward orange + case LibraryMediaType.podcasts: + hueShift = 160; // Shift toward teal + case LibraryMediaType.radio: + hueShift = -30; // Shift toward pink } + + return baseHsl.withHue((baseHsl.hue + hueShift) % 360).toColor(); } void _changeMediaType(LibraryMediaType type) { @@ -366,8 +513,10 @@ class _NewLibraryScreenState extends State } setState(() { _selectedMediaType = type; - _recreateTabController(); }); + _selectedTabIndex.value = 0; // Reset to first category + _tabIndexNotifier.value = 0; + _resetCategoryIndex(); // Load audiobooks when switching to books tab if (type == LibraryMediaType.books) { _logger.log('📚 Switched to Books, _audiobooks.isEmpty=${_audiobooks.isEmpty}'); @@ -379,41 +528,178 @@ class _NewLibraryScreenState extends State _loadSeries(); } } + // Load radio stations when switching to radio tab + if (type == LibraryMediaType.radio) { + final maProvider = context.read(); + if (maProvider.radioStations.isEmpty) { + maProvider.loadRadioStations(); + } + } + // Load podcasts when switching to podcasts tab + if (type == LibraryMediaType.podcasts) { + final maProvider = context.read(); + if (maProvider.podcasts.isEmpty) { + maProvider.loadPodcasts(); + } + } + } + + void _onPageChanged(int index) { + // Update both: RestorableInt for persistence, ValueNotifier for UI + // No setState needed - ValueListenableBuilder will rebuild only the filter chips + _selectedTabIndex.value = index; + _tabIndexNotifier.value = index; + } + + /// Handle horizontal overscroll to switch between media types + bool _handleHorizontalOverscroll(ScrollNotification notification) { + if (notification.metrics.axis != Axis.horizontal) { + return false; + } + + if (notification is OverscrollNotification) { + _horizontalOverscroll += notification.overscroll; + + // Check if we've overscrolled enough to switch + // Positive overscroll = swiping left at the end = go to NEXT type + // Negative overscroll = swiping right at the start = go to PREVIOUS type + if (_horizontalOverscroll > _overscrollThreshold) { + _horizontalOverscroll = 0; + _switchToNextMediaType(); + return true; + } else if (_horizontalOverscroll < -_overscrollThreshold) { + _horizontalOverscroll = 0; + _switchToPreviousMediaType(); + return true; + } + } else if (notification is ScrollEndNotification) { + // Reset overscroll accumulation when scroll ends + _horizontalOverscroll = 0; + } + + return false; + } + + void _switchToNextMediaType() { + final types = LibraryMediaType.values; + final currentIndex = types.indexOf(_selectedMediaType); + final nextIndex = (currentIndex + 1) % types.length; + _changeMediaType(types[nextIndex]); + } + + void _switchToPreviousMediaType() { + final types = LibraryMediaType.values; + final currentIndex = types.indexOf(_selectedMediaType); + final prevIndex = (currentIndex - 1 + types.length) % types.length; + _changeMediaType(types[prevIndex]); + } + + // Drag handlers for single-category types + void _onHorizontalDragStart(DragStartDetails details) { + _horizontalDragStart = details.globalPosition.dx; + _horizontalDragDelta = 0; + } + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + _horizontalDragDelta = details.globalPosition.dx - _horizontalDragStart; + } + + void _onHorizontalDragEnd(DragEndDetails details) { + // Swipe right (positive delta) = go to previous type + // Swipe left (negative delta) = go to next type + if (_horizontalDragDelta > _overscrollThreshold) { + _switchToPreviousMediaType(); + } else if (_horizontalDragDelta < -_overscrollThreshold) { + _switchToNextMediaType(); + } + _horizontalDragDelta = 0; + } + + /// Handle scroll notifications to hide/show filter bars + bool _handleScrollNotification(ScrollNotification notification) { + // Don't hide while dragging letter scrollbar + if (_isLetterScrollbarDragging) { + return false; + } + + // Only respond to vertical scroll (not horizontal PageView swipe) + if (notification.metrics.axis != Axis.vertical) { + return false; + } + + if (notification is ScrollUpdateNotification) { + final currentOffset = notification.metrics.pixels; + final delta = currentOffset - _lastScrollOffset; + + if (delta.abs() > _scrollThreshold) { + final shouldShow = delta < 0 || currentOffset <= 0; + if (shouldShow != _isFilterBarVisible) { + setState(() { + _isFilterBarVisible = shouldShow; + }); + } + _lastScrollOffset = currentOffset; + } + } + return false; + } + + void _onLetterScrollbarDragChanged(bool isDragging) { + setState(() { + _isLetterScrollbarDragging = isDragging; + // Show the filter bar when starting to drag + if (isDragging) { + _isFilterBarVisible = true; + } + }); } - void _onTabChanged() { - if (!_tabController.indexIsChanging) { - _selectedTabIndex.value = _tabController.index; + void _animateToCategory(int index) { + if (_pageController.hasClients) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); } + // Update both for persistence and UI + _selectedTabIndex.value = index; + _tabIndexNotifier.value = index; } @override void dispose() { - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); + _colorExtractionDebounce?.cancel(); + _pageController.dispose(); _selectedTabIndex.dispose(); + _tabIndexNotifier.dispose(); + _artistsScrollController.dispose(); + _albumsScrollController.dispose(); + _playlistsScrollController.dispose(); + _authorsScrollController.dispose(); + _audiobooksScrollController.dispose(); + _seriesScrollController.dispose(); + _podcastsScrollController.dispose(); + _radioScrollController.dispose(); super.dispose(); } Future _loadPlaylists({bool? favoriteOnly}) async { final maProvider = context.read(); - if (maProvider.api != null) { - final playlists = await maProvider.api!.getPlaylists( - limit: 100, - favoriteOnly: favoriteOnly, - ); - if (mounted) { - setState(() { - _playlists = playlists; - _isLoadingPlaylists = false; - }); - } - } else { - if (mounted) { - setState(() { - _isLoadingPlaylists = false; - }); - } + final playlists = await maProvider.getPlaylists( + limit: 100, + favoriteOnly: favoriteOnly, + ); + if (mounted) { + // PERF: Pre-sort once on load, not on every build + final sorted = List.from(playlists) + ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); + setState(() { + _playlists = playlists; + _sortedPlaylists = sorted; + _playlistNames = sorted.map((p) => p.name ?? '').toList(); + _isLoadingPlaylists = false; + }); } } @@ -431,8 +717,16 @@ class _NewLibraryScreenState extends State favoriteOnly: true, ); if (mounted) { + // PERF: Pre-sort once on load, not on every build + final sorted = List.from(tracks) + ..sort((a, b) { + final artistCompare = a.artistsString.compareTo(b.artistsString); + if (artistCompare != 0) return artistCompare; + return a.name.compareTo(b.name); + }); setState(() { _favoriteTracks = tracks; + _sortedFavoriteTracks = sorted; _isLoadingTracks = false; }); } @@ -459,32 +753,39 @@ class _NewLibraryScreenState extends State }); final maProvider = context.read(); - if (maProvider.api != null) { - _logger.log('📚 Calling API getAudiobooks...'); - final audiobooks = await maProvider.api!.getAudiobooks( - limit: 10000, // Large limit to get all audiobooks - favoriteOnly: favoriteOnly, - ); - _logger.log('📚 API returned ${audiobooks.length} audiobooks'); - if (audiobooks.isNotEmpty) { - _logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}'); - } - if (mounted) { - setState(() { - _audiobooks = audiobooks; - _isLoadingAudiobooks = false; - }); - _logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}'); - // Fetch author images in background - _fetchAuthorImages(audiobooks); - } - } else { - _logger.log('📚 API is null!'); - if (mounted) { - setState(() { - _isLoadingAudiobooks = false; - }); + _logger.log('📚 Calling getAudiobooks...'); + final audiobooks = await maProvider.getAudiobooks( + limit: 10000, // Large limit to get all audiobooks + favoriteOnly: favoriteOnly, + ); + _logger.log('📚 Returned ${audiobooks.length} audiobooks'); + if (audiobooks.isNotEmpty) { + _logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}'); + } + if (mounted) { + // PERF: Pre-sort and pre-group once on load, not on every build + final sortedAlpha = List.from(audiobooks) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + // Group audiobooks by author + final authorMap = >{}; + for (final book in audiobooks) { + final authorName = book.authorsString; + authorMap.putIfAbsent(authorName, () => []).add(book); } + final sortedAuthors = authorMap.keys.toList()..sort(); + + setState(() { + _audiobooks = audiobooks; + _sortedAudiobooks = sortedAlpha; + _audiobookNames = sortedAlpha.map((a) => a.name).toList(); + _groupedAudiobooksByAuthor = authorMap; + _sortedAuthorNames = sortedAuthors; + _isLoadingAudiobooks = false; + }); + _logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}'); + // Fetch author images in background + _fetchAuthorImages(audiobooks); } } @@ -536,8 +837,13 @@ class _NewLibraryScreenState extends State _logger.log('📚 Loaded ${series.length} series'); if (mounted) { + // PERF: Pre-sort once on load, not on every build + final sorted = List.from(series) + ..sort((a, b) => a.name.compareTo(b.name)); setState(() { _series = series; + _sortedSeries = sorted; + _seriesNames = sorted.map((s) => s.name).toList(); _isLoadingSeries = false; _seriesLoaded = true; }); @@ -587,8 +893,11 @@ class _NewLibraryScreenState extends State _seriesCoversLoading.remove(seriesId); }); - // Extract colors from covers asynchronously (don't block UI) - _extractSeriesColors(seriesId, covers); + // Precache images for smooth hero animations + _precacheSeriesCovers(covers); + + // PERF: Queue color extraction with debounce to avoid blocking UI during scroll + _queueColorExtraction(seriesId, covers); } } } catch (e) { @@ -597,14 +906,57 @@ class _NewLibraryScreenState extends State } } + /// Precache series cover images for smooth hero animations + void _precacheSeriesCovers(List covers) { + if (!mounted) return; + for (final url in covers) { + precacheImage( + CachedNetworkImageProvider(url), + context, + ).catchError((_) => false); + } + } + + /// PERF: Queue color extraction requests - processed after scroll settles + void _queueColorExtraction(String seriesId, List coverUrls) { + if (coverUrls.isEmpty) return; + + // Add to pending queue + _pendingColorExtractions[seriesId] = coverUrls; + + // Cancel existing timer and start a new one + _colorExtractionDebounce?.cancel(); + _colorExtractionDebounce = Timer(const Duration(milliseconds: 300), () { + _processQueuedColorExtractions(); + }); + } + + /// PERF: Process all queued color extractions in batch after scroll settles + Future _processQueuedColorExtractions() async { + if (_pendingColorExtractions.isEmpty || !mounted) return; + + // Copy and clear the queue to avoid processing new items added during extraction + final toProcess = Map>.from(_pendingColorExtractions); + _pendingColorExtractions.clear(); + + // Process each series sequentially to avoid overwhelming the UI thread + for (final entry in toProcess.entries) { + if (!mounted) break; + await _extractSeriesColors(entry.key, entry.value); + // Small yield between extractions to keep UI responsive + await Future.delayed(const Duration(milliseconds: 50)); + } + } + /// Extract dominant colors from series book covers for empty cell backgrounds Future _extractSeriesColors(String seriesId, List coverUrls) async { - if (coverUrls.isEmpty) return; + if (coverUrls.isEmpty || !mounted) return; final extractedColors = []; // Extract colors from first few covers (limit to avoid too much processing) for (final url in coverUrls.take(4)) { + if (!mounted) break; try { final palette = await PaletteGenerator.fromImageProvider( CachedNetworkImageProvider(url), @@ -641,8 +993,8 @@ class _NewLibraryScreenState extends State void _toggleFavoritesMode(bool value) { setState(() { _showFavoritesOnly = value; - _recreateTabController(); }); + _resetCategoryIndex(); if (value) { _loadPlaylists(favoriteOnly: true); _loadFavoriteTracks(); @@ -679,7 +1031,6 @@ class _NewLibraryScreenState extends State ), ), centerTitle: true, - actions: const [PlayerSelector()], ), body: DisconnectedState.withSettingsAction( context: context, @@ -693,295 +1044,594 @@ class _NewLibraryScreenState extends State return Scaffold( backgroundColor: colorScheme.background, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - titleSpacing: 0, - toolbarHeight: 56, - // Top left: Media type segmented selector - title: _buildMediaTypeSelector(colorScheme), - leadingWidth: 16, - leading: const SizedBox(), - centerTitle: false, - // Top right: Device selector - actions: const [PlayerSelector()], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(48), - child: Stack( - children: [ - // Bottom border line extending full width - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - height: 1, - color: colorScheme.outlineVariant.withOpacity(0.3), - ), - ), - Row( - children: [ - // Tabs centered - Expanded( - child: TabBar( - controller: _tabController, - labelColor: colorScheme.primary, - unselectedLabelColor: colorScheme.onSurface.withOpacity(0.6), - indicatorColor: colorScheme.primary, - indicatorWeight: 3, - labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), - unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14), - tabs: _buildTabs(l10n), - ), - ), - // Favorites + Layout buttons on the right - IconButton( - icon: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? Colors.red : colorScheme.onSurface.withOpacity(0.7), - size: 20, + body: SafeArea( + child: Column( + children: [ + // Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible) + _buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible), + // Connecting banner when showing cached data + // Hide when we have cached players - UI is functional during background reconnect + if (!isConnected && syncService.hasCache && !context.read().hasCachedPlayers) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: colorScheme.primaryContainer.withOpacity(0.5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), ), - onPressed: () => _toggleFavoritesMode(!_showFavoritesOnly), - tooltip: _showFavoritesOnly ? l10n.showAll : l10n.showFavoritesOnly, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - IconButton( - icon: Icon( - _getViewModeIcon(_getCurrentViewMode()), - color: colorScheme.onSurface.withOpacity(0.7), - size: 20, + const SizedBox(width: 8), + Text( + l10n.connecting, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onPrimaryContainer, + ), ), - onPressed: _cycleCurrentViewMode, - tooltip: l10n.changeView, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - const SizedBox(width: 8), - ], + ], + ), ), - ], - ), - ), - ), - body: Column( - children: [ - // Connecting banner when showing cached data - // Hide when we have cached players - UI is functional during background reconnect - if (!isConnected && syncService.hasCache && !context.read().hasCachedPlayers) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: colorScheme.primaryContainer.withOpacity(0.5), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + Expanded( + child: Stack( children: [ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Text( - l10n.connecting, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onPrimaryContainer, + // Main scrollable content + // For single-category types, wrap with GestureDetector for swipe detection + // (PageView with 1 item doesn't generate overscroll) + _tabCount == 1 + ? GestureDetector( + onHorizontalDragStart: _onHorizontalDragStart, + onHorizontalDragUpdate: _onHorizontalDragUpdate, + onHorizontalDragEnd: _onHorizontalDragEnd, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: _buildTabAtIndex(context, l10n, 0), + ), + ) + : NotificationListener( + onNotification: _handleScrollNotification, + // PERF: Use PageView.builder to only build visible tabs + // Wrapped with horizontal overscroll detection for media type switching + child: NotificationListener( + onNotification: _handleHorizontalOverscroll, + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _tabCount, + // Faster settling so vertical scroll works sooner after swipe + physics: const _FastPageScrollPhysics(), + itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index), + ), + ), + ), + // Fade gradient at top - content fades as it scrolls under filter bar + Positioned( + top: 0, + left: 0, + right: 0, + height: 24, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.background, + colorScheme.surface.withOpacity(0), + ], + ), + ), + ), ), ), ], ), ), - Expanded( - child: TabBarView( - controller: _tabController, - children: _buildTabViews(context, l10n), - ), - ), - ], + ], + ), ), ); }, ); } - // ============ MEDIA TYPE SELECTOR ============ - Widget _buildMediaTypeSelector(ColorScheme colorScheme) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildMediaTypeSegment( - type: LibraryMediaType.music, - icon: MdiIcons.musicNote, - colorScheme: colorScheme, - ), - _buildMediaTypeSegment( - type: LibraryMediaType.books, - icon: MdiIcons.bookOutline, - colorScheme: colorScheme, - ), - _buildMediaTypeSegment( - type: LibraryMediaType.podcasts, - icon: MdiIcons.podcast, - colorScheme: colorScheme, - ), - ], - ), - ); - } + // ============ FILTER ROWS ============ + // Consistent height for filter rows + static const double _filterRowHeight = 36.0; - Widget _buildMediaTypeSegment({ - required LibraryMediaType type, - required IconData icon, - required ColorScheme colorScheme, - }) { - final isSelected = _selectedMediaType == type; - return Material( - color: isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceVariant.withOpacity(0.5), - child: InkWell( - onTap: () => _changeMediaType(type), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Icon( - icon, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant.withOpacity(0.7), - size: 20, + Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Row 1: Media type chips (hides when scrolling) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + height: showLibraryTypeRow ? _filterRowHeight : 0, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _buildMediaTypeChips(colorScheme, l10n), ), ), - ), + if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows + // Row 2: Sub-category chips (left) + action buttons (right) - always visible + SizedBox( + height: _filterRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left: category chips - wrapped in ValueListenableBuilder for efficient rebuilds + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ValueListenableBuilder( + valueListenable: _tabIndexNotifier, + builder: (context, selectedIndex, _) { + return _buildCategoryChips(colorScheme, l10n, selectedIndex); + }, + ), + ), + ), + // Right: action buttons + _buildInlineActionButtons(colorScheme), + ], + ), + ), + ), + ], ); } - // ============ CONTEXTUAL TABS ============ - List _buildTabs(S l10n) { - switch (_selectedMediaType) { - case LibraryMediaType.music: - return [ - Tab(text: l10n.artists), - Tab(text: l10n.albums), - if (_showFavoritesOnly) Tab(text: l10n.tracks), - Tab(text: l10n.playlists), - ]; - case LibraryMediaType.books: - return [ - Tab(text: l10n.authors), - Tab(text: l10n.books), - Tab(text: l10n.series), - ]; - case LibraryMediaType.podcasts: - return [ - Tab(text: l10n.shows), - ]; + Widget _buildMediaTypeChips(ColorScheme colorScheme, S l10n) { + String getMediaTypeLabel(LibraryMediaType type) { + switch (type) { + case LibraryMediaType.music: + return l10n.music; + case LibraryMediaType.books: + return l10n.audiobooks; + case LibraryMediaType.podcasts: + return l10n.podcasts; + case LibraryMediaType.radio: + return l10n.radio; + } } - } - List _buildTabViews(BuildContext context, S l10n) { - switch (_selectedMediaType) { - case LibraryMediaType.music: - return [ - _buildArtistsTab(context, l10n), - _buildAlbumsTab(context, l10n), - if (_showFavoritesOnly) _buildTracksTab(context, l10n), - _buildPlaylistsTab(context, l10n), - ]; - case LibraryMediaType.books: - return [ - _buildBooksAuthorsTab(context, l10n), - _buildAllBooksTab(context, l10n), - _buildSeriesTab(context, l10n), - ]; - case LibraryMediaType.podcasts: - return [ - _buildPodcastsComingSoonTab(context, l10n), - ]; + IconData getMediaTypeIcon(LibraryMediaType type) { + switch (type) { + case LibraryMediaType.music: + return MdiIcons.musicNote; + case LibraryMediaType.books: + return MdiIcons.bookOpenPageVariant; + case LibraryMediaType.podcasts: + return MdiIcons.podcast; + case LibraryMediaType.radio: + return MdiIcons.radio; + } } - } - // ============ BOOKS TABS ============ - Widget _buildBooksAuthorsTab(BuildContext context, S l10n) { - final colorScheme = Theme.of(context).colorScheme; + final types = LibraryMediaType.values; - if (_isLoadingAudiobooks) { - return Center(child: CircularProgressIndicator(color: colorScheme.primary)); + // Calculate flex values based on label length + // Longer labels get more space to avoid clipping + // Base flex of 10 for icon + padding, plus label length + int getFlexForType(LibraryMediaType type) { + final label = getMediaTypeLabel(type); + // Base space for icon/padding + proportional text space + return 10 + label.length; } - // Filter by favorites if enabled - final audiobooks = _showFavoritesOnly - ? _audiobooks.where((a) => a.favorite == true).toList() - : _audiobooks; + // Pre-calculate flex values and total + final flexValues = types.map((t) => getFlexForType(t)).toList(); + final totalFlex = flexValues.reduce((a, b) => a + b); + final selectedIndex = types.indexOf(_selectedMediaType); + + // Horizontal inset for the pill highlight (gap between adjacent tabs) + const double hInset = 2.0; + final isFirstTab = selectedIndex == 0; + final isLastTab = selectedIndex == types.length - 1; + + // Animated sliding highlight with pill shape + return LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + + // Calculate left position for a given tab index + double getLeftPosition(int index) { + double left = 0; + for (int i = 0; i < index; i++) { + left += (flexValues[i] / totalFlex) * totalWidth; + } + return left; + } - if (audiobooks.isEmpty) { - if (_showFavoritesOnly) { - return EmptyState.custom( - context: context, - icon: Icons.favorite_border, - title: l10n.noFavoriteAudiobooks, - subtitle: l10n.tapHeartAudiobook, - ); - } - return EmptyState.custom( - context: context, - icon: MdiIcons.bookOutline, - title: l10n.noAudiobooks, - subtitle: l10n.addAudiobooksHint, - onRefresh: () => _loadAudiobooks(), - ); + // Calculate width for a given tab index + double getTabWidth(int index) { + return (flexValues[index] / totalFlex) * totalWidth; + } + + // Calculate highlight position and size based on whether it's at the edge + // First tab: flush with left edge, gap on right + // Last tab: gap on left, flush with right edge + // Middle tabs: gap on both sides + final leftInset = isFirstTab ? 0.0 : hInset; + final rightInset = isLastTab ? 0.0 : hInset; + final highlightLeft = getLeftPosition(selectedIndex) + leftInset; + final highlightWidth = getTabWidth(selectedIndex) - leftInset - rightInset; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + // Animated sliding highlight (pill shape with full border radius) + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: highlightLeft, + width: highlightWidth, + top: 0, + bottom: 0, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: _getMediaTypeColor(colorScheme, _selectedMediaType), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + // Tab buttons row (transparent, on top of highlight) + Row( + children: types.asMap().entries.map((entry) { + final index = entry.key; + final type = entry.value; + final isSelected = _selectedMediaType == type; + + return Expanded( + flex: flexValues[index], + child: GestureDetector( + onTap: () => _changeMediaType(type), + behavior: HitTestBehavior.opaque, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + getMediaTypeIcon(type), + size: 18, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + getMediaTypeLabel(type), + style: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface.withOpacity(0.7), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ); + }, + ); + } + + // Inline action buttons for favorites and view mode (right side of row 2) + Widget _buildInlineActionButtons(ColorScheme colorScheme) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + // Favorites toggle (only for music and books) + if (_selectedMediaType == LibraryMediaType.music || _selectedMediaType == LibraryMediaType.books) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + width: 36, + height: 36, + child: Material( + color: _showFavoritesOnly ? Colors.red : colorScheme.background, + elevation: 2, + shadowColor: Colors.black26, + shape: const CircleBorder(), + child: InkWell( + onTap: () => _toggleFavoritesMode(!_showFavoritesOnly), + customBorder: const CircleBorder(), + child: Icon( + _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, + size: 18, + color: _showFavoritesOnly ? Colors.white : colorScheme.onSurface, + ), + ), + ), + ), + ), + // View mode toggle + SizedBox( + width: 36, + height: 36, + child: Material( + color: colorScheme.background, + elevation: 2, + shadowColor: Colors.black26, + shape: const CircleBorder(), + child: InkWell( + onTap: _cycleCurrentViewMode, + customBorder: const CircleBorder(), + child: Icon( + _getViewModeIcon(_getCurrentViewMode()), + size: 18, + color: colorScheme.onSurface, + ), + ), + ), + ), + ], + ); + } + + Widget _buildCategoryChips(ColorScheme colorScheme, S l10n, int selectedIndex) { + final categories = _getCategoryLabels(l10n); + + // Calculate flex values based on label length (same approach as media type bar) + final flexValues = categories.map((label) => 10 + label.length).toList(); + final totalFlex = flexValues.reduce((a, b) => a + b); + + // Estimate total width: base padding (28) + text width per label + final estimatedTotalWidth = categories.fold( + 0, + (sum, label) => sum + 28 + label.length * 8.5, + ); + + const double hInset = 2.0; + final isFirstTab = selectedIndex == 0; + final isLastTab = selectedIndex == categories.length - 1; + + // Calculate left position for a given index + double getLeftPosition(int index) { + double left = 0; + for (int i = 0; i < index; i++) { + left += (flexValues[i] / totalFlex) * estimatedTotalWidth; + } + return left; } - // Group audiobooks by author - final authorMap = >{}; - for (final book in audiobooks) { - final authorName = book.authorsString; - authorMap.putIfAbsent(authorName, () => []).add(book); + // Calculate width for a given index + double getTabWidth(int index) { + return (flexValues[index] / totalFlex) * estimatedTotalWidth; + } + + // Calculate highlight position and size based on whether it's at the edge + // First tab: flush with left edge, gap on right + // Last tab: gap on left, flush with right edge + // Middle tabs: gap on both sides + final leftInset = isFirstTab ? 0.0 : hInset; + final rightInset = isLastTab ? 0.0 : hInset; + final highlightLeft = getLeftPosition(selectedIndex) + leftInset; + final highlightWidth = getTabWidth(selectedIndex) - leftInset - rightInset; + + return Container( + width: estimatedTotalWidth, + height: _filterRowHeight, + decoration: BoxDecoration( + color: colorScheme.surfaceVariant.withOpacity(0.6), + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + // Animated sliding highlight + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: highlightLeft, + width: highlightWidth, + top: 0, + bottom: 0, + child: Container( + decoration: BoxDecoration( + color: _getMediaTypeColor(colorScheme, _selectedMediaType), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + // Labels row + Row( + children: categories.asMap().entries.map((entry) { + final index = entry.key; + final label = entry.value; + final isSelected = selectedIndex == index; + + return Expanded( + flex: flexValues[index], + child: GestureDetector( + onTap: () => _animateToCategory(index), + behavior: HitTestBehavior.opaque, + child: Container( + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant.withOpacity(0.8), + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + List _getCategoryLabels(S l10n) { + switch (_selectedMediaType) { + case LibraryMediaType.music: + return [ + l10n.artists, + l10n.albums, + if (_showFavoritesOnly) l10n.tracks, + l10n.playlists, + ]; + case LibraryMediaType.books: + return [ + l10n.authors, + l10n.books, + l10n.series, + ]; + case LibraryMediaType.podcasts: + return [l10n.shows]; + case LibraryMediaType.radio: + return [l10n.stations]; + } + } + + // ============ PAGE VIEWS ============ + /// PERF: Build only the requested tab (for PageView.builder) + Widget _buildTabAtIndex(BuildContext context, S l10n, int index) { + switch (_selectedMediaType) { + case LibraryMediaType.music: + if (_showFavoritesOnly) { + // Artists, Albums, Tracks, Playlists + switch (index) { + case 0: return _buildArtistsTab(context, l10n); + case 1: return _buildAlbumsTab(context, l10n); + case 2: return _buildTracksTab(context, l10n); + case 3: return _buildPlaylistsTab(context, l10n); + default: return const SizedBox(); + } + } else { + // Artists, Albums, Playlists (no Tracks) + switch (index) { + case 0: return _buildArtistsTab(context, l10n); + case 1: return _buildAlbumsTab(context, l10n); + case 2: return _buildPlaylistsTab(context, l10n); + default: return const SizedBox(); + } + } + case LibraryMediaType.books: + switch (index) { + case 0: return _buildBooksAuthorsTab(context, l10n); + case 1: return _buildAllBooksTab(context, l10n); + case 2: return _buildSeriesTab(context, l10n); + default: return const SizedBox(); + } + case LibraryMediaType.podcasts: + return _buildPodcastsTab(context, l10n); + case LibraryMediaType.radio: + return _buildRadioStationsTab(context, l10n); + } + } + + // ============ BOOKS TABS ============ + Widget _buildBooksAuthorsTab(BuildContext context, S l10n) { + final colorScheme = Theme.of(context).colorScheme; + + if (_isLoadingAudiobooks) { + return Center(child: CircularProgressIndicator(color: colorScheme.primary)); } - // Sort authors alphabetically - final sortedAuthors = authorMap.keys.toList()..sort(); + // Filter by favorites if enabled + final audiobooks = _showFavoritesOnly + ? _audiobooks.where((a) => a.favorite == true).toList() + : _audiobooks; + + if (audiobooks.isEmpty) { + if (_showFavoritesOnly) { + return EmptyState.custom( + context: context, + icon: Icons.favorite_border, + title: l10n.noFavoriteAudiobooks, + subtitle: l10n.tapHeartAudiobook, + ); + } + return EmptyState.custom( + context: context, + icon: MdiIcons.bookOutline, + title: l10n.noAudiobooks, + subtitle: l10n.addAudiobooksHint, + onRefresh: () => _loadAudiobooks(), + ); + } + // PERF: Use pre-sorted and pre-grouped lists (computed once on load) // Match music artists tab layout - no header, direct list/grid return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null), - child: _authorsViewMode == 'list' - ? ListView.builder( - key: const PageStorageKey('books_authors_list'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: sortedAuthors.length, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer), - itemBuilder: (context, index) { - return _buildAuthorListTile(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n); - }, - ) - : GridView.builder( - key: const PageStorageKey('books_authors_grid'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _authorsViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _authorsViewMode == 'grid3' ? 0.75 : 0.80, // Match music artists - crossAxisSpacing: 16, - mainAxisSpacing: 16, + child: LetterScrollbar( + controller: _authorsScrollController, + items: _sortedAuthorNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _authorsViewMode == 'list' + ? ListView.builder( + controller: _authorsScrollController, + key: const PageStorageKey('books_authors_list'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: _sortedAuthorNames.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), + itemBuilder: (context, index) { + final authorName = _sortedAuthorNames[index]; + return _buildAuthorListTile(authorName, _groupedAudiobooksByAuthor[authorName]!, l10n); + }, + ) + : GridView.builder( + controller: _authorsScrollController, + key: const PageStorageKey('books_authors_grid'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _authorsViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _authorsViewMode == 'grid3' ? 0.75 : 0.80, // Match music artists + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _sortedAuthorNames.length, + itemBuilder: (context, index) { + final authorName = _sortedAuthorNames[index]; + return _buildAuthorCard(authorName, _groupedAudiobooksByAuthor[authorName]!, l10n); + }, ), - itemCount: sortedAuthors.length, - itemBuilder: (context, index) { - return _buildAuthorCard(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n); - }, - ), + ), ); } @@ -1005,6 +1655,8 @@ class _NewLibraryScreenState extends State fit: BoxFit.cover, width: 48, height: 48, + memCacheWidth: 128, + memCacheHeight: 128, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (_, __) => Text( @@ -1090,6 +1742,8 @@ class _NewLibraryScreenState extends State fit: BoxFit.cover, width: size, height: size, + memCacheWidth: 256, + memCacheHeight: 256, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (_, __) => Center( @@ -1180,6 +1834,8 @@ class _NewLibraryScreenState extends State ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + memCacheWidth: 256, + memCacheHeight: 256, placeholder: (_, __) => const SizedBox(), errorWidget: (_, __, ___) => Icon( MdiIcons.bookOutline, @@ -1275,52 +1931,48 @@ class _NewLibraryScreenState extends State ); } - // Sort audiobooks - if (_audiobooksSortOrder == 'year') { - audiobooks.sort((a, b) { - if (a.year == null && b.year == null) return a.name.compareTo(b.name); - if (a.year == null) return 1; - if (b.year == null) return -1; - return a.year!.compareTo(b.year!); - }); - } else { - audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - } - + // PERF: Use pre-sorted list (sorted once on load or when sort order changes) // Match music albums tab layout - no header, direct list/grid return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null), - child: _audiobooksViewMode == 'list' - ? ListView.builder( - key: PageStorageKey('all_books_list_${_showFavoritesOnly ? 'fav' : 'all'}'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: audiobooks.length, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer), - itemBuilder: (context, index) { - return _buildAudiobookListTile(context, audiobooks[index], maProvider); - }, - ) - : GridView.builder( - key: PageStorageKey('all_books_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_audiobooksViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _audiobooksViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _audiobooksViewMode == 'grid3' ? 0.70 : 0.75, // Match music albums - crossAxisSpacing: 16, - mainAxisSpacing: 16, + child: LetterScrollbar( + controller: _audiobooksScrollController, + items: _audiobookNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _audiobooksViewMode == 'list' + ? ListView.builder( + controller: _audiobooksScrollController, + key: PageStorageKey('all_books_list_${_showFavoritesOnly ? 'fav' : 'all'}'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: _sortedAudiobooks.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), + itemBuilder: (context, index) { + return _buildAudiobookListTile(context, _sortedAudiobooks[index], maProvider); + }, + ) + : GridView.builder( + controller: _audiobooksScrollController, + key: PageStorageKey('all_books_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_audiobooksViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _audiobooksViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _audiobooksViewMode == 'grid3' ? 0.70 : 0.75, // Match music albums + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _sortedAudiobooks.length, + itemBuilder: (context, index) { + return _buildAudiobookCard(context, _sortedAudiobooks[index], maProvider); + }, ), - itemCount: audiobooks.length, - itemBuilder: (context, index) { - return _buildAudiobookCard(context, audiobooks[index], maProvider); - }, - ), + ), ); } @@ -1352,6 +2004,8 @@ class _NewLibraryScreenState extends State ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + memCacheWidth: 512, + memCacheHeight: 512, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (_, __) => const SizedBox(), @@ -1501,36 +2155,44 @@ class _NewLibraryScreenState extends State ); } + // PERF: Use pre-sorted list (sorted once on load) // Series view - supports grid2, grid3, and list modes return RefreshIndicator( onRefresh: _loadSeries, - child: _seriesViewMode == 'list' - ? ListView.builder( - key: const PageStorageKey('series_list'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: _series.length, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer), - itemBuilder: (context, index) { - return _buildSeriesListTile(context, _series[index], maProvider, l10n); - }, - ) - : GridView.builder( - key: PageStorageKey('series_grid_$_seriesViewMode'), - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _seriesViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _seriesViewMode == 'grid3' ? 0.70 : 0.75, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + child: LetterScrollbar( + controller: _seriesScrollController, + items: _seriesNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _seriesViewMode == 'list' + ? ListView.builder( + controller: _seriesScrollController, + key: const PageStorageKey('series_list'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: _sortedSeries.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), + itemBuilder: (context, index) { + return _buildSeriesListTile(context, _sortedSeries[index], maProvider, l10n); + }, + ) + : GridView.builder( + controller: _seriesScrollController, + key: PageStorageKey('series_grid_$_seriesViewMode'), + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _seriesViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _seriesViewMode == 'grid3' ? 0.70 : 0.75, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _sortedSeries.length, + itemBuilder: (context, index) { + final series = _sortedSeries[index]; + return _buildSeriesCard(context, series, maProvider, l10n, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3); + }, ), - itemCount: _series.length, - itemBuilder: (context, index) { - final series = _series[index]; - return _buildSeriesCard(context, series, maProvider, l10n, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3); - }, - ), + ), ); } @@ -1563,6 +2225,8 @@ class _NewLibraryScreenState extends State ? CachedNetworkImage( imageUrl: firstCover, fit: BoxFit.cover, + memCacheWidth: 256, + memCacheHeight: 256, placeholder: (_, __) => Icon( Icons.collections_bookmark_rounded, color: colorScheme.onSurfaceVariant, @@ -1603,8 +2267,8 @@ class _NewLibraryScreenState extends State _logger.log('📚 Tapped series: ${series.name}, path: ${series.id}'); Navigator.push( context, - MaterialPageRoute( - builder: (context) => AudiobookSeriesScreen( + FadeSlidePageRoute( + child: AudiobookSeriesScreen( series: series, heroTag: heroTag, initialCovers: covers, @@ -1635,8 +2299,8 @@ class _NewLibraryScreenState extends State _logger.log('📚 Tapped series: ${series.name}, path: ${series.id}'); Navigator.push( context, - MaterialPageRoute( - builder: (context) => AudiobookSeriesScreen( + FadeSlidePageRoute( + child: AudiobookSeriesScreen( series: series, heroTag: heroTag, initialCovers: cachedCovers, @@ -1650,13 +2314,16 @@ class _NewLibraryScreenState extends State // Square cover grid with Hero animation Hero( tag: heroTag, - child: AspectRatio( - aspectRatio: 1.0, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: colorScheme.surfaceVariant, - child: _buildSeriesCoverGrid(series, colorScheme, maProvider, maxGridSize: maxCoverGridSize), + // RepaintBoundary caches the rendered grid for smooth animation + child: RepaintBoundary( + child: AspectRatio( + aspectRatio: 1.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), // Match detail screen + child: Container( + color: colorScheme.surfaceVariant, + child: _buildSeriesCoverGrid(series, colorScheme, maProvider, maxGridSize: maxCoverGridSize), + ), ), ), ), @@ -1848,33 +2515,194 @@ class _NewLibraryScreenState extends State ); } - // ============ PODCASTS TAB (Placeholder) ============ - Widget _buildPodcastsComingSoonTab(BuildContext context, S l10n) { + // ============ PODCASTS TAB ============ + Widget _buildPodcastsTab(BuildContext context, S l10n) { final colorScheme = Theme.of(context).colorScheme; - return Center( + final textTheme = Theme.of(context).textTheme; + final maProvider = context.watch(); + final podcasts = maProvider.podcasts; + final isLoading = maProvider.isLoadingPodcasts; + + if (isLoading) { + return Center(child: CircularProgressIndicator(color: colorScheme.primary)); + } + + if (podcasts.isEmpty) { + return EmptyState.custom( + context: context, + icon: MdiIcons.podcast, + title: l10n.noPodcasts, + subtitle: l10n.addPodcastsHint, + onRefresh: () => maProvider.loadPodcasts(), + ); + } + + // Pre-cache podcast images for smooth hero animations + _precachePodcastImages(podcasts, maProvider); + + // PERF: Request larger images from API but decode at appropriate size for memory + // Use consistent 256 for all views to improve hero animation smoothness (matches detail screen) + const cacheSize = 256; + + // Generate podcast names for letter scrollbar + final podcastNames = podcasts.map((p) => p.name).toList(); + + return RefreshIndicator( + color: colorScheme.primary, + backgroundColor: colorScheme.background, + onRefresh: () => maProvider.loadPodcasts(), + child: LetterScrollbar( + controller: _podcastsScrollController, + items: podcastNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _podcastsViewMode == 'list' + ? ListView.builder( + controller: _podcastsScrollController, + key: const PageStorageKey('podcasts_list'), + cacheExtent: 1000, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), + itemCount: podcasts.length, + itemBuilder: (context, index) { + final podcast = podcasts[index]; + // iTunes URL from persisted cache (loaded on app start for instant high-res) + final imageUrl = maProvider.getPodcastImageUrl(podcast); + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Hero( + tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + '_library', + // Match detail screen: ClipRRect(16) → Container → CachedNetworkImage + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + // FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Icon( + MdiIcons.podcast, + color: colorScheme.onSurfaceVariant, + ), + ) + : Icon( + MdiIcons.podcast, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + title: Text( + podcast.name, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: podcast.metadata?['author'] != null + ? Text( + podcast.metadata!['author'] as String, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + onTap: () => _openPodcastDetails(podcast, maProvider, imageUrl), + ); + }, + ) + : GridView.builder( + controller: _podcastsScrollController, + key: PageStorageKey('podcasts_grid_$_podcastsViewMode'), + cacheExtent: 1000, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _podcastsViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _podcastsViewMode == 'grid3' ? 0.75 : 0.80, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: podcasts.length, + itemBuilder: (context, index) { + final podcast = podcasts[index]; + return _buildPodcastCard(podcast, maProvider, cacheSize); + }, + ), + ), + ); + } + + Widget _buildPodcastCard(MediaItem podcast, MusicAssistantProvider maProvider, int cacheSize) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // iTunes URL from persisted cache (loaded on app start for instant high-res) + final imageUrl = maProvider.getPodcastImageUrl(podcast); + + return GestureDetector( + onTap: () => _openPodcastDetails(podcast, maProvider, imageUrl), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Icons.podcasts_rounded, - size: 64, - color: colorScheme.primary.withOpacity(0.3), - ), - const SizedBox(height: 16), - Text( - l10n.podcasts, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, + AspectRatio( + aspectRatio: 1.0, + child: Hero( + tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + '_library', + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + // Match detail screen structure exactly + child: Container( + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + // FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Center( + child: Icon( + MdiIcons.podcast, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Center( + child: Icon( + MdiIcons.podcast, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), ), ), const SizedBox(height: 8), Text( - l10n.podcastSupportComingSoon, - style: TextStyle( - color: colorScheme.onSurface.withOpacity(0.6), - fontSize: 14, + podcast.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + height: 1.15, ), ), ], @@ -1882,80 +2710,321 @@ class _NewLibraryScreenState extends State ); } - // ============ ARTISTS TAB ============ + void _openPodcastDetails(MediaItem podcast, MusicAssistantProvider maProvider, String? imageUrl) { + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PodcastDetailScreen( + podcast: podcast, + heroTagSuffix: 'library', + initialImageUrl: imageUrl, + ), + ), + ); + } + + /// Pre-cache podcast images so hero animations are smooth on first tap + void _precachePodcastImages(List podcasts, MusicAssistantProvider maProvider) { + if (!mounted || _hasPrecachedPodcasts) return; + _hasPrecachedPodcasts = true; + + // Only precache first ~10 visible items to avoid excessive network/memory use + final podcastsToCache = podcasts.take(10); + + for (final podcast in podcastsToCache) { + // iTunes URL from persisted cache + final imageUrl = maProvider.getPodcastImageUrl(podcast); + if (imageUrl != null) { + // Use CachedNetworkImageProvider to warm the cache + precacheImage( + CachedNetworkImageProvider(imageUrl), + context, + ).catchError((_) { + // Silently ignore precache errors + return false; + }); + } + } + } + + // ============ RADIO TAB ============ + Widget _buildRadioStationsTab(BuildContext context, S l10n) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final maProvider = context.watch(); + final radioStations = maProvider.radioStations; + final isLoading = maProvider.isLoadingRadio; + + if (isLoading) { + return Center(child: CircularProgressIndicator(color: colorScheme.primary)); + } + + if (radioStations.isEmpty) { + return EmptyState.custom( + context: context, + icon: MdiIcons.radio, + title: l10n.noRadioStations, + subtitle: l10n.addRadioStationsHint, + onRefresh: () => maProvider.loadRadioStations(), + ); + } + + // PERF: Use appropriate cache size based on view mode + final cacheSize = _radioViewMode == 'grid3' ? 200 : 256; + + // Generate radio station names for letter scrollbar + final radioNames = radioStations.map((s) => s.name).toList(); + + return RefreshIndicator( + color: colorScheme.primary, + backgroundColor: colorScheme.background, + onRefresh: () => maProvider.loadRadioStations(), + child: LetterScrollbar( + controller: _radioScrollController, + items: radioNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _radioViewMode == 'list' + ? ListView.builder( + controller: _radioScrollController, + key: const PageStorageKey('radio_stations_list'), + cacheExtent: 1000, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), + itemCount: radioStations.length, + itemBuilder: (context, index) { + final station = radioStations[index]; + final imageUrl = maProvider.getImageUrl(station, size: cacheSize); + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceVariant, + child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant), + ), + errorWidget: (context, url, error) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceVariant, + child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceVariant, + child: Icon(MdiIcons.radio, color: colorScheme.onSurfaceVariant), + ), + ), + title: Text( + station.name, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: station.metadata?['description'] != null + ? Text( + station.metadata!['description'] as String, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + onTap: () => _playRadioStation(maProvider, station), + ); + }, + ) + : GridView.builder( + controller: _radioScrollController, + key: PageStorageKey('radio_stations_grid_$_radioViewMode'), + cacheExtent: 1000, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _radioViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _radioViewMode == 'grid3' ? 0.75 : 0.80, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: radioStations.length, + itemBuilder: (context, index) { + final station = radioStations[index]; + return _buildRadioCard(station, maProvider, cacheSize); + }, + ), + ), + ); + } + + Widget _buildRadioCard(MediaItem station, MusicAssistantProvider maProvider, int cacheSize) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final imageUrl = maProvider.getImageUrl(station, size: cacheSize); + + return GestureDetector( + onTap: () => _playRadioStation(maProvider, station), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AspectRatio( + aspectRatio: 1.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + 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.radio, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Center( + child: Icon( + MdiIcons.radio, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + station.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + height: 1.15, + ), + ), + ], + ), + ); + } + + void _playRadioStation(MusicAssistantProvider maProvider, MediaItem station) { + final selectedPlayer = maProvider.selectedPlayer; + if (selectedPlayer != null) { + maProvider.api?.playRadioStation(selectedPlayer.playerId, station); + } + } + + // ============ ARTISTS TAB ============ Widget _buildArtistsTab(BuildContext context, S l10n) { // Use Selector for targeted rebuilds - only rebuild when artists or loading state changes + // Artist filtering (albumArtistsOnly) is now done at API level in library_provider return Selector, bool)>( selector: (_, provider) => (provider.artists, provider.isLoading), builder: (context, data, _) { - final (allArtists, isLoading) = data; - final colorScheme = Theme.of(context).colorScheme; - - if (isLoading) { - return Center(child: CircularProgressIndicator(color: colorScheme.primary)); - } - - // Filter by favorites if enabled - final artists = _showFavoritesOnly - ? allArtists.where((a) => a.favorite == true).toList() - : allArtists; - - if (artists.isEmpty) { - if (_showFavoritesOnly) { - return EmptyState.custom( - context: context, - icon: Icons.favorite_border, - title: l10n.noFavoriteArtists, - subtitle: l10n.tapHeartArtist, + final (allArtists, isLoading) = data; + final colorScheme = Theme.of(context).colorScheme; + + if (isLoading) { + return Center(child: CircularProgressIndicator(color: colorScheme.primary)); + } + + // Filter by favorites if enabled (artist filter is done at API level) + final artists = _showFavoritesOnly + ? allArtists.where((a) => a.favorite == true).toList() + : allArtists; + + if (artists.isEmpty) { + if (_showFavoritesOnly) { + return EmptyState.custom( + context: context, + icon: Icons.favorite_border, + title: l10n.noFavoriteArtists, + subtitle: l10n.tapHeartArtist, + ); + } + return EmptyState.artists( + context: context, + onRefresh: () => context.read().loadLibrary(), + ); + } + + // Sort artists alphabetically for letter scrollbar + final sortedArtists = List.from(artists) + ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); + final artistNames = sortedArtists.map((a) => a.name ?? '').toList(); + + return RefreshIndicator( + color: colorScheme.primary, + backgroundColor: colorScheme.background, + onRefresh: () async => context.read().loadLibrary(), + child: LetterScrollbar( + controller: _artistsScrollController, + items: artistNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _artistsViewMode == 'list' + ? ListView.builder( + controller: _artistsScrollController, + key: PageStorageKey('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: sortedArtists.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly), + itemBuilder: (context, index) { + final artist = sortedArtists[index]; + return _buildArtistTile( + context, + artist, + key: ValueKey(artist.uri ?? artist.itemId), + ); + }, + ) + : GridView.builder( + controller: _artistsScrollController, + key: PageStorageKey('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: sortedArtists.length, + itemBuilder: (context, index) { + final artist = sortedArtists[index]; + return _buildArtistGridCard(context, artist); + }, + ), + ), ); - } - return EmptyState.artists( - context: context, - onRefresh: () => context.read().loadLibrary(), - ); - } - - return RefreshIndicator( - color: colorScheme.primary, - backgroundColor: colorScheme.surface, - onRefresh: () async => context.read().loadLibrary(), - child: _artistsViewMode == 'list' - ? ListView.builder( - key: PageStorageKey('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: artists.length, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), - itemBuilder: (context, index) { - final artist = artists[index]; - return _buildArtistTile( - context, - artist, - key: ValueKey(artist.uri ?? artist.itemId), - ); - }, - ) - : GridView.builder( - key: PageStorageKey('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: artists.length, - itemBuilder: (context, index) { - final artist = artists[index]; - return _buildArtistGridCard(context, artist); - }, - ), - ); - }, + }, ); } @@ -2041,11 +3110,14 @@ class _NewLibraryScreenState extends State final size = constraints.maxWidth < constraints.maxHeight ? constraints.maxWidth : constraints.maxHeight; + // PERF: Use appropriate cache size based on grid columns + final cacheSize = _artistsViewMode == 'grid3' ? 200 : 300; return Center( child: ArtistAvatar( artist: artist, radius: size / 2, - imageSize: 256, + imageSize: cacheSize, + heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + '_library_grid', ), ); }, @@ -2100,45 +3172,60 @@ class _NewLibraryScreenState extends State ); } + // Sort albums alphabetically for letter scrollbar + final sortedAlbums = List.from(albums) + ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); + final albumNames = sortedAlbums.map((a) => a.name ?? '').toList(); + return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () async => context.read().loadLibrary(), - child: _albumsViewMode == 'list' - ? ListView.builder( - key: PageStorageKey('library_albums_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return _buildAlbumListTile(context, album); - }, - ) - : GridView.builder( - key: PageStorageKey('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _albumsViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _albumsViewMode == 'grid3' ? 0.70 : 0.75, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + child: LetterScrollbar( + controller: _albumsScrollController, + items: albumNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _albumsViewMode == 'list' + ? ListView.builder( + controller: _albumsScrollController, + key: PageStorageKey('library_albums_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly), + itemCount: sortedAlbums.length, + itemBuilder: (context, index) { + final album = sortedAlbums[index]; + return _buildAlbumListTile(context, album); + }, + ) + : GridView.builder( + controller: _albumsScrollController, + key: PageStorageKey('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _albumsViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _albumsViewMode == 'grid3' ? 0.70 : 0.75, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: sortedAlbums.length, + itemBuilder: (context, index) { + final album = sortedAlbums[index]; + // PERF: Use appropriate cache size based on grid columns + final cacheSize = _albumsViewMode == 'grid3' ? 200 : 300; + return AlbumCard( + key: ValueKey(album.uri ?? album.itemId), + album: album, + heroTagSuffix: 'library_grid', + imageCacheSize: cacheSize, + ); + }, ), - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return AlbumCard( - key: ValueKey(album.uri ?? album.itemId), - album: album, - heroTagSuffix: 'library_grid', - ); - }, - ), + ), ); }, ); @@ -2162,6 +3249,8 @@ class _NewLibraryScreenState extends State ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + memCacheWidth: 256, + memCacheHeight: 256, placeholder: (_, __) => const SizedBox(), errorWidget: (_, __, ___) => Icon( Icons.album_rounded, @@ -2226,41 +3315,49 @@ class _NewLibraryScreenState extends State return EmptyState.playlists(context: context, onRefresh: () => _loadPlaylists()); } + // PERF: Use pre-sorted lists (sorted once on load) return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null), - child: _playlistsViewMode == 'list' - ? ListView.builder( - key: PageStorageKey('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: _playlists.length, - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return _buildPlaylistTile(context, playlist, l10n); - }, - ) - : GridView.builder( - key: PageStorageKey('library_playlists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _playlistsViewMode == 'grid3' ? 3 : 2, - childAspectRatio: _playlistsViewMode == 'grid3' ? 0.75 : 0.80, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + child: LetterScrollbar( + controller: _playlistsScrollController, + items: _playlistNames, + onDragStateChanged: _onLetterScrollbarDragChanged, + child: _playlistsViewMode == 'list' + ? ListView.builder( + controller: _playlistsScrollController, + key: PageStorageKey('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: _sortedPlaylists.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly), + itemBuilder: (context, index) { + final playlist = _sortedPlaylists[index]; + return _buildPlaylistTile(context, playlist, l10n); + }, + ) + : GridView.builder( + controller: _playlistsScrollController, + key: PageStorageKey('library_playlists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _playlistsViewMode == 'grid3' ? 3 : 2, + childAspectRatio: _playlistsViewMode == 'grid3' ? 0.75 : 0.80, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _sortedPlaylists.length, + itemBuilder: (context, index) { + final playlist = _sortedPlaylists[index]; + return _buildPlaylistGridCard(context, playlist, l10n); + }, ), - itemCount: _playlists.length, - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return _buildPlaylistGridCard(context, playlist, l10n); - }, - ), + ), ); } @@ -2270,51 +3367,76 @@ class _NewLibraryScreenState extends State final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; + // Unique suffix for list view context + const heroSuffix = '_library_list'; + return RepaintBoundary( child: ListTile( key: ValueKey(playlist.itemId), - leading: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(8), - image: imageUrl != null - ? DecorationImage(image: CachedNetworkImageProvider(imageUrl), fit: BoxFit.cover) - : null, + leading: Hero( + tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + 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, + ), + ), + ), + subtitle: Text( + playlist.trackCount != null + ? '${playlist.trackCount} ${l10n.tracks}' + : playlist.owner ?? l10n.playlist, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - playlist.trackCount != null - ? '${playlist.trackCount} ${l10n.tracks}' - : playlist.owner ?? l10n.playlist, - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), trailing: playlist.favorite == true ? const Icon(Icons.favorite, color: Colors.red, 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: 'library_list', + initialImageUrl: imageUrl, ), ), ); @@ -2329,73 +3451,92 @@ class _NewLibraryScreenState extends State final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PlaylistDetailsScreen( - playlist: playlist, - provider: playlist.provider, - itemId: playlist.itemId, + // Unique suffix for grid view context + const heroSuffix = '_library_grid'; + + return RepaintBoundary( + child: GestureDetector( + onTap: () { + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PlaylistDetailsScreen( + playlist: playlist, + heroTagSuffix: 'library_grid', + initialImageUrl: imageUrl, + ), ), - ), - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( - color: colorScheme.surfaceVariant, - child: imageUrl != null - ? CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - placeholder: (_, __) => const SizedBox(), - errorWidget: (_, __, ___) => Center( - child: Icon( - Icons.playlist_play_rounded, - size: 48, - color: colorScheme.onSurfaceVariant, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Hero( + tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Center( + child: Icon( + Icons.playlist_play_rounded, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Center( + child: Icon( + Icons.playlist_play_rounded, + size: 48, + color: colorScheme.onSurfaceVariant, + ), ), - ), - ) - : Center( - child: Icon( - Icons.playlist_play_rounded, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - ), + ), + ), ), ), - ), - const SizedBox(height: 8), - Text( - playlist.name, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, + const SizedBox(height: 8), + Hero( + tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix, + child: Material( + color: Colors.transparent, + child: Text( + playlist.name, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - playlist.trackCount != null - ? '${playlist.trackCount} ${l10n.tracks}' - : playlist.owner ?? l10n.playlist, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withOpacity(0.7), + Text( + playlist.trackCount != null + ? '${playlist.trackCount} ${l10n.tracks}' + : playlist.owner ?? l10n.playlist, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), ); } @@ -2417,27 +3558,20 @@ class _NewLibraryScreenState extends State ); } - // Sort tracks by artist name, then track name - final sortedTracks = List.from(_favoriteTracks) - ..sort((a, b) { - final artistCompare = a.artistsString.compareTo(b.artistsString); - if (artistCompare != 0) return artistCompare; - return a.name.compareTo(b.name); - }); - + // PERF: Use pre-sorted list (sorted once on load) return RefreshIndicator( color: colorScheme.primary, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.background, onRefresh: _loadFavoriteTracks, child: ListView.builder( key: const PageStorageKey('library_tracks_list'), - cacheExtent: 500, + cacheExtent: 1000, addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles - padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), - itemCount: sortedTracks.length, + padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly), + itemCount: _sortedFavoriteTracks.length, itemBuilder: (context, index) { - final track = sortedTracks[index]; + final track = _sortedFavoriteTracks[index]; return _buildTrackTile(context, track); }, ), @@ -2519,3 +3653,21 @@ class _NewLibraryScreenState extends State ); } } + +/// Custom PageScrollPhysics with faster spring for quicker settling +/// This allows vertical scrolling to work sooner after a horizontal swipe +class _FastPageScrollPhysics extends PageScrollPhysics { + const _FastPageScrollPhysics({super.parent}); + + @override + _FastPageScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _FastPageScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => const SpringDescription( + mass: 50, // Lower mass = faster movement + stiffness: 500, // Higher stiffness = snappier + damping: 1.0, // Critical damping for no overshoot + ); +} diff --git a/lib/screens/playlist_details_screen.dart b/lib/screens/playlist_details_screen.dart index 390928de..1927fbc9 100644 --- a/lib/screens/playlist_details_screen.dart +++ b/lib/screens/playlist_details_screen.dart @@ -1,42 +1,96 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../l10n/app_localizations.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 '../theme/palette_helper.dart'; +import '../theme/theme_provider.dart'; import '../services/debug_logger.dart'; import '../services/recently_played_service.dart'; - -final _playlistLogger = DebugLogger(); +import '../widgets/global_player_overlay.dart'; +import '../widgets/player_picker_sheet.dart'; +import '../l10n/app_localizations.dart'; class PlaylistDetailsScreen extends StatefulWidget { final Playlist playlist; - final String provider; - final String itemId; + final String? heroTagSuffix; + /// Initial image URL from the source (e.g., PlaylistCard) for seamless hero animation + final String? initialImageUrl; + + // Legacy constructor parameters for backward compatibility + final String? provider; + final String? itemId; const PlaylistDetailsScreen({ super.key, required this.playlist, - required this.provider, - required this.itemId, + this.heroTagSuffix, + this.initialImageUrl, + this.provider, + this.itemId, }); @override State createState() => _PlaylistDetailsScreenState(); } -class _PlaylistDetailsScreenState extends State { +class _PlaylistDetailsScreenState extends State with SingleTickerProviderStateMixin { + final _logger = DebugLogger(); List _tracks = []; bool _isLoading = true; + bool _isFavorite = false; + ColorScheme? _lightColorScheme; + ColorScheme? _darkColorScheme; + int? _expandedTrackIndex; + + String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; + + // Helper to get provider/itemId from widget or playlist + String get _provider => widget.provider ?? widget.playlist.provider; + String get _itemId => widget.itemId ?? widget.playlist.itemId; @override void initState() { super.initState(); + _isFavorite = widget.playlist.favorite ?? false; _loadTracks(); + + // Defer color extraction until after hero animation completes + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 350), () { + if (mounted) { + _extractColors(); + } + }); + }); + } + + Future _extractColors() async { + final maProvider = context.read(); + final imageUrl = maProvider.getImageUrl(widget.playlist, size: 512); + + if (imageUrl == null) return; + + try { + final colorSchemes = await PaletteHelper.extractColorSchemes( + CachedNetworkImageProvider(imageUrl), + ); + + if (colorSchemes != null && mounted) { + setState(() { + _lightColorScheme = colorSchemes.$1; + _darkColorScheme = colorSchemes.$2; + }); + } + } catch (e) { + _logger.log('Failed to extract colors for playlist: $e'); + } } Future _loadTracks() async { final maProvider = context.read(); - final cacheKey = '${widget.provider}_${widget.itemId}'; + final cacheKey = '${_provider}_$_itemId'; // 1. Show cached data immediately (if available) final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey); @@ -54,8 +108,8 @@ class _PlaylistDetailsScreenState extends State { // 2. Fetch fresh data in background (silent refresh) try { final freshTracks = await maProvider.getPlaylistTracksWithCache( - widget.provider, - widget.itemId, + _provider, + _itemId, forceRefresh: cachedTracks != null, ); @@ -73,6 +127,7 @@ class _PlaylistDetailsScreenState extends State { } } catch (e) { // Silent failure - keep showing cached data + _logger.log('Background refresh failed: $e'); } if (mounted && _isLoading) { @@ -80,42 +135,438 @@ class _PlaylistDetailsScreenState extends State { } } + Future _toggleFavorite() async { + final maProvider = context.read(); + + try { + final newState = !_isFavorite; + bool success; + + if (newState) { + // For adding: use the actual provider and itemId from provider_mappings + String actualProvider = widget.playlist.provider; + String actualItemId = widget.playlist.itemId; + + if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) { + final mapping = widget.playlist.providerMappings!.firstWhere( + (m) => m.available && m.providerInstance != 'library', + orElse: () => widget.playlist.providerMappings!.firstWhere( + (m) => m.available, + orElse: () => widget.playlist.providerMappings!.first, + ), + ); + actualProvider = mapping.providerDomain; + actualItemId = mapping.itemId; + } + + _logger.log('Adding playlist to favorites: provider=$actualProvider, itemId=$actualItemId'); + success = await maProvider.addToFavorites( + mediaType: 'playlist', + itemId: actualItemId, + provider: actualProvider, + ); + } else { + // For removing: need the library_item_id (numeric) + int? libraryItemId; + + if (widget.playlist.provider == 'library') { + libraryItemId = int.tryParse(widget.playlist.itemId); + } else if (widget.playlist.providerMappings != null) { + final libraryMapping = widget.playlist.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => widget.playlist.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId == null) { + _logger.log('Error: Could not determine library_item_id for removal'); + throw Exception('Could not determine library ID for this playlist'); + } + + success = await maProvider.removeFromFavorites( + mediaType: 'playlist', + libraryItemId: libraryItemId, + ); + } + + if (success) { + setState(() { + _isFavorite = newState; + }); + + maProvider.invalidateHomeCache(); + + if (mounted) { + final isOffline = !maProvider.isConnected; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isOffline + ? S.of(context)!.actionQueuedForSync + : (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites), + ), + duration: const Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + _logger.log('Error toggling favorite: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + Future _toggleTrackFavorite(int trackIndex) async { + if (trackIndex < 0 || trackIndex >= _tracks.length) return; + + final track = _tracks[trackIndex]; + final maProvider = context.read(); + final currentFavorite = track.favorite ?? false; + + try { + bool success; + + if (currentFavorite) { + int? libraryItemId; + if (track.provider == 'library') { + libraryItemId = int.tryParse(track.itemId); + } else if (track.providerMappings != null) { + final libraryMapping = track.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => track.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId != null) { + success = await maProvider.removeFromFavorites( + mediaType: 'track', + libraryItemId: libraryItemId, + ); + } else { + success = false; + } + } else { + String actualProvider = track.provider; + String actualItemId = track.itemId; + + if (track.providerMappings != null && track.providerMappings!.isNotEmpty) { + final mapping = track.providerMappings!.firstWhere( + (m) => m.available && m.providerInstance != 'library', + orElse: () => track.providerMappings!.firstWhere( + (m) => m.available, + orElse: () => track.providerMappings!.first, + ), + ); + actualProvider = mapping.providerDomain; + actualItemId = mapping.itemId; + } + + success = await maProvider.addToFavorites( + mediaType: 'track', + itemId: actualItemId, + provider: actualProvider, + ); + } + + if (success) { + setState(() { + _tracks[trackIndex] = Track( + itemId: track.itemId, + provider: track.provider, + name: track.name, + uri: track.uri, + favorite: !currentFavorite, + artists: track.artists, + album: track.album, + duration: track.duration, + providerMappings: track.providerMappings, + ); + }); + + if (mounted) { + final isOffline = !maProvider.isConnected; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isOffline + ? S.of(context)!.actionQueuedForSync + : (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites), + ), + duration: const Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + _logger.log('Error toggling track favorite: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + Future _playPlaylist() async { if (_tracks.isEmpty) return; final maProvider = context.read(); try { - // Use the selected player final player = maProvider.selectedPlayer; if (player == null) { _showError(S.of(context)!.noPlayerSelected); return; } - _playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist'); - - // Queue all tracks via Music Assistant + _logger.log('Queueing playlist on ${player.name}'); await maProvider.playTracks(player.playerId, _tracks, startIndex: 0); - _playlistLogger.info('Playlist queued successfully', context: 'Playlist'); + _logger.log('Playlist queued successfully'); - // Record to local recently played (per-profile) RecentlyPlayedService.instance.recordPlaylistPlayed(widget.playlist); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context)!.playing(widget.playlist.name)), + content: Text(S.of(context)!.playingPlaylist(widget.playlist.name)), duration: const Duration(seconds: 2), ), ); } } catch (e) { - _playlistLogger.error('Error playing playlist', context: 'Playlist', error: e); + _logger.log('Error playing playlist: $e'); _showError('Error playing playlist: $e'); } } + Future _playTrack(int index) async { + final maProvider = context.read(); + + try { + final player = maProvider.selectedPlayer; + if (player == null) { + _showError(S.of(context)!.noPlayerSelected); + return; + } + + _logger.log('Queueing tracks on ${player.name} starting at index $index'); + await maProvider.playTracks(player.playerId, _tracks, startIndex: index); + _logger.log('Tracks queued on ${player.name}'); + } catch (e) { + _logger.log('Error playing track: $e'); + _showError('Failed to play track: $e'); + } + } + + void _showPlayOnMenu(BuildContext context) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + maProvider.selectPlayer(player); + await maProvider.playTracks(player.playerId, _tracks); + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + void _addPlaylistToQueue() { + final maProvider = context.read(); + final players = maProvider.availablePlayers; + + GlobalPlayerOverlay.hidePlayer(); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Text( + S.of(context)!.addToQueueOn, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (players.isEmpty) + Padding( + padding: const EdgeInsets.all(32.0), + child: Text(S.of(context)!.noPlayersAvailable), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: players.length, + itemBuilder: (context, playerIndex) { + final player = players[playerIndex]; + return ListTile( + leading: Icon( + Icons.speaker, + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text(player.name), + onTap: () async { + Navigator.pop(context); + try { + _logger.log('Adding playlist to queue on ${player.name}'); + await maProvider.playTracks( + player.playerId, + _tracks, + startIndex: 0, + clearQueue: false, + ); + _logger.log('Playlist added to queue on ${player.name}'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.tracksAddedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + _logger.log('Error adding playlist to queue: $e'); + _showError('Failed to add playlist to queue: $e'); + } + }, + ); + }, + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], + ), + ), + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + void _addTrackToQueue(BuildContext context, int index) { + final maProvider = context.read(); + final players = maProvider.availablePlayers; + + GlobalPlayerOverlay.hidePlayer(); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Text( + S.of(context)!.addToQueueOn, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (players.isEmpty) + Padding( + padding: const EdgeInsets.all(32.0), + child: Text(S.of(context)!.noPlayersAvailable), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: players.length, + itemBuilder: (context, playerIndex) { + final player = players[playerIndex]; + return ListTile( + leading: Icon( + Icons.speaker, + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text(player.name), + onTap: () async { + Navigator.pop(context); + try { + await maProvider.playTracks( + player.playerId, + _tracks, + startIndex: index, + clearQueue: false, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.tracksAddedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + _logger.log('Error adding to queue: $e'); + _showError('Failed to add to queue: $e'); + } + }, + ); + }, + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], + ), + ), + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + void _showPlayRadioMenu(BuildContext context, int trackIndex) { + final maProvider = context.read(); + final track = _tracks[trackIndex]; + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + maProvider.selectPlayer(player); + await maProvider.playRadio(player.playerId, track); + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + void _showError(String message) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -127,253 +578,487 @@ class _PlaylistDetailsScreenState extends State { } } - @override - Widget build(BuildContext context) { - final maProvider = context.watch(); - final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 400); - - return Scaffold( - backgroundColor: const Color(0xFF1a1a1a), - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: const Color(0xFF1a1a1a), - flexibleSpace: FlexibleSpaceBar( - title: Text( - widget.playlist.name, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - background: Stack( - fit: StackFit.expand, - children: [ - if (imageUrl != null) - Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey[900], - child: const Icon( - Icons.playlist_play_rounded, - size: 100, - color: Colors.white30, - ), - ); - }, - ) - else - Container( - color: Colors.grey[900], - child: const Icon( - Icons.playlist_play_rounded, - size: 100, - color: Colors.white30, - ), - ), - // Gradient overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.7), - ], - ), + /// Show fullscreen playlist art overlay + void _showFullscreenArt(String? imageUrl) { + if (imageUrl == null) return; + + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.black87, + barrierDismissible: true, + pageBuilder: (context, animation, secondaryAnimation) { + return FadeTransition( + opacity: animation, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + backgroundColor: Colors.transparent, + body: Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.contain, + memCacheWidth: 1024, + memCacheHeight: 1024, ), ), - ], + ), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Playlist info - if (widget.playlist.owner != null) - Text( - 'By ${widget.playlist.owner}', - style: TextStyle( - color: Colors.grey[400], - fontSize: 14, - ), - ), - const SizedBox(height: 8), - Text( - '${_tracks.length} tracks', - style: TextStyle( - color: Colors.grey[500], - fontSize: 12, - ), + ); + }, + ), + ); + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + // Use select() to reduce rebuilds + final providerImageUrl = context.select( + (provider) => provider.getImageUrl(widget.playlist, size: 512), + ); + final imageUrl = providerImageUrl ?? widget.initialImageUrl; + final adaptiveTheme = context.select( + (provider) => provider.adaptiveTheme, + ); + final adaptiveLightScheme = context.select( + (provider) => provider.adaptiveLightScheme, + ); + final adaptiveDarkScheme = context.select( + (provider) => provider.adaptiveDarkScheme, + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + + ColorScheme? adaptiveScheme; + if (adaptiveTheme) { + adaptiveScheme = isDark + ? (_darkColorScheme ?? adaptiveDarkScheme) + : (_lightColorScheme ?? adaptiveLightScheme); + } + + final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + clearAdaptiveColorsOnBack(context); + } + }, + child: Scaffold( + backgroundColor: colorScheme.surface, + body: LayoutBuilder( + builder: (context, constraints) { + // Responsive cover size: 70% of screen width, clamped between 200-320 + final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); + final expandedHeight = coverSize + 70; + + return CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + backgroundColor: colorScheme.surface, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + clearAdaptiveColorsOnBack(context); + Navigator.pop(context); + }, + color: colorScheme.onSurface, ), - const SizedBox(height: 16), - // Play button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isLoading ? null : _playPlaylist, - icon: const Icon(Icons.play_arrow_rounded), - label: Text(S.of(context)!.play), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: const Color(0xFF1a1a1a), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + flexibleSpace: FlexibleSpaceBar( + background: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 60), + GestureDetector( + onTap: () => _showFullscreenArt(imageUrl), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Hero( + tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: coverSize, + height: coverSize, + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Icon( + Icons.playlist_play_rounded, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ) + : Icon( + Icons.playlist_play_rounded, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), ), - ), + ], ), ), - const SizedBox(height: 24), - // Tracks header - Text( - S.of(context)!.tracks, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Playlist title with Hero animation + Hero( + tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix, + child: Material( + color: Colors.transparent, + child: Text( + widget.playlist.name, + style: textTheme.headlineMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 8), + // Owner info + if (widget.playlist.owner != null) + Text( + S.of(context)!.byOwner(widget.playlist.owner!), + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 4), + // Track count + Text( + S.of(context)!.trackCount(_tracks.isNotEmpty ? _tracks.length : (widget.playlist.trackCount ?? 0)), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + const SizedBox(height: 16), + // Action buttons row + Row( + children: [ + // Main Play Button + Expanded( + flex: 2, + child: SizedBox( + height: 50, + child: ElevatedButton.icon( + onPressed: _isLoading || _tracks.isEmpty ? null : _playPlaylist, + icon: const Icon(Icons.play_arrow_rounded), + label: Text(S.of(context)!.play), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: colorScheme.primary.withOpacity(0.38), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + + // "Play on..." Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined), + ), + ), + + const SizedBox(width: 12), + + // "Add to Queue" Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _isLoading || _tracks.isEmpty ? null : _addPlaylistToQueue, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.playlist_add), + ), + ), + + const SizedBox(width: 12), + + // Favorite Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _toggleFavorite, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + child: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_border, + color: _isFavorite + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], ), ), - ], - ), - ), - ), - _isLoading - ? const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ) - : _tracks.isEmpty - ? SliverFillRemaining( - child: Center( - child: Text( - S.of(context)!.noTracksInPlaylist, - style: TextStyle( - color: Colors.grey[600], - fontSize: 16, - ), + ), + if (_isLoading) + SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(color: colorScheme.primary), + ), + ) + else if (_tracks.isEmpty) + SliverFillRemaining( + child: Center( + child: Text( + S.of(context)!.noTracksInPlaylist, + style: TextStyle( + color: colorScheme.onSurface.withOpacity(0.54), + fontSize: 16, ), ), - ) - : SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final track = _tracks[index]; - return _buildTrackTile(track, index, maProvider); - }, - childCount: _tracks.length, - ), ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 80), // Space for mini player - ), - ], - ), - ); - } - - Widget _buildTrackTile(Track track, int index, MusicAssistantProvider maProvider) { - final imageUrl = maProvider.api?.getImageUrl(track, size: 80); - - return ListTile( - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Track number - SizedBox( - width: 24, - child: Text( - '${index + 1}', - style: TextStyle( - color: Colors.grey[600], - fontSize: 14, - ), - textAlign: TextAlign.right, - ), - ), - const SizedBox(width: 12), - // Album art - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: imageUrl != null - ? Image.network( - imageUrl, - width: 48, - height: 48, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - width: 48, - height: 48, - color: Colors.grey[800], - child: const Icon(Icons.music_note, size: 24), - ); - }, ) - : Container( - width: 48, - height: 48, - color: Colors.grey[800], - child: const Icon(Icons.music_note, size: 24), + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final track = _tracks[index]; + final isExpanded = _expandedTrackIndex == index; + final trackImageUrl = context.read().getImageUrl(track, size: 80); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Track number + SizedBox( + width: 28, + child: Text( + '${index + 1}', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 12), + // Track artwork + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: trackImageUrl != null + ? CachedNetworkImage( + imageUrl: trackImageUrl, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Icon( + Icons.music_note, + size: 24, + color: colorScheme.onSurfaceVariant, + ), + ) + : Icon( + Icons.music_note, + size: 24, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + title: Text( + track.name, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + track.artistsString, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: track.duration != null + ? Text( + _formatDuration(track.duration!), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ) + : null, + onTap: () { + if (isExpanded) { + setState(() { + _expandedTrackIndex = null; + }); + } else { + _playTrack(index); + } + }, + onLongPress: () { + setState(() { + _expandedTrackIndex = isExpanded ? null : index; + }); + }, + ), + // Expandable action buttons + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Radio button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showPlayRadioMenu(context, index), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.radio, size: 20), + ), + ), + const SizedBox(width: 10), + // Add to queue button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _addTrackToQueue(context, index), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.playlist_add, size: 20), + ), + ), + const SizedBox(width: 10), + // Favorite button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _toggleTrackFavorite(index), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + ), + child: Icon( + track.favorite == true ? Icons.favorite : Icons.favorite_border, + size: 20, + color: track.favorite == true + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + }, + childCount: _tracks.length, + ), ), - ), - ], - ), - title: Text( - track.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, + const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player + ], + ); + }, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: track.artists != null && track.artists!.isNotEmpty - ? Text( - track.artists!.first.name, - style: TextStyle( - color: Colors.grey[400], - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: IconButton( - icon: const Icon(Icons.play_arrow, color: Colors.white70), - onPressed: () async { - final player = maProvider.selectedPlayer; - if (player == null) { - _showError(S.of(context)!.noPlayerSelected); - return; - } - - try { - // Play from this track - await maProvider.playTracks(player.playerId, _tracks, startIndex: index); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(S.of(context)!.playingTrack), - duration: const Duration(seconds: 1), - ), - ); - } - } catch (e) { - _showError('Error playing track: $e'); - } - }, ), ); } diff --git a/lib/screens/podcast_detail_screen.dart b/lib/screens/podcast_detail_screen.dart new file mode 100644 index 00000000..f7c79b86 --- /dev/null +++ b/lib/screens/podcast_detail_screen.dart @@ -0,0 +1,873 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import '../models/media_item.dart'; +import '../providers/music_assistant_provider.dart'; +import '../widgets/global_player_overlay.dart'; +import '../widgets/player_picker_sheet.dart'; +import '../theme/palette_helper.dart'; +import '../theme/theme_provider.dart'; +import '../services/debug_logger.dart'; +import '../constants/hero_tags.dart'; +import '../l10n/app_localizations.dart'; + +class PodcastDetailScreen extends StatefulWidget { + final MediaItem podcast; + final String? heroTagSuffix; + final String? initialImageUrl; + + const PodcastDetailScreen({ + super.key, + required this.podcast, + this.heroTagSuffix, + this.initialImageUrl, + }); + + @override + State createState() => _PodcastDetailScreenState(); +} + +class _PodcastDetailScreenState extends State { + final _logger = DebugLogger(); + ColorScheme? _lightColorScheme; + ColorScheme? _darkColorScheme; + List _episodes = []; + bool _isLoadingEpisodes = false; + bool _isDescriptionExpanded = false; + bool _isInLibrary = false; + String? _expandedEpisodeId; // Track which episode is expanded for actions + + String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; + + @override + void initState() { + super.initState(); + _isInLibrary = _checkIfInLibrary(widget.podcast); + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 350), () { + if (mounted) { + _extractColors(); + } + }); + _loadEpisodes(); + }); + } + + /// Check if podcast is in library + bool _checkIfInLibrary(MediaItem podcast) { + if (podcast.provider == 'library') return true; + return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false; + } + + /// Toggle library status + Future _toggleLibrary() async { + final maProvider = context.read(); + + try { + final newState = !_isInLibrary; + bool success; + + if (newState) { + // Add to library - MUST use non-library provider + String? actualProvider; + String? actualItemId; + + if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) { + // For adding to library, we MUST use a non-library provider + final nonLibraryMapping = widget.podcast.providerMappings!.where( + (m) => m.providerInstance != 'library' && m.providerDomain != 'library', + ).firstOrNull; + + if (nonLibraryMapping != null) { + actualProvider = nonLibraryMapping.providerDomain; + actualItemId = nonLibraryMapping.itemId; + } + } + + // Fallback to item's own provider if no non-library mapping found + if (actualProvider == null || actualItemId == null) { + if (widget.podcast.provider != 'library') { + actualProvider = widget.podcast.provider; + actualItemId = widget.podcast.itemId; + } else { + // Item is library-only, can't add + _logger.log('Cannot add to library: podcast is library-only'); + return; + } + } + + _logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId'); + success = await maProvider.addToLibrary( + mediaType: 'podcast', + provider: actualProvider, + itemId: actualItemId, + ); + } else { + // Remove from library + int? libraryItemId; + if (widget.podcast.provider == 'library') { + libraryItemId = int.tryParse(widget.podcast.itemId); + } else if (widget.podcast.providerMappings != null) { + final libraryMapping = widget.podcast.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => widget.podcast.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId == null) { + _logger.log('Cannot remove from library: no library ID found'); + return; + } + + success = await maProvider.removeFromLibrary( + mediaType: 'podcast', + libraryItemId: libraryItemId, + ); + } + + if (success) { + setState(() { + _isInLibrary = newState; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary, + ), + duration: const Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + _logger.log('Error toggling podcast library: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update library: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + Future _extractColors() async { + final maProvider = context.read(); + // Use initialImageUrl first for consistency with hero animation + final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast); + + if (imageUrl != null) { + try { + final colorSchemes = await PaletteHelper.extractColorSchemes( + CachedNetworkImageProvider(imageUrl), + ); + if (mounted && colorSchemes != null) { + setState(() { + _lightColorScheme = colorSchemes.$1; + _darkColorScheme = colorSchemes.$2; + }); + } + } catch (e) { + _logger.log('🎙️ Error extracting colors: $e'); + } + } + } + + Future _loadEpisodes() async { + if (_isLoadingEpisodes) return; + + setState(() { + _isLoadingEpisodes = true; + }); + + try { + final maProvider = context.read(); + if (maProvider.api != null) { + final episodes = await maProvider.api!.getPodcastEpisodes( + widget.podcast.itemId, + provider: widget.podcast.provider, + ); + + if (mounted) { + _logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}'); + // Debug: Log first episode metadata to see available fields + if (episodes.isNotEmpty) { + final first = episodes.first; + _logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}'); + _logger.log('🎙️ First episode metadata: ${first.metadata}'); + } + setState(() { + _episodes = episodes; + }); + } + } + } catch (e) { + _logger.log('🎙️ Error loading episodes: $e'); + } finally { + if (mounted) { + setState(() { + _isLoadingEpisodes = false; + }); + } + } + } + + void _playEpisode(MediaItem episode) async { + final maProvider = context.read(); + final selectedPlayer = maProvider.selectedPlayer; + + 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( + SnackBar( + content: Text('Playing: ${episode.name}'), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + _logger.log('🎙️ Error playing episode: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play episode: $e')), + ); + } + } + } else { + _showPlayOnMenu(context, episode); + } + } + + void _showPlayOnMenu(BuildContext context, MediaItem episode) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + 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(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + String _formatDuration(Duration? duration) { + if (duration == null || duration == Duration.zero) return ''; + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes} min'; + } + + /// Format release date from episode metadata + /// Tries various common field names for publication date + String? _formatReleaseDate(Map? metadata) { + if (metadata == null) return null; + + // Try various common field names for publication date + dynamic dateValue = metadata['published'] ?? + metadata['pub_date'] ?? + metadata['release_date'] ?? + metadata['aired'] ?? + metadata['timestamp']; + + if (dateValue == null) return null; + + try { + DateTime? date; + if (dateValue is String) { + date = DateTime.tryParse(dateValue); + } else if (dateValue is int) { + // Unix timestamp + date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000); + } + + if (date != null) { + // Format as "Jan 15, 2024" + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; + } + } catch (e) { + _logger.log('🎙️ Error parsing release date: $e'); + } + + return null; + } + + /// Add episode to queue on current player + Future _addToQueue(MediaItem episode) async { + final maProvider = context.read(); + final selectedPlayer = maProvider.selectedPlayer; + + if (selectedPlayer == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + // Convert MediaItem to Track for queue addition + final track = Track( + itemId: episode.itemId, + provider: episode.provider, + name: episode.name, + uri: episode.uri, + duration: episode.duration, + metadata: episode.metadata, + ); + await maProvider.addTrackToQueue(selectedPlayer.playerId, track); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.addedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + _logger.log('🎙️ Error adding episode to queue: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + // CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation + // watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank + // select() only rebuilds when the specific podcast image URL changes + final providerImageUrl = context.select( + (provider) => provider.getPodcastImageUrl(widget.podcast), + ); + // Use initialImageUrl for seamless hero animation (same URL as source) + final imageUrl = widget.initialImageUrl ?? providerImageUrl; + + final adaptiveTheme = context.select( + (provider) => provider.adaptiveTheme, + ); + final adaptiveLightScheme = context.select( + (provider) => provider.adaptiveLightScheme, + ); + final adaptiveDarkScheme = context.select( + (provider) => provider.adaptiveDarkScheme, + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + + ColorScheme? adaptiveScheme; + if (adaptiveTheme) { + adaptiveScheme = isDark + ? (_darkColorScheme ?? adaptiveDarkScheme) + : (_lightColorScheme ?? adaptiveLightScheme); + } + final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final description = widget.podcast.metadata?['description'] as String?; + final author = widget.podcast.metadata?['author'] as String?; + + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + clearAdaptiveColorsOnBack(context); + } + }, + child: Scaffold( + backgroundColor: colorScheme.surface, + body: LayoutBuilder( + builder: (context, constraints) { + final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); + final expandedHeight = coverSize + 70; + + return CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + backgroundColor: colorScheme.surface, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + clearAdaptiveColorsOnBack(context); + Navigator.pop(context); + }, + color: colorScheme.onSurface, + ), + flexibleSpace: FlexibleSpaceBar( + background: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 60), + Hero( + tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix, + // Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: coverSize, + height: coverSize, + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + // Match source memCacheWidth for smooth Hero animation + memCacheWidth: 256, + memCacheHeight: 256, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => Center( + child: Icon( + MdiIcons.podcast, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ), + errorWidget: (_, __, ___) => Center( + child: Icon( + MdiIcons.podcast, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Center( + child: Icon( + MdiIcons.podcast, + size: coverSize * 0.43, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Podcast title + Text( + widget.podcast.name, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + if (author != null) ...[ + const SizedBox(height: 4), + Text( + author, + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + + // Action buttons row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Main Play Latest Episode Button + Expanded( + flex: 2, + child: SizedBox( + height: 50, + child: ElevatedButton.icon( + onPressed: _isLoadingEpisodes || _episodes.isEmpty + ? null + : () => _playEpisode(_episodes.first), + icon: const Icon(Icons.play_arrow_rounded), + label: Text(S.of(context)!.play), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: colorScheme.primary.withOpacity(0.38), + disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + // "Play on..." Button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _isLoadingEpisodes || _episodes.isEmpty + ? null + : () => _showPlayOnMenu(context, _episodes.first), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined), + ), + ), + const SizedBox(width: 12), + // Library button + SizedBox( + height: 50, + width: 50, + child: FilledButton.tonal( + onPressed: _toggleLibrary, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + _isInLibrary ? Icons.library_add_check : Icons.library_add, + color: _isInLibrary + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Description + if (description != null && description.isNotEmpty) ...[ + GestureDetector( + onTap: () { + setState(() { + _isDescriptionExpanded = !_isDescriptionExpanded; + }); + }, + child: AnimatedCrossFade( + firstChild: Text( + description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + secondChild: Text( + description, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + crossFadeState: _isDescriptionExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ), + const SizedBox(height: 16), + ], + + // Episodes header + Row( + children: [ + Text( + S.of(context)!.episodes, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + if (_episodes.isNotEmpty) + Text( + '(${_episodes.length})', + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ], + ), + ), + ), + + // Episodes list + if (_isLoadingEpisodes) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: CircularProgressIndicator(color: colorScheme.primary), + ), + ), + ) + else if (_episodes.isEmpty) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + MdiIcons.microphoneOff, + size: 48, + color: colorScheme.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final episode = _episodes[index]; + final episodeDescription = episode.metadata?['description'] as String?; + final duration = episode.duration; + final episodeId = episode.uri ?? episode.itemId; + final isExpanded = _expandedEpisodeId == episodeId; + final episodeImageUrl = context.read().getImageUrl(episode, size: 256); + + // Try to get release date from metadata + final releaseDate = _formatReleaseDate(episode.metadata); + + return Column( + children: [ + InkWell( + onTap: () => _playEpisode(episode), + onLongPress: () { + setState(() { + _expandedEpisodeId = isExpanded ? null : episodeId; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode cover (bigger than before) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 72, + height: 72, + color: colorScheme.surfaceContainerHighest, + child: episodeImageUrl != null + ? CachedNetworkImage( + imageUrl: episodeImageUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => Icon( + MdiIcons.podcast, + size: 32, + color: colorScheme.onSurfaceVariant, + ), + errorWidget: (_, __, ___) => Icon( + MdiIcons.podcast, + size: 32, + color: colorScheme.onSurfaceVariant, + ), + ) + : Icon( + MdiIcons.podcast, + size: 32, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.name, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (episodeDescription != null && episodeDescription.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + episodeDescription, + maxLines: isExpanded ? 100 : 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + const SizedBox(height: 6), + // Duration and release date row + Row( + children: [ + if (duration != null && duration > Duration.zero) ...[ + Icon( + Icons.access_time, + size: 14, + color: colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(width: 4), + Text( + _formatDuration(duration), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + if (duration != null && duration > Duration.zero && releaseDate != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '•', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + if (releaseDate != null) + Text( + releaseDate, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + // Expanded action buttons (matching album track style) + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12), + child: Row( + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playEpisode(episode), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play on... button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showPlayOnMenu(context, episode), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Add to queue button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _addToQueue(episode), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.playlist_add, size: 20), + ), + ), + ], + ), + ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], + ); + }, + childCount: _episodes.length, + ), + ), + + // Bottom padding for mini player + const SliverToBoxAdapter( + child: SizedBox(height: 100), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 0138c405..a3c9e21d 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -15,10 +15,12 @@ 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'; class SearchScreen extends StatefulWidget { @@ -29,7 +31,7 @@ class SearchScreen extends StatefulWidget { } // Helper enum and class for ListView.builder item types -enum _ListItemType { header, artist, album, track, playlist, audiobook, spacer } +enum _ListItemType { header, artist, album, track, playlist, audiobook, radio, podcast, spacer } class _ListItem { final _ListItemType type; @@ -68,6 +70,16 @@ class _ListItem { headerTitle = null, headerCount = null; + _ListItem.radio(this.mediaItem, {this.relevanceScore}) + : type = _ListItemType.radio, + headerTitle = null, + headerCount = null; + + _ListItem.podcast(this.mediaItem, {this.relevanceScore}) + : type = _ListItemType.podcast, + headerTitle = null, + headerCount = null; + _ListItem.spacer() : type = _ListItemType.spacer, mediaItem = null, @@ -89,27 +101,49 @@ class SearchScreenState extends State { 'tracks': [], 'playlists': [], 'audiobooks': [], + 'radios': [], 'podcasts': [], }; bool _isSearching = false; bool _hasSearched = false; - String _activeFilter = 'all'; // 'all', 'artists', 'albums', 'tracks', 'playlists', 'audiobooks' + // PERF: Use ValueNotifier to avoid full screen rebuild on filter change + final ValueNotifier _activeFilterNotifier = ValueNotifier('all'); String? _searchError; List _recentSearches = []; bool _libraryOnly = false; String? _expandedTrackId; // Track ID for expanded quick actions + String? _expandedArtistId; // Artist ID for expanded quick actions + String? _expandedAlbumId; // Album ID for expanded quick actions + String? _expandedPlaylistId; // Playlist ID for expanded quick actions + String? _expandedAudiobookId; // Audiobook ID for expanded quick actions + String? _expandedRadioId; // Radio ID for expanded quick actions + String? _expandedPodcastId; // Podcast ID for expanded quick actions bool _hasSearchText = false; // PERF: Track separately to avoid rebuild on every keystroke + // Track library changes locally since item data doesn't update immediately + final Set _addedToLibrary = {}; + final Set _removedFromLibrary = {}; + // PERF: Cache list items per filter to avoid rebuilding during PageView animation Map> _cachedListItems = {}; // PERF: Cache available filters to avoid recalculating during build List? _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 _filterKeys = {}; + final Map _filterWidths = {}; + final Map _filterPositions = {}; + double _highlightLeft = 0; + double _highlightWidth = 80; // Default width until measured + @override void initState() { super.initState(); @@ -128,8 +162,8 @@ class SearchScreenState extends State { if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks'); if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists'); if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks'); - // Always show podcasts filter (placeholder until fully integrated) - filters.add('podcasts'); + if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios'); + if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts'); _cachedAvailableFilters = filters; return filters; } @@ -151,10 +185,43 @@ class SearchScreenState extends State { void _onPageChanged(int pageIndex) { final filters = _getAvailableFilters(); if (pageIndex >= 0 && pageIndex < filters.length) { + // Only update ValueNotifier - no setState needed + // ValueListenableBuilder will rebuild only the filter chips + _activeFilterNotifier.value = filters[pageIndex]; + _scrollFilterIntoView(pageIndex); + } + } + + /// 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(() { - _activeFilter = filters[pageIndex]; + _highlightLeft = position; + _highlightWidth = width; }); - _scrollFilterIntoView(pageIndex); } } @@ -173,30 +240,35 @@ class SearchScreenState extends State { // 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 currentScroll = _filterScrollController.offset; - final viewportWidth = _filterScrollController.position.viewportDimension; + final filter = filters[filterIndex]; - // Calculate the left and right edges of the active filter chip - final chipLeft = (filterIndex * chipWidth); + // 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 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 @@ -240,6 +312,7 @@ class SearchScreenState extends State { _focusNode.dispose(); _pageController.dispose(); _filterScrollController.dispose(); + _activeFilterNotifier.dispose(); super.dispose(); } @@ -277,7 +350,7 @@ class SearchScreenState extends State { Future _performSearch(String query, {bool keepFocus = false}) async { if (query.isEmpty) { setState(() { - _searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []}; + _searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []}; _hasSearched = false; _searchError = null; _cachedListItems.clear(); // PERF: Clear cache @@ -295,9 +368,71 @@ class SearchScreenState extends State { final provider = context.read(); final results = await provider.searchWithCache(query, libraryOnly: _libraryOnly); + // Search for radios: combine library filtering + global search + final queryLower = query.toLowerCase(); + + // 1. Filter from library radio stations + final libraryRadios = provider.radioStations + .where((radio) => radio.name.toLowerCase().contains(queryLower)) + .toList(); + + // 2. Also search globally via API (for providers like TuneIn) + List globalRadios = []; + if (!_libraryOnly) { + try { + globalRadios = await provider.api?.searchRadioStations(query) ?? []; + } catch (e) { + _logger.log('Global radio search failed: $e'); + } + } + + // 3. Combine and deduplicate by name + final allRadios = {}; + for (final radio in libraryRadios) { + allRadios[radio.name.toLowerCase()] = radio; + } + for (final radio in globalRadios) { + final key = radio.name.toLowerCase(); + if (!allRadios.containsKey(key)) { + allRadios[key] = radio; + } + } + + // Search for podcasts: combine library filtering + global search + // 1. Filter from library podcasts + final libraryPodcasts = provider.podcasts + .where((podcast) => podcast.name.toLowerCase().contains(queryLower)) + .toList(); + + // 2. Also search globally via API (for providers like iTunes) + List globalPodcasts = []; + if (!_libraryOnly) { + try { + globalPodcasts = await provider.api?.searchPodcasts(query) ?? []; + } catch (e) { + _logger.log('Global podcast search failed: $e'); + } + } + + // 3. Combine and deduplicate by name + final allPodcasts = {}; + for (final podcast in libraryPodcasts) { + allPodcasts[podcast.name.toLowerCase()] = podcast; + } + for (final podcast in globalPodcasts) { + final key = podcast.name.toLowerCase(); + if (!allPodcasts.containsKey(key)) { + allPodcasts[key] = podcast; + } + } + if (mounted) { setState(() { - _searchResults = results; + _searchResults = { + ...results, + 'radios': allRadios.values.toList(), + 'podcasts': allPodcasts.values.toList(), + }; _isSearching = false; _hasSearched = true; _cachedListItems.clear(); // PERF: Clear cache on new results @@ -544,9 +679,12 @@ class SearchScreenState extends State { final tracks = _searchResults['tracks'] as List? ?? []; final playlists = _searchResults['playlists'] as List? ?? []; final audiobooks = _searchResults['audiobooks'] as List? ?? []; + final radios = _searchResults['radios'] as List? ?? []; + final podcasts = _searchResults['podcasts'] as List? ?? []; final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty || - playlists.isNotEmpty || audiobooks.isNotEmpty; + playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty || + podcasts.isNotEmpty; if (!hasResults) { return EmptyState.search(context: context); @@ -555,140 +693,121 @@ class SearchScreenState extends State { // Column layout - filter bar above results, no overlay return Column( children: [ - // Filter tabs - always visible + // Filter tabs - rounded container matching search bar width Padding( - padding: const EdgeInsets.only(top: 8), - child: SingleChildScrollView( - controller: _filterScrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _buildFilterSelector(colorScheme), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: SizedBox( + height: 36, // Match library screen filter row height + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SingleChildScrollView( + controller: _filterScrollController, + scrollDirection: Axis.horizontal, + child: _buildFilterSelector(colorScheme), + ), + ), ), ), // Results with swipeable pages Expanded( - child: NotificationListener( - onNotification: _handleScrollNotification, - child: PageView.builder( - controller: _pageController, - onPageChanged: _onPageChanged, - itemCount: _getAvailableFilters().length, - itemBuilder: (context, pageIndex) { - final filters = _getAvailableFilters(); - final filterForPage = filters[pageIndex]; - - // PERF: Use cached list items to avoid rebuilding during animation - final listItems = _cachedListItems[filterForPage] ??= _buildListItemsForFilter( - filterForPage, artists, albums, tracks, playlists, audiobooks, - ); - - // PERF: Wrap each page in RepaintBoundary to isolate repaints during swipe - return RepaintBoundary( - key: ValueKey('page_$filterForPage'), - child: ListView.builder( - // PERF: Use key to preserve scroll position per filter - key: PageStorageKey('list_$filterForPage'), - padding: EdgeInsets.fromLTRB(16, 0, 16, BottomSpacing.navBarOnly), - cacheExtent: 500, - addAutomaticKeepAlives: false, - // PERF: false because each tile already has RepaintBoundary - addRepaintBoundaries: false, - itemCount: listItems.length, - itemBuilder: (context, index) { - final item = listItems[index]; - final showTypeInSubtitle = filterForPage == 'all'; - switch (item.type) { - case _ListItemType.header: - return _buildSectionHeader(item.headerTitle!, item.headerCount!); - case _ListItemType.artist: - return _buildArtistTile(item.mediaItem! as Artist); - case _ListItemType.album: - return _buildAlbumTile(item.mediaItem! as Album, showType: showTypeInSubtitle); - case _ListItemType.track: - return _buildTrackTile(item.mediaItem! as Track, showType: showTypeInSubtitle); - case _ListItemType.playlist: - return _buildPlaylistTile(item.mediaItem! as Playlist, showType: showTypeInSubtitle); - case _ListItemType.audiobook: - return _buildAudiobookTile(item.mediaItem! as Audiobook, showType: showTypeInSubtitle); - case _ListItemType.spacer: - return const SizedBox(height: 24); - } - }, + child: Stack( + children: [ + // Main scrollable content + NotificationListener( + onNotification: _handleScrollNotification, + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _getAvailableFilters().length, + // Faster settling so vertical scroll works sooner after swipe + physics: const _FastPageScrollPhysics(), + itemBuilder: (context, pageIndex) { + final filters = _getAvailableFilters(); + final filterForPage = filters[pageIndex]; + + // PERF: Use cached list items to avoid rebuilding during animation + final listItems = _cachedListItems[filterForPage] ??= _buildListItemsForFilter( + filterForPage, artists, albums, tracks, playlists, audiobooks, radios, podcasts, + ); + + // PERF: Wrap each page in RepaintBoundary to isolate repaints during swipe + return RepaintBoundary( + key: ValueKey('page_$filterForPage'), + child: ListView.builder( + // PERF: Use key to preserve scroll position per filter + key: PageStorageKey('list_$filterForPage'), + padding: EdgeInsets.fromLTRB(16, 16, 16, BottomSpacing.navBarOnly), + cacheExtent: 1000, + addAutomaticKeepAlives: false, + // PERF: false because each tile already has RepaintBoundary + addRepaintBoundaries: false, + itemCount: listItems.length, + itemBuilder: (context, index) { + final item = listItems[index]; + final showTypeInSubtitle = filterForPage == 'all'; + switch (item.type) { + case _ListItemType.header: + return _buildSectionHeader(item.headerTitle!, item.headerCount!); + case _ListItemType.artist: + return _buildArtistTile(item.mediaItem! as Artist, showType: showTypeInSubtitle); + case _ListItemType.album: + return _buildAlbumTile(item.mediaItem! as Album, showType: showTypeInSubtitle); + case _ListItemType.track: + return _buildTrackTile(item.mediaItem! as Track, showType: showTypeInSubtitle); + case _ListItemType.playlist: + return _buildPlaylistTile(item.mediaItem! as Playlist, showType: showTypeInSubtitle); + case _ListItemType.audiobook: + return _buildAudiobookTile(item.mediaItem! as Audiobook, showType: showTypeInSubtitle); + case _ListItemType.radio: + return _buildRadioTile(item.mediaItem!, showType: showTypeInSubtitle); + case _ListItemType.podcast: + return _buildPodcastTile(item.mediaItem!, showType: showTypeInSubtitle); + case _ListItemType.spacer: + return const SizedBox(height: 24); + } + }, + ), + ); + }, + ), + ), + // Fade gradient at top - content fades as it scrolls under filter bar + Positioned( + top: 0, + left: 0, + right: 0, + height: 24, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.background, + colorScheme.surface.withOpacity(0), + ], + ), + ), ), - ); - }, - ), + ), + ), + ], ), ), ], ); } - /// 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 name for albums/tracks) - 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; - } - } - } - - return score; - } - - /// Check if query matches at a word boundary in text - bool _matchesWordBoundary(String text, String query) { - 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 @@ -701,8 +820,10 @@ class SearchScreenState extends State { ) { 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 = {}; @@ -736,6 +857,7 @@ class SearchScreenState extends State { } // Filter candidates: only include if artist name contains any query word + // (stopwords already removed from queryWords) final crossRefArtists = []; for (final artist in candidateArtists.values) { final artistLower = artist.name.toLowerCase(); @@ -758,6 +880,8 @@ class SearchScreenState extends State { List tracks, List playlists, List audiobooks, + List radios, + List podcasts, ) { final items = <_ListItem>[]; final query = _searchController.text; @@ -786,6 +910,14 @@ class SearchScreenState extends State { final score = _calculateRelevanceScore(audiobook, query); scoredItems.add(_ListItem.audiobook(audiobook, relevanceScore: score)); } + for (final radio in radios) { + final score = _calculateRelevanceScore(radio, query); + scoredItems.add(_ListItem.radio(radio, relevanceScore: score)); + } + for (final podcast in podcasts) { + final score = _calculateRelevanceScore(podcast, query); + scoredItems.add(_ListItem.podcast(podcast, relevanceScore: score)); + } final crossRefArtists = _extractCrossReferencedArtists( query, @@ -832,10 +964,22 @@ class SearchScreenState extends State { } } + if (filter == 'radios' && radios.isNotEmpty) { + for (final radio in radios) { + items.add(_ListItem.radio(radio)); + } + } + + if (filter == 'podcasts' && podcasts.isNotEmpty) { + for (final podcast in podcasts) { + items.add(_ListItem.podcast(podcast)); + } + } + 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)!; @@ -848,46 +992,101 @@ class SearchScreenState extends State { case 'tracks': return l10n.tracks; case 'playlists': return l10n.playlists; case 'audiobooks': return l10n.audiobooks; + case 'radios': return l10n.radio; case 'podcasts': return l10n.podcasts; default: return filter; } } - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: 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: () { - setState(() { - _activeFilter = filter; - }); - _animateToFilter(filter); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Text( - getLabel(filter), - style: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant.withOpacity(0.8), - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + // 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( + valueListenable: _activeFilterNotifier, + builder: (context, activeFilter, _) { + // 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), + ), ), ), - ), + // 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(), + ), + ], ), - ); - }).toList(), - ), + ), + ); + }, ); } @@ -905,7 +1104,7 @@ class SearchScreenState extends State { ); } - Widget _buildArtistTile(Artist artist) { + Widget _buildArtistTile(Artist artist, {bool showType = false}) { final colorScheme = Theme.of(context).colorScheme; final maProvider = context.read(); final imageUrl = maProvider.getImageUrl(artist, size: 256); @@ -913,166 +1112,25 @@ class SearchScreenState extends State { // Use 'search' suffix to avoid hero tag conflicts with library cards const heroSuffix = '_search'; final artistId = artist.uri ?? artist.itemId; - - return RepaintBoundary( - child: ListTile( - key: ValueKey(artistId), - leading: Hero( - tag: HeroTags.artistImage + artistId + heroSuffix, - child: ArtistAvatar( - artist: artist, - radius: 24, - imageSize: 128, - ), - ), - title: Text( - artist.name, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - S.of(context)!.artist, - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), - ), - onTap: () { - // Update adaptive colors before navigation - updateAdaptiveColorsFromImage(context, imageUrl); - Navigator.push( - context, - FadeSlidePageRoute( - child: ArtistDetailsScreen( - artist: artist, - heroTagSuffix: 'search', - initialImageUrl: imageUrl, - ), - ), - ); - }, - ), - ); - } - - Widget _buildAlbumTile(Album album, {bool showType = false}) { - final maProvider = context.read(); - final imageUrl = maProvider.getImageUrl(album, size: 128); - final colorScheme = Theme.of(context).colorScheme; - final subtitleText = showType - ? '${album.artistsString} • ${S.of(context)!.albumSingular}' - : album.artistsString; - - // Use 'search' suffix to avoid hero tag conflicts with library cards - const heroSuffix = '_search'; - final albumId = album.uri ?? album.itemId; - - return RepaintBoundary( - child: ListTile( - key: ValueKey(albumId), - leading: Hero( - tag: HeroTags.albumCover + albumId + heroSuffix, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(8), - image: imageUrl != null - ? DecorationImage( - image: CachedNetworkImageProvider(imageUrl), - fit: BoxFit.cover, - ) - : null, - ), - child: imageUrl == null - ? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant) - : null, - ), - ), - title: Hero( - tag: HeroTags.albumTitle + albumId + heroSuffix, - child: Material( - color: Colors.transparent, - child: Text( - album.nameWithYear, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - subtitle: Hero( - tag: HeroTags.artistName + albumId + heroSuffix, - child: Material( - color: Colors.transparent, - child: Text( - subtitleText, - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - onTap: () { - // Update adaptive colors before navigation - updateAdaptiveColorsFromImage(context, imageUrl); - Navigator.push( - context, - FadeSlidePageRoute( - child: AlbumDetailsScreen( - album: album, - heroTagSuffix: 'search', - initialImageUrl: imageUrl, - ), - ), - ); - }, - ), - ); - } - - Widget _buildTrackTile(Track track, {bool showType = false}) { - final maProvider = context.read(); - final imageUrl = track.album != null - ? maProvider.getImageUrl(track.album!, size: 128) - : null; - final colorScheme = Theme.of(context).colorScheme; - final subtitleText = showType - ? '${track.artistsString} • ${S.of(context)!.trackSingular}' - : track.artistsString; - final trackId = track.uri ?? track.itemId; - final isExpanded = _expandedTrackId == trackId; + final isExpanded = _expandedArtistId == artistId; + final isInLib = _isInLibrary(artist); return RepaintBoundary( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( - key: ValueKey(trackId), - leading: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(8), - image: imageUrl != null - ? DecorationImage( - image: CachedNetworkImageProvider(imageUrl), - fit: BoxFit.cover, - ) - : null, + key: ValueKey(artistId), + leading: Hero( + tag: HeroTags.artistImage + artistId + heroSuffix, + child: ArtistAvatar( + artist: artist, + radius: 24, + imageSize: 128, ), - child: imageUrl == null - ? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant) - : null, ), title: Text( - track.name, + artist.name, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -1080,28 +1138,36 @@ class SearchScreenState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, ), - subtitle: Text( - subtitleText, - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: track.duration != null - ? Text( - _formatDuration(track.duration!), - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), + subtitle: showType + ? Row( + mainAxisSize: MainAxisSize.min, + children: [_buildTypePill('artist', colorScheme)], ) - : null, + : Text( + S.of(context)!.artist, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + ), onTap: () { if (isExpanded) { - setState(() => _expandedTrackId = null); + setState(() => _expandedArtistId = null); } else { - _playTrack(track); + // Update adaptive colors before navigation + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: ArtistDetailsScreen( + artist: artist, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); } }, onLongPress: () { setState(() { - _expandedTrackId = isExpanded ? null : trackId; + _expandedArtistId = isExpanded ? null : artistId; }); }, ), @@ -1115,12 +1181,12 @@ class SearchScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - // Radio button (uses current player - 1 tap) + // Artist Radio button SizedBox( height: 44, width: 44, child: FilledButton.tonal( - onPressed: () => _playRadio(track), + onPressed: () => _playArtistRadio(artist), style: FilledButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( @@ -1131,55 +1197,63 @@ class SearchScreenState extends State { ), ), const SizedBox(width: 10), - // Radio On button (pick player - 2 taps) + // Add to queue button SizedBox( height: 44, width: 44, child: FilledButton.tonal( - onPressed: () => _showRadioOnMenu(track), + onPressed: () => _addArtistToQueue(artist), style: FilledButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), - child: const Icon(Icons.speaker_group_outlined, size: 20), + child: const Icon(Icons.playlist_add, size: 20), ), ), const SizedBox(width: 10), - // Add to queue button (uses current player - 1 tap) + // Favorite button SizedBox( height: 44, width: 44, child: FilledButton.tonal( - onPressed: () => _addToQueue(track), + onPressed: () => _toggleArtistFavorite(artist), style: FilledButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(22), ), ), - child: const Icon(Icons.playlist_add, size: 20), + child: Icon( + artist.favorite == true ? Icons.favorite : Icons.favorite_border, + size: 20, + color: artist.favorite == true + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), ), ), const SizedBox(width: 10), - // Favorite button + // Library button SizedBox( height: 44, width: 44, child: FilledButton.tonal( - onPressed: () => _toggleTrackFavorite(track), + onPressed: () => isInLib + ? _removeFromLibrary(artist, 'artist') + : _addToLibrary(artist, 'artist'), style: FilledButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(12), ), ), child: Icon( - track.favorite == true ? Icons.favorite : Icons.favorite_border, + isInLib ? Icons.library_add_check : Icons.library_add, size: 20, - color: track.favorite == true - ? colorScheme.error + color: isInLib + ? colorScheme.primary : colorScheme.onSurfaceVariant, ), ), @@ -1194,113 +1268,256 @@ class SearchScreenState extends State { ); } - Widget _buildPlaylistTile(Playlist playlist, {bool showType = false}) { + Widget _buildAlbumTile(Album album, {bool showType = false}) { final maProvider = context.read(); - final imageUrl = maProvider.getImageUrl(playlist, size: 128); + final imageUrl = maProvider.getImageUrl(album, size: 128); final colorScheme = Theme.of(context).colorScheme; - final subtitleText = showType - ? (playlist.owner != null ? '${playlist.owner} • ${S.of(context)!.playlist}' : S.of(context)!.playlist) - : (playlist.owner ?? S.of(context)!.playlist); + + // Use 'search' suffix to avoid hero tag conflicts with library cards + const heroSuffix = '_search'; + final albumId = album.uri ?? album.itemId; + final isExpanded = _expandedAlbumId == albumId; + final isInLib = album.inLibrary; return RepaintBoundary( - child: ListTile( - key: ValueKey(playlist.uri ?? playlist.itemId), - leading: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(8), - image: imageUrl != null - ? DecorationImage( - image: CachedNetworkImageProvider(imageUrl), - fit: BoxFit.cover, - ) - : null, - ), - child: imageUrl == null - ? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant) - : null, - ), - title: Text( - playlist.name, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - subtitleText, - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: playlist.trackCount != null - ? Text( - S.of(context)!.trackCount(playlist.trackCount!), - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), - ) - : null, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PlaylistDetailsScreen( - playlist: playlist, - provider: playlist.provider, - itemId: playlist.itemId, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: ValueKey(albumId), + leading: Hero( + tag: HeroTags.albumCover + albumId + heroSuffix, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + image: imageUrl != null + ? DecorationImage( + image: CachedNetworkImageProvider(imageUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: imageUrl == null + ? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant) + : null, ), ), - ); - }, + title: Hero( + tag: HeroTags.albumTitle + albumId + heroSuffix, + child: Material( + color: Colors.transparent, + child: Text( + album.nameWithYear, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + subtitle: Hero( + tag: HeroTags.artistName + albumId + heroSuffix, + child: Material( + color: Colors.transparent, + child: showType + ? Row( + children: [ + _buildTypePill('album', colorScheme), + const SizedBox(width: 6), + Expanded( + child: Text( + album.artistsString, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : Text( + album.artistsString, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + onTap: () { + if (isExpanded) { + setState(() => _expandedAlbumId = null); + } else { + // Update adaptive colors before navigation + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: AlbumDetailsScreen( + album: album, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + }, + onLongPress: () { + setState(() { + _expandedAlbumId = isExpanded ? null : albumId; + }); + }, + ), + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playAlbum(album), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play On button (pick player) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showAlbumPlayOnMenu(album), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Add to queue button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _addAlbumToQueue(album), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.playlist_add, size: 20), + ), + ), + const SizedBox(width: 10), + // Favorite button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _toggleAlbumFavorite(album), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + ), + child: Icon( + album.favorite == true ? Icons.favorite : Icons.favorite_border, + size: 20, + color: album.favorite == true + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 10), + // Library button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => isInLib + ? _removeFromLibrary(album, 'album') + : _addToLibrary(album, 'album'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isInLib ? Icons.library_add_check : Icons.library_add, + size: 20, + color: isInLib + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], ), ); } - Widget _buildAudiobookTile(Audiobook audiobook, {bool showType = false}) { + Widget _buildTrackTile(Track track, {bool showType = false}) { final maProvider = context.read(); - final imageUrl = maProvider.getImageUrl(audiobook, size: 128); + final imageUrl = track.album != null + ? maProvider.getImageUrl(track.album!, size: 128) + : null; final colorScheme = Theme.of(context).colorScheme; - final authorText = audiobook.authors?.map((a) => a.name).join(', ') ?? S.of(context)!.unknownAuthor; - final subtitleText = showType - ? '$authorText • ${S.of(context)!.audiobookSingular}' - : authorText; - - // Use 'search' suffix to avoid hero tag conflicts with library cards - const heroSuffix = '_search'; - final audiobookId = audiobook.uri ?? audiobook.itemId; + final trackId = track.uri ?? track.itemId; + final isExpanded = _expandedTrackId == trackId; return RepaintBoundary( - child: ListTile( - key: ValueKey(audiobookId), - leading: Hero( - tag: HeroTags.audiobookCover + audiobookId + heroSuffix, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(8), - image: imageUrl != null - ? DecorationImage( - image: CachedNetworkImageProvider(imageUrl), - fit: BoxFit.cover, - ) + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: ValueKey(trackId), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + image: imageUrl != null + ? DecorationImage( + image: CachedNetworkImageProvider(imageUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: imageUrl == null + ? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant) : null, ), - child: imageUrl == null - ? Icon(Icons.headphones_rounded, color: colorScheme.onSurfaceVariant) - : null, - ), - ), - title: Hero( - tag: HeroTags.audiobookTitle + audiobookId + heroSuffix, - child: Material( - color: Colors.transparent, - child: Text( - audiobook.name, + title: Text( + track.name, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -1308,57 +1525,1523 @@ class SearchScreenState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, ), + subtitle: showType + ? Row( + children: [ + _buildTypePill('track', colorScheme), + const SizedBox(width: 6), + Expanded( + child: Text( + track.artistsString, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : Text( + track.artistsString, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: track.duration != null + ? Text( + _formatDuration(track.duration!), + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), + ) + : null, + onTap: () { + if (isExpanded) { + setState(() => _expandedTrackId = null); + } else { + _playTrack(track); + } + }, + onLongPress: () { + setState(() { + _expandedTrackId = isExpanded ? null : trackId; + }); + }, ), - ), - subtitle: Text( - subtitleText, - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: audiobook.duration != null - ? Text( - _formatDuration(audiobook.duration!), - style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), - ) - : null, - onTap: () { - // Update adaptive colors before navigation - updateAdaptiveColorsFromImage(context, imageUrl); - Navigator.push( - context, - FadeSlidePageRoute( - child: AudiobookDetailScreen( - audiobook: audiobook, - heroTagSuffix: 'search', - initialImageUrl: imageUrl, - ), - ), - ); - }, - ), - ); + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Radio button (uses current player - 1 tap) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playRadio(track), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.radio, size: 20), + ), + ), + const SizedBox(width: 10), + // Radio On button (pick player - 2 taps) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showRadioOnMenu(track), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Add to queue button (uses current player - 1 tap) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _addToQueue(track), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.playlist_add, size: 20), + ), + ), + const SizedBox(width: 10), + // Favorite button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _toggleTrackFavorite(track), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + ), + child: Icon( + track.favorite == true ? Icons.favorite : Icons.favorite_border, + size: 20, + color: track.favorite == true + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildPlaylistTile(Playlist playlist, {bool showType = false}) { + final maProvider = context.read(); + final imageUrl = maProvider.getImageUrl(playlist, size: 128); + final colorScheme = Theme.of(context).colorScheme; + final playlistId = playlist.uri ?? playlist.itemId; + 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: Hero( + tag: HeroTags.playlistCover + playlistId + heroSuffix, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + 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, + ), + ), + ), + ), + 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, + ), + ), + ), + subtitle: showType + ? Row( + children: [ + _buildTypePill('playlist', colorScheme), + if (playlist.owner != null) ...[ + const SizedBox(width: 6), + Expanded( + child: Text( + playlist.owner!, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ) + : Text( + playlist.owner ?? S.of(context)!.playlist, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: playlist.trackCount != null + ? Text( + S.of(context)!.trackCount(playlist.trackCount!), + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), + ) + : null, + onTap: () { + if (isExpanded) { + setState(() => _expandedPlaylistId = null); + } else { + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PlaylistDetailsScreen( + playlist: playlist, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + }, + onLongPress: () { + setState(() { + _expandedPlaylistId = isExpanded ? null : playlistId; + }); + }, + ), + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playPlaylist(playlist), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play On button (pick player) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showPlaylistPlayOnMenu(playlist), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Library button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => isInLib + ? _removeFromLibrary(playlist, 'playlist') + : _addToLibrary(playlist, 'playlist'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isInLib ? Icons.library_add_check : Icons.library_add, + size: 20, + color: isInLib + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildAudiobookTile(Audiobook audiobook, {bool showType = false}) { + final maProvider = context.read(); + final imageUrl = maProvider.getImageUrl(audiobook, size: 128); + final colorScheme = Theme.of(context).colorScheme; + final authorText = audiobook.authors?.map((a) => a.name).join(', ') ?? S.of(context)!.unknownAuthor; + + // Use 'search' suffix to avoid hero tag conflicts with library cards + const heroSuffix = '_search'; + final audiobookId = audiobook.uri ?? audiobook.itemId; + final isExpanded = _expandedAudiobookId == audiobookId; + final isInLib = _isInLibrary(audiobook); + + return RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: ValueKey(audiobookId), + leading: Hero( + tag: HeroTags.audiobookCover + audiobookId + heroSuffix, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + image: imageUrl != null + ? DecorationImage( + image: CachedNetworkImageProvider(imageUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: imageUrl == null + ? Icon(Icons.headphones_rounded, color: colorScheme.onSurfaceVariant) + : null, + ), + ), + title: Hero( + tag: HeroTags.audiobookTitle + audiobookId + heroSuffix, + child: Material( + color: Colors.transparent, + child: Text( + audiobook.name, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + subtitle: showType + ? Row( + children: [ + _buildTypePill('audiobook', colorScheme), + const SizedBox(width: 6), + Expanded( + child: Text( + authorText, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : Text( + authorText, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: audiobook.duration != null + ? Text( + _formatDuration(audiobook.duration!), + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12), + ) + : null, + onTap: () { + if (isExpanded) { + setState(() => _expandedAudiobookId = null); + } else { + // Update adaptive colors before navigation + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: AudiobookDetailScreen( + audiobook: audiobook, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + }, + onLongPress: () { + setState(() { + _expandedAudiobookId = isExpanded ? null : audiobookId; + }); + }, + ), + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playAudiobook(audiobook), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play On button (pick player) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showAudiobookPlayOnMenu(audiobook), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Favorite button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _toggleAudiobookFavorite(audiobook), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + ), + child: Icon( + audiobook.favorite == true ? Icons.favorite : Icons.favorite_border, + size: 20, + color: audiobook.favorite == true + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 10), + // Library button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => isInLib + ? _removeFromLibrary(audiobook, 'audiobook') + : _addToLibrary(audiobook, 'audiobook'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isInLib ? Icons.library_add_check : Icons.library_add, + size: 20, + color: isInLib + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildRadioTile(MediaItem radio, {bool showType = false}) { + final maProvider = context.read(); + final imageUrl = maProvider.getImageUrl(radio, size: 128); + final colorScheme = Theme.of(context).colorScheme; + final radioId = radio.uri ?? radio.itemId; + final isExpanded = _expandedRadioId == radioId; + final isInLib = _isInLibrary(radio); + + return RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: ValueKey(radioId), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + image: imageUrl != null + ? DecorationImage( + image: CachedNetworkImageProvider(imageUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: imageUrl == null + ? Icon(Icons.radio_rounded, color: colorScheme.onSurfaceVariant) + : null, + ), + title: Text( + radio.name, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: showType + ? Row( + mainAxisSize: MainAxisSize.min, + children: [_buildTypePill('radio', colorScheme)], + ) + : Text( + S.of(context)!.radio, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + if (isExpanded) { + setState(() => _expandedRadioId = null); + } else { + _playRadioStation(radio); + } + }, + onLongPress: () { + setState(() { + _expandedRadioId = isExpanded ? null : radioId; + }); + }, + ), + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playRadioStation(radio), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play On button (pick player) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showRadioPlayOnMenu(radio), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Library button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => isInLib + ? _removeFromLibrary(radio, 'radio') + : _addToLibrary(radio, 'radio'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isInLib ? Icons.library_add_check : Icons.library_add, + size: 20, + color: isInLib + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildPodcastTile(MediaItem podcast, {bool showType = false}) { + final maProvider = context.read(); + final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 128); + final colorScheme = Theme.of(context).colorScheme; + + // Use 'search' suffix to avoid hero tag conflicts with library cards + const heroSuffix = '_search'; + final podcastId = podcast.uri ?? podcast.itemId; + final isExpanded = _expandedPodcastId == podcastId; + final isInLib = _isInLibrary(podcast); + + return RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: ValueKey(podcastId), + leading: Hero( + tag: HeroTags.podcastCover + podcastId + heroSuffix, + // Match library/detail pattern: ClipRRect(16) → Container → CachedNetworkImage + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => const SizedBox(), + errorWidget: (_, __, ___) => Icon(Icons.podcasts_rounded, color: colorScheme.onSurfaceVariant), + ) + : Icon(Icons.podcasts_rounded, color: colorScheme.onSurfaceVariant), + ), + ), + ), + title: Text( + podcast.name, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: showType + ? Row( + mainAxisSize: MainAxisSize.min, + children: [_buildTypePill('podcast', colorScheme)], + ) + : Text( + S.of(context)!.podcasts, + style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + if (isExpanded) { + setState(() => _expandedPodcastId = null); + } else { + // Update adaptive colors before navigation + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PodcastDetailScreen( + podcast: podcast, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + }, + onLongPress: () { + setState(() { + _expandedPodcastId = isExpanded ? null : podcastId; + }); + }, + ), + // Expandable quick actions row + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: isExpanded + ? Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _playPodcast(podcast), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.play_arrow, size: 20), + ), + ), + const SizedBox(width: 10), + // Play On button (pick player) + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => _showPodcastPlayOnMenu(podcast), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.speaker_group_outlined, size: 20), + ), + ), + const SizedBox(width: 10), + // Library button + SizedBox( + height: 44, + width: 44, + child: FilledButton.tonal( + onPressed: () => isInLib + ? _removeFromLibrary(podcast, 'podcast') + : _addToLibrary(podcast, 'podcast'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isInLib ? Icons.library_add_check : Icons.library_add, + size: 20, + color: isInLib + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Future _playRadioStation(MediaItem station) async { + final maProvider = context.read(); + + if (maProvider.selectedPlayer == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.api?.playRadioStation( + maProvider.selectedPlayer!.playerId, + station, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.playingRadioStation(station.name)), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play radio station: $e')), + ); + } + } + } + + Future _playTrack(Track track) async { + final maProvider = context.read(); + + if (maProvider.selectedPlayer == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + // Use Music Assistant to play the track on the selected player + await maProvider.playTrack( + maProvider.selectedPlayer!.playerId, + track, + ); + } + + /// Play track radio on current player (1 tap) + Future _playRadio(Track track) async { + final maProvider = context.read(); + final player = maProvider.selectedPlayer; + + if (player == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.playRadio(player.playerId, track); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.startingRadio(track.name)), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))), + ); + } + } + } + + /// Show player picker for Radio On (2 taps) + void _showRadioOnMenu(Track track) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + try { + maProvider.selectPlayer(player); + await maProvider.playRadio(player.playerId, track); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.startingRadioOnPlayer(track.name, player.name)), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))), + ); + } + } + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + /// Add track to queue on current player (1 tap) + Future _addToQueue(Track track) async { + final maProvider = context.read(); + final player = maProvider.selectedPlayer; + + if (player == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.addTrackToQueue(player.playerId, track); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.addedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))), + ); + } + } + } + + Future _toggleTrackFavorite(Track track) async { + final maProvider = context.read(); + final currentFavorite = track.favorite ?? false; + + try { + bool success; + + if (currentFavorite) { + // Remove from favorites + int? libraryItemId; + if (track.provider == 'library') { + libraryItemId = int.tryParse(track.itemId); + } else if (track.providerMappings != null) { + final libraryMapping = track.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => track.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId != null) { + success = await maProvider.removeFromFavorites( + mediaType: 'track', + libraryItemId: libraryItemId, + ); + } else { + success = false; + } + } else { + // Add to favorites + String actualProvider = track.provider; + String actualItemId = track.itemId; + + if (track.providerMappings != null && track.providerMappings!.isNotEmpty) { + final mapping = track.providerMappings!.firstWhere( + (m) => m.available && m.providerInstance != 'library', + orElse: () => track.providerMappings!.firstWhere( + (m) => m.available, + orElse: () => track.providerMappings!.first, + ), + ); + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = mapping.providerDomain; + actualItemId = mapping.itemId; + } + + success = await maProvider.addToFavorites( + mediaType: 'track', + provider: actualProvider, + itemId: actualItemId, + ); + } + + if (success && mounted) { + // Update the track's favorite state in search results + setState(() { + final tracks = _searchResults['tracks'] as List?; + if (tracks != null) { + final index = tracks.indexWhere((t) => (t.uri ?? t.itemId) == (track.uri ?? track.itemId)); + if (index != -1) { + // Create updated track with new favorite state + final updatedTrack = Track.fromJson({ + ...tracks[index].toJson(), + 'favorite': !currentFavorite, + }); + tracks[index] = updatedTrack; + } + } + }); + } + } catch (e) { + _logger.log('Error toggling favorite: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update favorite: $e')), + ); + } + } + } + + /// Check if a media item is in the library + /// Uses local tracking to reflect changes made in this session + bool _isInLibrary(MediaItem item) { + final itemKey = '${item.mediaType.name}:${item.itemId}'; + + // Check local state first (overrides server state for this session) + if (_addedToLibrary.contains(itemKey)) return true; + if (_removedFromLibrary.contains(itemKey)) return false; + + // Fall back to server state + if (item.provider == 'library') return true; + return item.providerMappings?.any((m) => m.providerInstance == 'library') ?? false; + } + + /// Add media item to library + Future _addToLibrary(MediaItem item, String mediaTypeKey) async { + final maProvider = context.read(); + + // Get provider info for adding - MUST use non-library provider + String? actualProvider; + String? actualItemId; + + if (item.providerMappings != null && item.providerMappings!.isNotEmpty) { + // For adding to library, we MUST use a non-library provider + // Availability doesn't matter - we just need the external provider's ID + final nonLibraryMapping = item.providerMappings!.where( + (m) => m.providerInstance != 'library' && m.providerDomain != 'library', + ).firstOrNull; + + if (nonLibraryMapping != null) { + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") + actualProvider = nonLibraryMapping.providerDomain; + actualItemId = nonLibraryMapping.itemId; + } + } + + // Fallback to item's own provider if no non-library mapping found + if (actualProvider == null || actualItemId == null) { + if (item.provider != 'library') { + actualProvider = item.provider; + actualItemId = item.itemId; + } else { + // Item is library-only, can't add + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Item is already in library')), + ); + } + return; + } + } + + // OPTIMISTIC UPDATE: Update UI immediately, don't wait for API + final itemKey = '${item.mediaType.name}:${item.itemId}'; + _addedToLibrary.add(itemKey); + _removedFromLibrary.remove(itemKey); + setState(() {}); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.addedToLibrary), + duration: const Duration(seconds: 1), + ), + ); + } + + // Fire and forget - API call happens in background + // Even if it times out, MA usually processes it successfully + maProvider.addToLibrary( + mediaType: mediaTypeKey, + provider: actualProvider, + itemId: actualItemId, + ).catchError((e) { + _logger.log('Error adding to library: $e'); + // Only revert on actual errors, not timeouts (MA usually succeeds anyway) + if (!e.toString().contains('timeout')) { + _addedToLibrary.remove(itemKey); + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to add to library')), + ); + } + } + }); + } + + /// Remove media item from library + Future _removeFromLibrary(MediaItem item, String mediaTypeKey) async { + final maProvider = context.read(); + + // Get library item ID for removal + int? libraryItemId; + if (item.provider == 'library') { + libraryItemId = int.tryParse(item.itemId); + } else if (item.providerMappings != null) { + final libraryMapping = item.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => item.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot find library ID for removal')), + ); + return; + } + + // OPTIMISTIC UPDATE: Update UI immediately, don't wait for API + final itemKey = '${item.mediaType.name}:${item.itemId}'; + _removedFromLibrary.add(itemKey); + _addedToLibrary.remove(itemKey); + setState(() {}); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.removedFromLibrary), + duration: const Duration(seconds: 1), + ), + ); + } + + // Fire and forget - API call happens in background + maProvider.removeFromLibrary( + mediaType: mediaTypeKey, + libraryItemId: libraryItemId, + ).catchError((e) { + _logger.log('Error removing from library: $e'); + // Only revert on actual errors, not timeouts + if (!e.toString().contains('timeout')) { + _removedFromLibrary.remove(itemKey); + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to remove from library')), + ); + } + } + }); + } + + /// Play artist radio on current player + Future _playArtistRadio(Artist artist) async { + final maProvider = context.read(); + final player = maProvider.selectedPlayer; + + if (player == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.playArtistRadio(player.playerId, artist); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.startingRadio(artist.name)), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))), + ); + } + } + } + + /// Add artist's tracks to queue + Future _addArtistToQueue(Artist artist) async { + final maProvider = context.read(); + final player = maProvider.selectedPlayer; + + if (player == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.api?.addArtistToQueue(player.playerId, artist); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.addedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))), + ); + } + } + } + + /// Toggle artist favorite status + Future _toggleArtistFavorite(Artist artist) async { + final maProvider = context.read(); + final currentFavorite = artist.favorite ?? false; + + try { + bool success; + + if (currentFavorite) { + // Remove from favorites + int? libraryItemId; + if (artist.provider == 'library') { + libraryItemId = int.tryParse(artist.itemId); + } else if (artist.providerMappings != null) { + final libraryMapping = artist.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => artist.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId != null) { + success = await maProvider.removeFromFavorites( + mediaType: 'artist', + libraryItemId: libraryItemId, + ); + } else { + success = false; + } + } else { + // Add to favorites + String actualProvider = artist.provider; + String actualItemId = artist.itemId; + + if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) { + final mapping = artist.providerMappings!.firstWhere( + (m) => m.available && m.providerInstance != 'library', + orElse: () => artist.providerMappings!.firstWhere( + (m) => m.available, + orElse: () => artist.providerMappings!.first, + ), + ); + actualProvider = mapping.providerDomain; + actualItemId = mapping.itemId; + } + + success = await maProvider.addToFavorites( + mediaType: 'artist', + provider: actualProvider, + itemId: actualItemId, + ); + } + + if (success && mounted) { + setState(() { + final artists = _searchResults['artists'] as List?; + if (artists != null) { + final index = artists.indexWhere((a) => (a.uri ?? a.itemId) == (artist.uri ?? artist.itemId)); + if (index != -1) { + // Create updated artist with new favorite state + final updatedArtist = Artist.fromJson({ + ...artists[index].toJson(), + 'favorite': !currentFavorite, + }); + artists[index] = updatedArtist; + } + } + }); + } + } catch (e) { + _logger.log('Error toggling artist favorite: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update favorite: $e')), + ); + } + } + } + + /// Play album on current player + Future _playAlbum(Album album) async { + final maProvider = context.read(); + final player = maProvider.selectedPlayer; + + if (player == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.noPlayerSelected)), + ); + return; + } + + try { + await maProvider.api?.playAlbum(player.playerId, album); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.playingAlbum(album.name)), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play album: $e')), + ); + } + } + } + + /// Show player picker for album + void _showAlbumPlayOnMenu(Album album) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + try { + maProvider.selectPlayer(player); + await maProvider.api?.playAlbum(player.playerId, album); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Playing ${album.name} on ${player.name}'), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play album: $e')), + ); + } + } + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); } - Future _playTrack(Track track) async { + /// Add album to queue + Future _addAlbumToQueue(Album album) async { final maProvider = context.read(); + final player = maProvider.selectedPlayer; - if (maProvider.selectedPlayer == null) { + if (player == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(S.of(context)!.noPlayerSelected)), ); return; } - // Use Music Assistant to play the track on the selected player - await maProvider.playTrack( - maProvider.selectedPlayer!.playerId, - track, - ); + try { + await maProvider.api?.addAlbumToQueue(player.playerId, album); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.addedToQueue), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))), + ); + } + } } - /// Play track radio on current player (1 tap) - Future _playRadio(Track track) async { + /// Toggle album favorite status + Future _toggleAlbumFavorite(Album album) async { + final maProvider = context.read(); + final currentFavorite = album.favorite ?? false; + + try { + bool success; + + if (currentFavorite) { + // Remove from favorites + int? libraryItemId; + if (album.provider == 'library') { + libraryItemId = int.tryParse(album.itemId); + } else if (album.providerMappings != null) { + final libraryMapping = album.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => album.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + + if (libraryItemId != null) { + success = await maProvider.removeFromFavorites( + mediaType: 'album', + libraryItemId: libraryItemId, + ); + } else { + success = false; + } + } else { + // Add to favorites + String actualProvider = album.provider; + String actualItemId = album.itemId; + + if (album.providerMappings != null && album.providerMappings!.isNotEmpty) { + final mapping = album.providerMappings!.firstWhere( + (m) => m.available && m.providerInstance != 'library', + orElse: () => album.providerMappings!.firstWhere( + (m) => m.available, + orElse: () => album.providerMappings!.first, + ), + ); + actualProvider = mapping.providerDomain; + actualItemId = mapping.itemId; + } + + success = await maProvider.addToFavorites( + mediaType: 'album', + provider: actualProvider, + itemId: actualItemId, + ); + } + + if (success && mounted) { + setState(() { + final albums = _searchResults['albums'] as List?; + if (albums != null) { + final index = albums.indexWhere((a) => (a.uri ?? a.itemId) == (album.uri ?? album.itemId)); + if (index != -1) { + // Create updated album with new favorite state + final updatedAlbum = Album.fromJson({ + ...albums[index].toJson(), + 'favorite': !currentFavorite, + }); + albums[index] = updatedAlbum; + } + } + }); + } + } catch (e) { + _logger.log('Error toggling album favorite: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update favorite: $e')), + ); + } + } + } + + /// Play playlist on current player + Future _playPlaylist(Playlist playlist) async { final maProvider = context.read(); final player = maProvider.selectedPlayer; @@ -1370,11 +3053,11 @@ class SearchScreenState extends State { } try { - await maProvider.playRadio(player.playerId, track); + await maProvider.api?.playPlaylist(player.playerId, playlist); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context)!.startingRadio(track.name)), + content: Text(S.of(context)!.playingPlaylist(playlist.name)), duration: const Duration(seconds: 1), ), ); @@ -1382,14 +3065,14 @@ class SearchScreenState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))), + SnackBar(content: Text('Failed to play playlist: $e')), ); } } } - /// Show player picker for Radio On (2 taps) - void _showRadioOnMenu(Track track) { + /// Show player picker for playlist + void _showPlaylistPlayOnMenu(Playlist playlist) { final maProvider = context.read(); GlobalPlayerOverlay.hidePlayer(); @@ -1402,11 +3085,11 @@ class SearchScreenState extends State { onPlayerSelected: (player) async { try { maProvider.selectPlayer(player); - await maProvider.playRadio(player.playerId, track); + await maProvider.api?.playPlaylist(player.playerId, playlist); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context)!.startingRadioOnPlayer(track.name, player.name)), + content: Text('Playing ${playlist.name} on ${player.name}'), duration: const Duration(seconds: 1), ), ); @@ -1414,7 +3097,7 @@ class SearchScreenState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(S.of(context)!.failedToStartRadio(e.toString()))), + SnackBar(content: Text('Failed to play playlist: $e')), ); } } @@ -1424,8 +3107,8 @@ class SearchScreenState extends State { }); } - /// Add track to queue on current player (1 tap) - Future _addToQueue(Track track) async { + /// Play audiobook on current player + Future _playAudiobook(Audiobook audiobook) async { final maProvider = context.read(); final player = maProvider.selectedPlayer; @@ -1437,11 +3120,11 @@ class SearchScreenState extends State { } try { - await maProvider.addTrackToQueue(player.playerId, track); + await maProvider.api?.playAudiobook(player.playerId, audiobook); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(S.of(context)!.addedToQueue), + content: Text('Playing ${audiobook.name}'), duration: const Duration(seconds: 1), ), ); @@ -1449,15 +3132,52 @@ class SearchScreenState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))), + SnackBar(content: Text('Failed to play audiobook: $e')), ); } } } - Future _toggleTrackFavorite(Track track) async { + /// Show player picker for audiobook + void _showAudiobookPlayOnMenu(Audiobook audiobook) { final maProvider = context.read(); - final currentFavorite = track.favorite ?? false; + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + try { + maProvider.selectPlayer(player); + await maProvider.api?.playAudiobook(player.playerId, audiobook); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Playing ${audiobook.name} on ${player.name}'), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play audiobook: $e')), + ); + } + } + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + /// Toggle audiobook favorite status + Future _toggleAudiobookFavorite(Audiobook audiobook) async { + final maProvider = context.read(); + final currentFavorite = audiobook.favorite ?? false; try { bool success; @@ -1465,12 +3185,12 @@ class SearchScreenState extends State { if (currentFavorite) { // Remove from favorites int? libraryItemId; - if (track.provider == 'library') { - libraryItemId = int.tryParse(track.itemId); - } else if (track.providerMappings != null) { - final libraryMapping = track.providerMappings!.firstWhere( + if (audiobook.provider == 'library') { + libraryItemId = int.tryParse(audiobook.itemId); + } else if (audiobook.providerMappings != null) { + final libraryMapping = audiobook.providerMappings!.firstWhere( (m) => m.providerInstance == 'library', - orElse: () => track.providerMappings!.first, + orElse: () => audiobook.providerMappings!.first, ); if (libraryMapping.providerInstance == 'library') { libraryItemId = int.tryParse(libraryMapping.itemId); @@ -1479,7 +3199,7 @@ class SearchScreenState extends State { if (libraryItemId != null) { success = await maProvider.removeFromFavorites( - mediaType: 'track', + mediaType: 'audiobook', libraryItemId: libraryItemId, ); } else { @@ -1487,47 +3207,46 @@ class SearchScreenState extends State { } } else { // Add to favorites - String actualProvider = track.provider; - String actualItemId = track.itemId; + String actualProvider = audiobook.provider; + String actualItemId = audiobook.itemId; - if (track.providerMappings != null && track.providerMappings!.isNotEmpty) { - final mapping = track.providerMappings!.firstWhere( + if (audiobook.providerMappings != null && audiobook.providerMappings!.isNotEmpty) { + final mapping = audiobook.providerMappings!.firstWhere( (m) => m.available && m.providerInstance != 'library', - orElse: () => track.providerMappings!.firstWhere( + orElse: () => audiobook.providerMappings!.firstWhere( (m) => m.available, - orElse: () => track.providerMappings!.first, + orElse: () => audiobook.providerMappings!.first, ), ); - actualProvider = mapping.providerInstance; + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; } success = await maProvider.addToFavorites( - mediaType: 'track', + mediaType: 'audiobook', provider: actualProvider, itemId: actualItemId, ); } if (success && mounted) { - // Update the track's favorite state in search results setState(() { - final tracks = _searchResults['tracks'] as List?; - if (tracks != null) { - final index = tracks.indexWhere((t) => (t.uri ?? t.itemId) == (track.uri ?? track.itemId)); + final audiobooks = _searchResults['audiobooks'] as List?; + if (audiobooks != null) { + final index = audiobooks.indexWhere((a) => (a.uri ?? a.itemId) == (audiobook.uri ?? audiobook.itemId)); if (index != -1) { - // Create updated track with new favorite state - final updatedTrack = Track.fromJson({ - ...tracks[index].toJson(), + // Create updated audiobook with new favorite state + final updatedAudiobook = Audiobook.fromJson({ + ...audiobooks[index].toJson(), 'favorite': !currentFavorite, }); - tracks[index] = updatedTrack; + audiobooks[index] = updatedAudiobook; } } }); } } catch (e) { - _logger.log('Error toggling favorite: $e'); + _logger.log('Error toggling audiobook favorite: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to update favorite: $e')), @@ -1536,9 +3255,220 @@ class SearchScreenState extends State { } } + /// Show player picker for radio station + void _showRadioPlayOnMenu(MediaItem radio) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + try { + maProvider.selectPlayer(player); + await maProvider.api?.playRadioStation(player.playerId, radio); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Playing ${radio.name} on ${player.name}'), + duration: const Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to play radio station: $e')), + ); + } + } + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + + /// Play podcast (navigate to detail screen where episodes can be played) + Future _playPodcast(MediaItem podcast) async { + // For podcasts, we navigate to the detail screen where episodes can be selected + final maProvider = context.read(); + final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 128); + updateAdaptiveColorsFromImage(context, imageUrl); + + if (mounted) { + Navigator.push( + context, + FadeSlidePageRoute( + child: PodcastDetailScreen( + podcast: podcast, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + } + + /// Show player picker for podcast + void _showPodcastPlayOnMenu(MediaItem podcast) { + final maProvider = context.read(); + + GlobalPlayerOverlay.hidePlayer(); + + showPlayerPickerSheet( + context: context, + title: S.of(context)!.playOn, + players: maProvider.availablePlayers, + selectedPlayer: maProvider.selectedPlayer, + onPlayerSelected: (player) async { + // Select the player, then navigate to podcast detail + maProvider.selectPlayer(player); + final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 128); + updateAdaptiveColorsFromImage(context, imageUrl); + + if (mounted) { + Navigator.push( + context, + FadeSlidePageRoute( + child: PodcastDetailScreen( + podcast: podcast, + heroTagSuffix: 'search', + initialImageUrl: imageUrl, + ), + ), + ); + } + }, + ).whenComplete(() { + GlobalPlayerOverlay.showPlayer(); + }); + } + String _formatDuration(Duration duration) { final minutes = duration.inMinutes; final seconds = duration.inSeconds % 60; return '$minutes:${seconds.toString().padLeft(2, '0')}'; } + + /// Build a colored type pill for media type identification in search results + Widget _buildTypePill(String type, ColorScheme colorScheme) { + Color backgroundColor; + Color textColor; + String label; + + switch (type) { + case 'track': + backgroundColor = Colors.blue.shade100; + textColor = Colors.blue.shade800; + label = S.of(context)!.trackSingular; + break; + case 'album': + backgroundColor = Colors.purple.shade100; + textColor = Colors.purple.shade800; + label = S.of(context)!.albumSingular; + break; + case 'artist': + backgroundColor = Colors.pink.shade100; + textColor = Colors.pink.shade800; + label = S.of(context)!.artist; + break; + case 'playlist': + backgroundColor = Colors.teal.shade100; + textColor = Colors.teal.shade800; + label = S.of(context)!.playlist; + break; + case 'audiobook': + backgroundColor = Colors.orange.shade100; + textColor = Colors.orange.shade800; + label = S.of(context)!.audiobookSingular; + break; + case 'radio': + backgroundColor = Colors.red.shade100; + textColor = Colors.red.shade800; + label = S.of(context)!.radio; + break; + case 'podcast': + backgroundColor = Colors.green.shade100; + textColor = Colors.green.shade800; + label = S.of(context)!.podcastSingular; + break; + default: + backgroundColor = colorScheme.surfaceVariant; + textColor = colorScheme.onSurfaceVariant; + label = type; + } + + // For dark mode, use darker variants + final isDark = Theme.of(context).brightness == Brightness.dark; + if (isDark) { + switch (type) { + case 'track': + backgroundColor = Colors.blue.shade900.withOpacity(0.5); + textColor = Colors.blue.shade200; + break; + case 'album': + backgroundColor = Colors.purple.shade900.withOpacity(0.5); + textColor = Colors.purple.shade200; + break; + case 'artist': + backgroundColor = Colors.pink.shade900.withOpacity(0.5); + textColor = Colors.pink.shade200; + break; + case 'playlist': + backgroundColor = Colors.teal.shade900.withOpacity(0.5); + textColor = Colors.teal.shade200; + break; + case 'audiobook': + backgroundColor = Colors.orange.shade900.withOpacity(0.5); + textColor = Colors.orange.shade200; + break; + case 'radio': + backgroundColor = Colors.red.shade900.withOpacity(0.5); + textColor = Colors.red.shade200; + break; + case 'podcast': + backgroundColor = Colors.green.shade900.withOpacity(0.5); + textColor = Colors.green.shade200; + break; + } + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + color: textColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// Fast settling physics for horizontal page swipes. +/// Reduces the time the page animation takes to settle, so vertical scrolling +/// within the page becomes responsive sooner after a horizontal swipe. +class _FastPageScrollPhysics extends PageScrollPhysics { + const _FastPageScrollPhysics({super.parent}); + + @override + _FastPageScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _FastPageScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => const SpringDescription( + mass: 50, // Lower mass = faster movement + stiffness: 500, // Higher stiffness = snappier + damping: 1.0, // Critical damping for no overshoot + ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a7348ee9..4c289a10 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -29,6 +29,9 @@ class _SettingsScreenState extends State { 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,8 +44,11 @@ class _SettingsScreenState extends State { // Player settings bool _preferLocalPlayer = false; bool _smartSortPlayers = false; + bool _volumePrecisionMode = true; // Hint settings bool _showHints = true; + // Library settings + bool _showOnlyArtistsWithAlbums = false; @override void initState() { @@ -68,6 +74,9 @@ class _SettingsScreenState extends State { 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(); @@ -90,10 +99,14 @@ class _SettingsScreenState extends State { // 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(); + // Load library settings + final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums(); + if (mounted) { setState(() { _showRecentAlbums = showRecent; @@ -102,6 +115,9 @@ class _SettingsScreenState extends State { _showFavoriteAlbums = showFavAlbums; _showFavoriteArtists = showFavArtists; _showFavoriteTracks = showFavTracks; + _showFavoritePlaylists = showFavPlaylists; + _showFavoriteRadioStations = showFavRadio; + _showFavoritePodcasts = showFavPodcasts; _showContinueListeningAudiobooks = showContinueAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverSeries = showDiscSeries; @@ -110,7 +126,9 @@ class _SettingsScreenState extends State { _libraryEnabled = libraryEnabled; _preferLocalPlayer = preferLocal; _smartSortPlayers = smartSort; + _volumePrecisionMode = volumePrecision; _showHints = showHints; + _showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums; }); } } @@ -144,6 +162,12 @@ class _SettingsScreenState extends State { 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': ''}; } @@ -170,6 +194,12 @@ class _SettingsScreenState extends State { 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; } @@ -215,6 +245,18 @@ class _SettingsScreenState extends State { _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; } }); } @@ -281,13 +323,26 @@ class _SettingsScreenState extends State { 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 @@ -629,6 +684,15 @@ class _SettingsScreenState extends State { Navigator.pop(dialogContext); }, ), + RadioListTile( + title: const Text('Français'), + value: 'fr', + groupValue: localeProvider.locale?.languageCode, + onChanged: (value) { + localeProvider.setLocale(const Locale('fr')); + Navigator.pop(dialogContext); + }, + ), ], ), ); @@ -702,6 +766,31 @@ class _SettingsScreenState extends State { 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), @@ -745,6 +834,49 @@ class _SettingsScreenState extends State { const SizedBox(height: 32), + // Library section + Text( + S.of(context)!.library, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onBackground, + fontWeight: FontWeight.bold, + ), + ), + 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: SwitchListTile( + title: Text( + 'Show only artists with albums', + style: TextStyle(color: colorScheme.onSurface), + ), + subtitle: Text( + 'Hide artists that have no albums in your library', + style: TextStyle( + color: colorScheme.onSurface.withOpacity(0.6), + fontSize: 12, + ), + ), + value: _showOnlyArtistsWithAlbums, + onChanged: (value) async { + setState(() => _showOnlyArtistsWithAlbums = value); + await SettingsService.setShowOnlyArtistsWithAlbums(value); + // Force sync library to apply the new filter at API level + if (mounted) { + context.read().forceLibrarySync(); + } + }, + activeColor: colorScheme.primary, + contentPadding: EdgeInsets.zero, + ), + ), + + const SizedBox(height: 32), + // Home Screen section Text( S.of(context)!.homeScreen, diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 7e6d1e8b..0b586448 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:just_audio/just_audio.dart'; @@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler { final AuthManager authManager; final _logger = DebugLogger(); + // Stream subscriptions for proper cleanup + StreamSubscription? _interruptionSubscription; + StreamSubscription? _becomingNoisySubscription; + StreamSubscription? _playbackEventSubscription; + StreamSubscription? _currentIndexSubscription; + // Track current metadata separately from what's in the notification // This allows us to update the notification when metadata arrives late MediaItem? _currentMediaItem; @@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler { await session.configure(const AudioSessionConfiguration.music()); // Handle audio interruptions - session.interruptionEventStream.listen((event) { + _interruptionSubscription = session.interruptionEventStream.listen((event) { if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: @@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler { }); // Handle becoming noisy (headphones unplugged) - session.becomingNoisyEventStream.listen((_) { + _becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) { pause(); }); // Broadcast playback state changes - _player.playbackEventStream.listen(_broadcastState); + _playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState); // Broadcast current media item changes - _player.currentIndexStream.listen((_) { + _currentIndexSubscription = _player.currentIndexStream.listen((_) { if (_currentMediaItem != null) { mediaItem.add(_currentMediaItem); } @@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler { Stream get durationStream => _player.durationStream; MediaItem? get currentMediaItem => _currentMediaItem; + + /// Dispose of resources and cancel all subscriptions + Future dispose() async { + await _interruptionSubscription?.cancel(); + await _becomingNoisySubscription?.cancel(); + await _playbackEventSubscription?.cancel(); + await _currentIndexSubscription?.cancel(); + await _player.dispose(); + } } diff --git a/lib/services/auth/auth_manager.dart b/lib/services/auth/auth_manager.dart index 2bebf188..43bb559c 100644 --- a/lib/services/auth/auth_manager.dart +++ b/lib/services/auth/auth_manager.dart @@ -100,7 +100,8 @@ class AuthManager { host.startsWith('172.31.') || host == 'localhost' || host.startsWith('127.') || - host.endsWith('.local'); + host.endsWith('.local') || + host.endsWith('.ts.net'); } /// Try to detect auth strategy for a given URL diff --git a/lib/services/cache_service.dart b/lib/services/cache_service.dart index cf796196..fefc17b1 100644 --- a/lib/services/cache_service.dart +++ b/lib/services/cache_service.dart @@ -11,13 +11,24 @@ class CacheService { final DebugLogger _logger = DebugLogger(); bool _homeRowsLoaded = false; + // Cache size limits to prevent unbounded memory growth + static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached + static const int _maxSearchCacheSize = 20; // Max search queries cached + static const int _maxPlayerTrackCacheSize = 30; // Max player track associations + // Home screen row caching List? _cachedRecentAlbums; List? _cachedDiscoverArtists; List? _cachedDiscoverAlbums; + List? _cachedInProgressAudiobooks; + List? _cachedDiscoverAudiobooks; + List? _cachedDiscoverSeries; DateTime? _recentAlbumsLastFetched; DateTime? _discoverArtistsLastFetched; DateTime? _discoverAlbumsLastFetched; + DateTime? _inProgressAudiobooksLastFetched; + DateTime? _discoverAudiobooksLastFetched; + DateTime? _discoverSeriesLastFetched; // Detail screen caching final Map> _albumTracksCache = {}; @@ -38,6 +49,7 @@ class CacheService { // Player track cache (for smooth swipe transitions) final Map _playerTrackCache = {}; + final Map _playerTrackCacheTime = {}; // ============================================================================ // HOME SCREEN ROW CACHING @@ -106,14 +118,101 @@ 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? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks; + + /// Set cached in-progress audiobooks + void setCachedInProgressAudiobooks(List 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? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks; + + /// Set cached discover audiobooks + void setCachedDiscoverAudiobooks(List 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? getCachedDiscoverSeries() => _cachedDiscoverSeries; + + /// Set cached discover series + void setCachedDiscoverSeries(List 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'); } + /// Invalidate home album caches (call when albums are added/removed from library) + void invalidateHomeAlbumCaches() { + _cachedRecentAlbums = null; + _recentAlbumsLastFetched = null; + _cachedDiscoverAlbums = null; + _discoverAlbumsLastFetched = null; + _logger.log('🗑️ Home album caches invalidated'); + } + + /// Invalidate home artist caches (call when artists are added/removed from library) + void invalidateHomeArtistCaches() { + _cachedDiscoverArtists = null; + _discoverArtistsLastFetched = null; + _logger.log('🗑️ Home artist caches invalidated'); + } + // ============================================================================ // DETAIL SCREEN CACHING // ============================================================================ @@ -135,15 +234,23 @@ class CacheService { void setCachedAlbumTracks(String cacheKey, List tracks) { _albumTracksCache[cacheKey] = tracks; _albumTracksCacheTime[cacheKey] = DateTime.now(); + _evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize); _logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey'); } - /// Invalidate album tracks cache + /// Invalidate album tracks cache for a specific album void invalidateAlbumTracksCache(String albumId) { _albumTracksCache.remove(albumId); _albumTracksCacheTime.remove(albumId); } + /// Invalidate all album tracks caches (call when tracks are added/removed from library) + void invalidateAllAlbumTracksCaches() { + _albumTracksCache.clear(); + _albumTracksCacheTime.clear(); + _logger.log('🗑️ All album tracks caches invalidated'); + } + /// Check if playlist tracks cache is valid bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) { if (forceRefresh) return false; @@ -161,15 +268,23 @@ class CacheService { void setCachedPlaylistTracks(String cacheKey, List tracks) { _playlistTracksCache[cacheKey] = tracks; _playlistTracksCacheTime[cacheKey] = DateTime.now(); + _evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize); _logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey'); } - /// Invalidate playlist tracks cache + /// Invalidate playlist tracks cache for a specific playlist void invalidatePlaylistTracksCache(String playlistId) { _playlistTracksCache.remove(playlistId); _playlistTracksCacheTime.remove(playlistId); } + /// Invalidate all playlist tracks caches (call when tracks are added/removed from library) + void invalidateAllPlaylistTracksCaches() { + _playlistTracksCache.clear(); + _playlistTracksCacheTime.clear(); + _logger.log('🗑️ All playlist tracks caches invalidated'); + } + /// Check if artist albums cache is valid bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) { if (forceRefresh) return false; @@ -187,9 +302,17 @@ class CacheService { void setCachedArtistAlbums(String cacheKey, List albums) { _artistAlbumsCache[cacheKey] = albums; _artistAlbumsCacheTime[cacheKey] = DateTime.now(); + _evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize); _logger.log('✅ Cached ${albums.length} albums for artist $cacheKey'); } + /// Invalidate all artist albums caches (call when albums are added/removed from library) + void invalidateArtistAlbumsCache() { + _artistAlbumsCache.clear(); + _artistAlbumsCacheTime.clear(); + _logger.log('🗑️ Artist albums cache invalidated'); + } + // ============================================================================ // SEARCH CACHING // ============================================================================ @@ -211,9 +334,17 @@ class CacheService { void setCachedSearchResults(String cacheKey, Map> results) { _searchCache[cacheKey] = results; _searchCacheTime[cacheKey] = DateTime.now(); + _evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize); _logger.log('✅ Cached search results for "$cacheKey"'); } + /// Invalidate all search cache (call when library items change) + void invalidateSearchCache() { + _searchCache.clear(); + _searchCacheTime.clear(); + _logger.log('🗑️ Search cache invalidated'); + } + // ============================================================================ // PLAYER CACHING // ============================================================================ @@ -254,14 +385,20 @@ class CacheService { /// Get cached track for a player (used for smooth swipe transitions) Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId]; + /// Get all player IDs that have cached tracks + Iterable getAllCachedPlayerIds() => _playerTrackCache.keys; + /// Set cached track for a player void setCachedTrackForPlayer(String playerId, Track? track) { _playerTrackCache[playerId] = track; + _playerTrackCacheTime[playerId] = DateTime.now(); + _evictOldestEntries(_playerTrackCache, _playerTrackCacheTime, _maxPlayerTrackCacheSize); } /// Clear cached track for a player (e.g., when external source is active) void clearCachedTrackForPlayer(String playerId) { _playerTrackCache.remove(playerId); + _playerTrackCacheTime.remove(playerId); } // ============================================================================ @@ -279,6 +416,7 @@ class CacheService { _searchCache.clear(); _searchCacheTime.clear(); _playerTrackCache.clear(); + _playerTrackCacheTime.clear(); _logger.log('🗑️ All detail caches cleared'); } @@ -364,4 +502,34 @@ class CacheService { } }(); } + + // ============================================================================ + // LRU CACHE EVICTION + // ============================================================================ + + /// Evict oldest entries from cache maps to enforce size limit (LRU eviction) + /// Uses the timestamp map to determine which entries are oldest + void _evictOldestEntries( + Map cache, + Map cacheTime, + int maxSize, + ) { + if (cache.length <= maxSize) return; + + // Sort keys by timestamp (oldest first) + final sortedKeys = cacheTime.keys.toList() + ..sort((a, b) => (cacheTime[a] ?? DateTime.now()) + .compareTo(cacheTime[b] ?? DateTime.now())); + + // Remove oldest entries until we're at maxSize + final keysToRemove = sortedKeys.take(cache.length - maxSize); + for (final key in keysToRemove) { + cache.remove(key); + cacheTime.remove(key); + } + + if (keysToRemove.isNotEmpty) { + _logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries'); + } + } } diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index bdfb374a..23e55fdc 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -157,6 +157,16 @@ class DatabaseService { /// Clear all cached data (useful for logout) Future clearAllCache() => db.clearAllCache(); + /// Clear cached items of a specific type (e.g., 'album', 'artist') + /// Used before re-syncing to remove stale items + Future clearCacheForType(String itemType) => db.clearCache(itemType); + + /// Mark a specific cached item as deleted + /// Used when removing items from library for immediate database update + Future markCachedItemDeleted(String itemType, String itemId) { + return db.markItemsDeleted(itemType, [itemId]); + } + // ============================================ // Player Cache Convenience Methods // ============================================ @@ -252,4 +262,53 @@ class DatabaseService { /// Clear all search history Future clearSearchHistory() => db.clearSearchHistory(); + + // ============================================ + // Cast-to-Sendspin Mapping Methods + // ============================================ + + static const String _sendspinMappingType = 'cast_sendspin_mapping'; + + /// Save a Cast UUID to Sendspin ID mapping + Future saveCastToSendspinMapping(String castId, String sendspinId) { + _logger.log('💾 Persisting Cast->Sendspin mapping: $castId -> $sendspinId'); + return db.cacheItem( + itemType: _sendspinMappingType, + itemId: castId, + data: jsonEncode({'sendspinId': sendspinId}), + ); + } + + /// Get persisted Sendspin ID for a Cast UUID + Future getSendspinIdForCast(String castId) async { + final item = await db.getCachedItem(_sendspinMappingType, castId); + if (item == null) return null; + try { + final data = jsonDecode(item.data) as Map; + return data['sendspinId'] as String?; + } catch (e) { + _logger.log('Error decoding Sendspin mapping: $e'); + return null; + } + } + + /// Load all persisted Cast-to-Sendspin mappings + Future> getAllCastToSendspinMappings() async { + final items = await db.getCachedItems(_sendspinMappingType); + final mappings = {}; + for (final item in items) { + try { + final data = jsonDecode(item.data) as Map; + final sendspinId = data['sendspinId'] as String?; + if (sendspinId != null) { + // The itemId is the Cast UUID + mappings[item.itemId] = sendspinId; + } + } catch (e) { + _logger.log('Error decoding Sendspin mapping: $e'); + } + } + _logger.log('📦 Loaded ${mappings.length} Cast->Sendspin mappings from database'); + return mappings; + } } diff --git a/lib/services/hardware_volume_service.dart b/lib/services/hardware_volume_service.dart index 1be3b95f..6a9127d6 100644 --- a/lib/services/hardware_volume_service.dart +++ b/lib/services/hardware_volume_service.dart @@ -82,5 +82,9 @@ class HardwareVolumeService { } catch (e) { _logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e); } + + // Close StreamControllers to prevent memory leaks + await _volumeUpController.close(); + await _volumeDownController.close(); } } diff --git a/lib/services/local_player_service.dart b/lib/services/local_player_service.dart index c4fa0685..3eb6b831 100644 --- a/lib/services/local_player_service.dart +++ b/lib/services/local_player_service.dart @@ -81,10 +81,6 @@ class LocalPlayerService { return audioHandler.duration; } - bool get isInitialized { - return _isInitialized; - } - Future initialize() async { if (_isInitialized) return; diff --git a/lib/services/music_assistant_api.dart b/lib/services/music_assistant_api.dart index add09553..409e2171 100644 --- a/lib/services/music_assistant_api.dart +++ b/lib/services/music_assistant_api.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/io.dart'; -import 'package:stream_channel/stream_channel.dart'; import 'package:uuid/uuid.dart'; import '../constants/network.dart'; import '../constants/timings.dart' show Timings, LibraryConstants; @@ -14,8 +13,6 @@ import 'settings_service.dart'; import 'device_id_service.dart'; import 'retry_helper.dart'; import 'auth/auth_manager.dart'; -import 'remote/remote_access_manager.dart'; -import 'remote/transport.dart'; enum MAConnectionState { disconnected, @@ -93,18 +90,6 @@ class MusicAssistantAPI { try { _updateConnectionState(MAConnectionState.connecting); - // Check if RemoteAccessManager has an active WebRTC transport - // Don't rely on isRemoteMode state - check if transport exists and is connected - final remoteManager = RemoteAccessManager.instance; - if (remoteManager.transport != null && - remoteManager.transport!.state == TransportState.connected) { - _logger.log('Connection: Using Remote Access WebRTC transport'); - return await _connectViaRemoteTransport(remoteManager.transport!); - } - - // Normal WebSocket connection flow - _logger.log('Connection: Using direct WebSocket connection'); - // Load and cache custom port setting _cachedCustomPort = await SettingsService.getWebSocketPort(); @@ -253,60 +238,6 @@ class MusicAssistantAPI { } } - /// Connect using RemoteAccess WebRTC transport - /// This is a minimal integration point that allows using WebRTC instead of WebSocket - Future _connectViaRemoteTransport(ITransport transport) async { - try { - _logger.log('[Remote] Setting up WebRTC transport bridge'); - - // Create a custom channel that wraps the WebRTC transport - _channel = _TransportChannelAdapter(transport); - - // Wait for server info message - _connectionCompleter = Completer(); - - // Listen to messages from the transport - _channel!.stream.listen( - _handleMessage, - onError: (error) { - _logger.log('[Remote] Transport error: $error'); - _updateConnectionState(MAConnectionState.error); - _reconnect(); - }, - onDone: () { - _logger.log('[Remote] Transport connection closed'); - _updateConnectionState(MAConnectionState.disconnected); - if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { - _connectionCompleter!.completeError(Exception('Connection closed')); - } - _reconnect(); - }, - ); - - // Wait for server info message with timeout - await _connectionCompleter!.future.timeout( - Timings.connectionTimeout, - onTimeout: () { - throw Exception('Connection timeout - no server info received'); - }, - ); - - _logger.log('[Remote] Connected via WebRTC transport'); - if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) { - _connectionInProgress!.complete(); - } - _connectionInProgress = null; - } catch (e) { - _logger.log('[Remote] Connection failed: $e'); - _updateConnectionState(MAConnectionState.error); - if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) { - _connectionInProgress!.completeError(e); - } - _connectionInProgress = null; - rethrow; - } - } - void _handleMessage(dynamic message) { try { final data = jsonDecode(message as String) as Map; @@ -362,16 +293,10 @@ class MusicAssistantAPI { // Handle event final eventType = data['event'] as String?; if (eventType != null) { - _logger.log('Event received: $eventType'); - // Get the object_id (player_id for player events) final objectId = data['object_id'] as String?; final eventData = data['data'] as Map? ?? {}; - // Debug: Log full data for player events - if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') { - _logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)'); - } // Include object_id in event data so listeners can filter by player final enrichedData = { @@ -567,6 +492,227 @@ class MusicAssistantAPI { } } + Future> getRadioStations({ + int? limit, + int? offset, + bool? favoriteOnly, + }) async { + try { + final response = await _sendCommand( + 'music/radios/library_items', + args: { + if (limit != null) 'limit': limit, + if (offset != null) 'offset': offset, + if (favoriteOnly != null) 'favorite': favoriteOnly, + }, + ); + + final items = response['items'] as List? ?? response['result'] as List?; + if (items == null) return []; + + return items + .map((item) => MediaItem.fromJson(item as Map)) + .toList(); + } catch (e) { + _logger.log('Error getting radio stations: $e'); + return []; + } + } + + /// Search for radio stations globally (across all providers like TuneIn) + Future> searchRadioStations(String query, {int limit = 25}) async { + try { + final response = await _sendCommand( + 'music/search', + args: { + 'search_query': query, + 'media_types': ['radio'], + 'limit': limit, + }, + ); + + final result = response['result'] as Map?; + if (result == null) return []; + + // Radio results might be under 'radios' or 'radio' key + final radios = (result['radios'] as List?) ?? + (result['radio'] as List?) ?? + []; + + return radios + .map((item) => MediaItem.fromJson(item as Map)) + .toList(); + } catch (e) { + _logger.log('Error searching radio stations: $e'); + return []; + } + } + + // ============ PODCASTS ============ + + /// Get all podcasts from the library + Future> getPodcasts({ + int? limit, + int? offset, + bool? favoriteOnly, + }) async { + try { + final response = await _sendCommand( + 'music/podcasts/library_items', + args: { + if (limit != null) 'limit': limit, + if (offset != null) 'offset': offset, + if (favoriteOnly != null) 'favorite': favoriteOnly, + }, + ); + + final items = response['items'] as List? ?? response['result'] as List?; + if (items == null) return []; + + return items + .map((item) => MediaItem.fromJson(item as Map)) + .toList(); + } catch (e) { + _logger.log('Error getting podcasts: $e'); + return []; + } + } + + /// Get episodes for a specific podcast + Future> getPodcastEpisodes(String podcastId, { + String? provider, + int? limit, + int? offset, + }) async { + try { + final response = await _sendCommand( + 'music/podcasts/podcast_episodes', + args: { + 'item_id': podcastId, + if (provider != null) 'provider_instance_id_or_domain': provider, + }, + ); + + final episodes = response['result'] as List?; + if (episodes != null && episodes.isNotEmpty) { + return episodes + .map((item) => MediaItem.fromJson(item as Map)) + .toList(); + } + + return []; + } catch (e) { + _logger.log('Error getting podcast episodes: $e'); + return []; + } + } + + /// Get best available image URL for a podcast + /// Falls back to first episode's image if podcast image is unavailable or low quality + Future getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async { + // First try the podcast's own image + final podcastImage = getImageUrl(podcast, size: size); + + // Check if podcast has images in metadata + final images = podcast.metadata?['images'] as List?; + if (images != null && images.isNotEmpty) { + // Podcast has images, use them + return podcastImage; + } + + // No podcast image, try to get first episode's image + try { + final episodes = await getPodcastEpisodes( + podcast.itemId, + provider: podcast.provider, + ); + + if (episodes.isNotEmpty) { + final firstEpisode = episodes.first; + final episodeImage = getImageUrl(firstEpisode, size: size); + if (episodeImage != null) { + return episodeImage; + } + } + } catch (e) { + // Fall through to return podcast image + } + + // Fall back to podcast image even if it might be low quality + return podcastImage; + } + + /// Parse browse results into MediaItem episodes + List _parseBrowseEpisodes(List items) { + final episodes = []; + for (final item in items) { + if (item is Map) { + final name = item['name'] as String? ?? + item['label'] as String? ?? + 'Episode'; + final uri = item['uri'] as String? ?? item['path'] as String?; + final itemId = item['item_id'] as String? ?? uri ?? ''; + final provider = item['provider'] as String? ?? 'library'; + + // Parse media type from string + final mediaTypeStr = item['media_type'] as String?; + MediaType mediaType = MediaType.track; + if (mediaTypeStr != null) { + mediaType = MediaType.values.firstWhere( + (e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(), + orElse: () => MediaType.track, + ); + } + + // Get duration if available + Duration? duration; + final durationVal = item['duration']; + if (durationVal is int) { + duration = Duration(seconds: durationVal); + } else if (durationVal is double) { + duration = Duration(seconds: durationVal.toInt()); + } + + episodes.add(MediaItem( + itemId: itemId, + provider: provider, + name: name, + uri: uri, + metadata: item['metadata'] as Map?, + mediaType: mediaType, + duration: duration, + )); + } + } + return episodes; + } + + /// Play a podcast episode + Future playPodcastEpisode(String playerId, MediaItem episode) async { + try { + // Try episode.uri first, then construct from itemId + String uri; + if (episode.uri != null && episode.uri!.isNotEmpty) { + uri = episode.uri!; + } else { + // Construct URI based on media type + uri = 'library://podcast_episode/${episode.itemId}'; + } + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'replace', + }, + ); + } catch (e) { + _logger.log('Error playing podcast episode: $e'); + rethrow; + } + } + Future> getAudiobooks({ int? limit, int? offset, @@ -580,7 +726,6 @@ class MusicAssistantAPI { // If specific libraries are enabled, use browse API for filtering if (enabledLibraries != null && enabledLibraries.isNotEmpty) { - _logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)'); return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly); } @@ -593,8 +738,6 @@ class MusicAssistantAPI { if (authorId != null) 'author_id': authorId, }; - _logger.log('📚 Calling music/audiobooks/library_items with args: $args'); - final response = await _sendCommand( 'music/audiobooks/library_items', args: args, @@ -602,65 +745,21 @@ class MusicAssistantAPI { // Check for error in response if (response.containsKey('error_code')) { - _logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}'); return []; } final items = response['result'] as List?; - if (items == null) { - _logger.log('📚 Audiobooks: result is null'); - return []; - } - - _logger.log('📚 Audiobooks: found ${items.length} items from API'); - - // Log first item's structure including metadata to find chapters/series - if (items.isNotEmpty) { - final firstItem = items.first as Map; - _logger.log('📚 First item keys: ${firstItem.keys.toList()}'); - _logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}'); - - // Log metadata contents - this is where chapters/series info likely lives - if (firstItem.containsKey('metadata')) { - final metadata = firstItem['metadata'] as Map?; - if (metadata != null) { - _logger.log('📚 METADATA keys: ${metadata.keys.toList()}'); - for (final key in metadata.keys) { - final value = metadata[key]; - if (value is List) { - _logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}'); - } else if (value is Map) { - _logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}'); - } else if (value != null) { - _logger.log('📚 metadata[$key]: $value'); - } - } - } - } - - // Check for chapters at top level too - if (firstItem.containsKey('chapters')) { - _logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}'); - } - } + if (items == null) return []; final audiobooks = []; - final parseErrors = []; - for (int i = 0; i < items.length; i++) { try { final book = Audiobook.fromJson(items[i] as Map); audiobooks.add(book); } catch (e) { - parseErrors.add('Item $i: $e'); + // Skip items that fail to parse } } - - if (parseErrors.isNotEmpty) { - _logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}'); - } - - _logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks'); return audiobooks; } catch (e, stack) { _logger.log('Error getting audiobooks: $e'); @@ -760,6 +859,26 @@ class MusicAssistantAPI { } } + /// Play a radio station + Future playRadioStation(String playerId, MediaItem station) async { + try { + final uri = station.uri ?? 'library://radio/${station.itemId}'; + _logger.log('📻 Playing radio station: ${station.name} with URI: $uri'); + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'replace', + }, + ); + } catch (e) { + _logger.log('Error playing radio station: $e'); + rethrow; + } + } + /// Get full audiobook details including chapters Future getAudiobookDetails(String provider, String itemId) async { try { @@ -1120,6 +1239,7 @@ class MusicAssistantAPI { ); final items = response['result'] as List?; + _logger.log('📋 recently_played_items returned ${items?.length ?? 0} items'); if (items == null || items.isEmpty) { _logger.log('⚠️ No recently played tracks found'); return []; @@ -1129,6 +1249,8 @@ class MusicAssistantAPI { final trackUris = []; for (final item in items) { final trackUri = (item as Map)['uri'] as String?; + final name = (item as Map)['name'] as String?; + _logger.log(' 📀 Track: $name'); if (trackUri != null) { trackUris.add(trackUri); } @@ -1158,12 +1280,17 @@ class MusicAssistantAPI { try { final fullTrack = trackResponse['result'] as Map?; + final trackName = fullTrack?['name'] as String?; final albumData = fullTrack?['album'] as Map?; final albumUri = albumData?['uri'] as String?; + final albumName = albumData?['name'] as String?; if (albumUri != null && !seenAlbumUris.contains(albumUri)) { seenAlbumUris.add(albumUri); albumUris.add(albumUri); + _logger.log(' 📀 Found album: $albumName from track: $trackName'); + } else if (albumUri == null) { + _logger.log(' ⚠️ Track "$trackName" has no album'); } } catch (_) { continue; @@ -1432,20 +1559,38 @@ class MusicAssistantAPI { /// Remove item from favorites /// Requires the library_item_id (the numeric ID in the MA library) Future removeFromFavorites(String mediaType, int libraryItemId) async { - try { - _logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId'); + await _sendCommand( + 'music/favorites/remove_item', + args: { + 'media_type': mediaType, + 'library_item_id': libraryItemId, + }, + ); + } - await _sendCommand( - 'music/favorites/remove_item', - args: { - 'media_type': mediaType, - 'library_item_id': libraryItemId, - }, - ); - } catch (e) { - _logger.log('Error removing from favorites: $e'); - rethrow; - } + // Library + /// Add item to library using URI format + /// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi" + Future addItemToLibrary(String mediaType, String itemId, String provider) async { + final uri = '$provider://$mediaType/$itemId'; + await _sendCommand( + 'music/library/add_item', + args: { + 'item': uri, + }, + ); + } + + /// Remove item from library + /// Requires the library_item_id (the numeric ID in the MA library) + Future removeItemFromLibrary(String mediaType, int libraryItemId) async { + await _sendCommand( + 'music/library/remove_item', + args: { + 'media_type': mediaType, + 'library_item_id': libraryItemId, + }, + ); } // Search @@ -1468,7 +1613,7 @@ class MusicAssistantAPI { final result = searchResponse['result'] as Map?; if (result == null) { - return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []}; } // Parse results from the search response @@ -1497,6 +1642,16 @@ class MusicAssistantAPI { .toList() ?? []; + final radios = (result['radios'] as List?) + ?.map((item) => MediaItem.fromJson(item as Map)) + .toList() ?? + []; + + final podcasts = (result['podcasts'] as List?) + ?.map((item) => MediaItem.fromJson(item as Map)) + .toList() ?? + []; + // Deduplicate results by name // MA returns both library items and provider items for the same content // Prefer library items (provider='library') as they have all provider mappings @@ -1506,18 +1661,55 @@ class MusicAssistantAPI { 'tracks': _deduplicateResults(tracks), 'playlists': _deduplicateResults(playlists), 'audiobooks': _deduplicateResults(audiobooks), + 'radios': _deduplicateResults(radios), + 'podcasts': _deduplicateResults(podcasts), }; } catch (e) { _logger.log('Error searching: $e'); - return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []}; } }, ).catchError((e) { _logger.log('Error searching after retries: $e'); - return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; + return >{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []}; }); } + /// Search for podcasts globally (across all providers) + Future> searchPodcasts(String query, {int limit = 25}) async { + try { + _logger.log('🎙️ Searching podcasts for: $query'); + final response = await _sendCommand( + 'music/search', + args: { + 'search_query': query, + 'media_types': ['podcast'], + 'limit': limit, + }, + ); + + final result = response['result'] as Map?; + if (result == null) { + _logger.log('🎙️ Podcast search: no result'); + return []; + } + + // Podcast results might be under 'podcasts' or 'podcast' key + final podcasts = (result['podcasts'] as List?) ?? + (result['podcast'] as List?) ?? + []; + + _logger.log('🎙️ Podcast search found ${podcasts.length} results'); + + return podcasts + .map((item) => MediaItem.fromJson(item as Map)) + .toList(); + } catch (e) { + _logger.log('🎙️ Error searching podcasts: $e'); + return []; + } + } + /// Deduplicate search results by name (case-insensitive) /// Prefers library items (provider='library') over provider-specific items /// since library items have complete provider mappings @@ -1703,6 +1895,86 @@ class MusicAssistantAPI { } } + /// Play album on player + Future playAlbum(String playerId, Album album) async { + try { + final uri = album.uri ?? 'library://album/${album.itemId}'; + _logger.log('💿 Playing album: ${album.name} with URI: $uri'); + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'replace', + }, + ); + } catch (e) { + _logger.log('Error playing album: $e'); + rethrow; + } + } + + /// Add album tracks to queue + Future addAlbumToQueue(String playerId, Album album) async { + try { + final uri = album.uri ?? 'library://album/${album.itemId}'; + _logger.log('💿 Adding album to queue: ${album.name} with URI: $uri'); + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'add', + }, + ); + } catch (e) { + _logger.log('Error adding album to queue: $e'); + rethrow; + } + } + + /// Add artist tracks to queue + Future addArtistToQueue(String playerId, Artist artist) async { + try { + final uri = artist.uri ?? 'library://artist/${artist.itemId}'; + _logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri'); + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'add', + }, + ); + } catch (e) { + _logger.log('Error adding artist to queue: $e'); + rethrow; + } + } + + /// Play playlist on player + Future playPlaylist(String playerId, Playlist playlist) async { + try { + final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}'; + _logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri'); + + await _sendCommand( + 'player_queues/play_media', + args: { + 'queue_id': playerId, + 'media': [uri], + 'option': 'replace', + }, + ); + } catch (e) { + _logger.log('Error playing playlist: $e'); + rethrow; + } + } + /// Play multiple tracks via queue /// If clearQueue is true, replaces the queue (default behavior) /// If startIndex is provided, only tracks from that index onwards will be queued @@ -2181,6 +2453,71 @@ class MusicAssistantAPI { } } + /// Delete an item from the queue + Future 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 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 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 queueCommandClear(String queueId) async { + try { + await _sendCommand( + 'player_queues/clear', + args: { + 'queue_id': queueId, + }, + ); + } catch (e) { + _logger.log('Error clearing queue: $e'); + rethrow; + } + } + // ============================================================================ // BUILT-IN PLAYER MANAGEMENT // ============================================================================ @@ -2208,6 +2545,39 @@ class MusicAssistantAPI { return _eventStreams['player_added']!.stream; } + /// Stream of media_item_added events (for refreshing library when items are added) + Stream> get mediaItemAddedEvents { + if (!_eventStreams.containsKey('media_item_added')) { + _eventStreams['media_item_added'] = StreamController>.broadcast(); + } + return _eventStreams['media_item_added']!.stream; + } + + /// Stream of media_item_deleted events (for refreshing library when items are removed) + Stream> get mediaItemDeletedEvents { + if (!_eventStreams.containsKey('media_item_deleted')) { + _eventStreams['media_item_deleted'] = StreamController>.broadcast(); + } + return _eventStreams['media_item_deleted']!.stream; + } + + /// Stream of queue_updated events (for queue metadata changes) + Stream> get queueUpdatedEvents { + if (!_eventStreams.containsKey('queue_updated')) { + _eventStreams['queue_updated'] = StreamController>.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> get queueItemsUpdatedEvents { + if (!_eventStreams.containsKey('queue_items_updated')) { + _eventStreams['queue_items_updated'] = StreamController>.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 @@ -2609,6 +2979,7 @@ class MusicAssistantAPI { } } } catch (e) { + _logger.log('⚠️ Error parsing stream URI "$uri": $e'); } } @@ -2688,6 +3059,57 @@ class MusicAssistantAPI { return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}'; } + // ==================== iTunes Artwork Lookup ==================== + + /// Search iTunes for a podcast and return high-res artwork URL + /// Returns null if not found or on error + Future getITunesPodcastArtwork(String podcastName) async { + try { + final searchTerm = Uri.encodeComponent(podcastName); + final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1'; + + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 10); + + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + + if (response.statusCode != 200) { + _logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}'); + return null; + } + + final body = await response.transform(utf8.decoder).join(); + final json = jsonDecode(body) as Map; + + final results = json['results'] as List?; + if (results == null || results.isEmpty) { + _logger.log('🎨 No iTunes results for "$podcastName"'); + return null; + } + + final podcast = results.first as Map; + var artworkUrl = podcast['artworkUrl600'] as String?; + + if (artworkUrl == null) { + artworkUrl = podcast['artworkUrl100'] as String?; + } + + if (artworkUrl != null) { + // Use 800x800 - good quality without being excessive (was 1400x1400) + artworkUrl = artworkUrl + .replaceAll('600x600', '800x800') + .replaceAll('100x100', '800x800'); + _logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl'); + } + + return artworkUrl; + } catch (e) { + _logger.log('🎨 iTunes lookup error for "$podcastName": $e'); + return null; + } + } + // ==================== Authentication Methods ==================== /// Authenticate the WebSocket session with a token @@ -2977,6 +3399,17 @@ class MusicAssistantAPI { _updateConnectionState(MAConnectionState.disconnected); await _channel?.sink.close(); _channel = null; + // Complete all pending requests with an error before clearing + _cancelPendingRequests('Connection disconnected'); + } + + /// Cancel all pending requests with an error message + void _cancelPendingRequests(String reason) { + for (final entry in _pendingRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError(Exception(reason)); + } + } _pendingRequests.clear(); } @@ -2996,73 +3429,3 @@ class MusicAssistantAPI { _eventStreams.clear(); } } - -/// Adapter to make ITransport look like a WebSocketChannel -/// This allows the existing MusicAssistantAPI code to work with WebRTC transport -class _TransportChannelAdapter extends StreamChannelMixin implements WebSocketChannel { - final ITransport _transport; - final StreamController _messageController = StreamController.broadcast(); - - _TransportChannelAdapter(this._transport) { - // Forward messages from transport to our stream - _transport.messageStream.listen( - (message) => _messageController.add(message), - onError: (error) => _messageController.addError(error), - onDone: () => _messageController.close(), - ); - } - - @override - Stream get stream => _messageController.stream; - - @override - WebSocketSink get sink => _TransportSinkAdapter(_transport); - - @override - int? get closeCode => null; - - @override - String? get closeReason => null; - - @override - String? get protocol => null; - - @override - Future get ready => Future.value(); -} - -/// Adapter to make ITransport send operations work like WebSocketSink -class _TransportSinkAdapter implements WebSocketSink { - final ITransport _transport; - - _TransportSinkAdapter(this._transport); - - @override - void add(dynamic data) { - if (data is String) { - _transport.send(data); - } else { - throw ArgumentError('Only String data is supported'); - } - } - - @override - void addError(Object error, [StackTrace? stackTrace]) { - // Not supported for transport - } - - @override - Future addStream(Stream stream) async { - await for (final data in stream) { - add(data); - } - } - - @override - Future close([int? closeCode, String? closeReason]) async { - _transport.disconnect(); - } - - @override - Future get done => Future.value(); -} diff --git a/lib/services/pcm_audio_player.dart b/lib/services/pcm_audio_player.dart index af7e3574..c384eae3 100644 --- a/lib/services/pcm_audio_player.dart +++ b/lib/services/pcm_audio_player.dart @@ -83,6 +83,11 @@ class PcmAudioPlayer { // Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response static const int _feedThreshold = 5000; + // Maximum buffer size to prevent memory overflow (~10 seconds of audio) + // At 48kHz stereo 16-bit: 192KB/sec, so ~2MB for 10 seconds + // Each chunk is ~4KB, so max ~500 chunks + static const int _maxBufferChunks = 500; + // Stats int _framesPlayed = 0; int _bytesPlayed = 0; @@ -201,6 +206,16 @@ class PcmAudioPlayer { return true; } + /// Safely add audio data to buffer with overflow protection + void _addToBuffer(Uint8List audioData) { + // If buffer is full, drop oldest chunks to make room + while (_audioBuffer.length >= _maxBufferChunks) { + _audioBuffer.removeAt(0); + _logger.log('⚠️ PcmAudioPlayer: Buffer overflow - dropping oldest chunk'); + } + _audioBuffer.add(audioData); + } + /// Handle incoming audio data from the stream void _onAudioData(Uint8List audioData) { // Ignore data if in error state @@ -215,7 +230,7 @@ class PcmAudioPlayer { if (_state == PcmPlayerState.paused && !_isAutoRecovering) { _logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery'); _isAutoRecovering = true; - _audioBuffer.add(audioData); + _addToBuffer(audioData); // Trigger async recovery _autoRecoverFromPause(); @@ -224,7 +239,7 @@ class PcmAudioPlayer { // If already recovering or in transitional state, buffer the data if (_isAutoRecovering) { - _audioBuffer.add(audioData); + _addToBuffer(audioData); return; } @@ -232,8 +247,8 @@ class PcmAudioPlayer { return; } - // Add to buffer - _audioBuffer.add(audioData); + // Add to buffer with overflow protection + _addToBuffer(audioData); // Start playback if not already started if (!_isStarted && _state == PcmPlayerState.ready) { diff --git a/lib/services/position_tracker.dart b/lib/services/position_tracker.dart index cf9d5bd2..da39b6cb 100644 --- a/lib/services/position_tracker.dart +++ b/lib/services/position_tracker.dart @@ -24,6 +24,10 @@ class PositionTracker { double _anchorPosition = 0.0; // Position in seconds at anchor time DateTime _anchorTime = DateTime.now(); // When we set the anchor + // Maximum time to interpolate from a stale anchor before capping + // After this, we stop adding time to prevent indefinite drift + static const Duration _maxAnchorAge = Duration(seconds: 30); + // Interpolation timer Timer? _interpolationTimer; @@ -43,7 +47,13 @@ class PositionTracker { } final now = DateTime.now(); - final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0; + final anchorAge = now.difference(_anchorTime); + + // Cap interpolation at max anchor age to prevent indefinite drift + // If anchor is stale, return last known position (anchor + max age) + final elapsed = anchorAge > _maxAnchorAge + ? _maxAnchorAge.inMilliseconds / 1000.0 + : anchorAge.inMilliseconds / 1000.0; final interpolated = _anchorPosition + elapsed; // Cap at duration to prevent overflow @@ -61,7 +71,12 @@ class PositionTracker { } final now = DateTime.now(); - final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0; + final anchorAge = now.difference(_anchorTime); + + // Cap interpolation at max anchor age to prevent indefinite drift + final elapsed = anchorAge > _maxAnchorAge + ? _maxAnchorAge.inMilliseconds / 1000.0 + : anchorAge.inMilliseconds / 1000.0; final interpolated = _anchorPosition + elapsed; // Cap at duration diff --git a/lib/services/recently_played_service.dart b/lib/services/recently_played_service.dart index 3e831592..6eb57c1d 100644 --- a/lib/services/recently_played_service.dart +++ b/lib/services/recently_played_service.dart @@ -151,7 +151,10 @@ class RecentlyPlayedService { if (item.metadata != null) { try { metadata = jsonDecode(item.metadata!) as Map; - } catch (_) {} + } catch (e) { + // Corrupted metadata JSON - log and continue with null metadata + _logger.log('⚠️ Error parsing album metadata for ${item.mediaId}: $e'); + } } // Build metadata with images if available (for instant artwork display) @@ -203,7 +206,10 @@ class RecentlyPlayedService { if (item.metadata != null) { try { metadata = jsonDecode(item.metadata!) as Map; - } catch (_) {} + } catch (e) { + // Corrupted metadata JSON - log and continue with null metadata + _logger.log('⚠️ Error parsing audiobook metadata for ${item.mediaId}: $e'); + } } audiobooks.add(Audiobook( diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart new file mode 100644 index 00000000..30055111 --- /dev/null +++ b/lib/services/secure_storage_service.dart @@ -0,0 +1,148 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +/// Service for securely storing sensitive data like passwords and tokens. +/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). +class SecureStorageService { + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + ); + + // Keys for secure storage + static const String _keyAuthToken = 'secure_auth_token'; + static const String _keyMaAuthToken = 'secure_ma_auth_token'; + static const String _keyAuthCredentials = 'secure_auth_credentials'; + static const String _keyPassword = 'secure_password'; + static const String _keyAbsApiToken = 'secure_abs_api_token'; + + // Auth Token (for stream requests) + static Future getAuthToken() async { + return await _storage.read(key: _keyAuthToken); + } + + static Future setAuthToken(String? token) async { + if (token == null || token.isEmpty) { + await _storage.delete(key: _keyAuthToken); + } else { + await _storage.write(key: _keyAuthToken, value: token); + } + } + + // Music Assistant Native Auth Token (long-lived token) + static Future getMaAuthToken() async { + return await _storage.read(key: _keyMaAuthToken); + } + + static Future setMaAuthToken(String? token) async { + if (token == null || token.isEmpty) { + await _storage.delete(key: _keyMaAuthToken); + } else { + await _storage.write(key: _keyMaAuthToken, value: token); + } + } + + static Future clearMaAuthToken() async { + await _storage.delete(key: _keyMaAuthToken); + } + + // Auth Credentials (serialized auth strategy credentials) + static Future?> getAuthCredentials() async { + final json = await _storage.read(key: _keyAuthCredentials); + if (json == null) return null; + try { + return jsonDecode(json) as Map; + } catch (e) { + return null; + } + } + + static Future setAuthCredentials(Map credentials) async { + await _storage.write(key: _keyAuthCredentials, value: jsonEncode(credentials)); + } + + static Future clearAuthCredentials() async { + await _storage.delete(key: _keyAuthCredentials); + } + + // Password + static Future getPassword() async { + return await _storage.read(key: _keyPassword); + } + + static Future setPassword(String? password) async { + if (password == null || password.isEmpty) { + await _storage.delete(key: _keyPassword); + } else { + await _storage.write(key: _keyPassword, value: password); + } + } + + // Audiobookshelf API Token + static Future getAbsApiToken() async { + return await _storage.read(key: _keyAbsApiToken); + } + + static Future setAbsApiToken(String? token) async { + if (token == null || token.isEmpty) { + await _storage.delete(key: _keyAbsApiToken); + } else { + await _storage.write(key: _keyAbsApiToken, value: token); + } + } + + /// Clear all secure storage (used during logout) + static Future clearAll() async { + await _storage.deleteAll(); + } + + /// Migrate credentials from SharedPreferences to secure storage. + /// Call this once during app upgrade to migrate existing users. + static Future migrateFromSharedPreferences() async { + // Import SharedPreferences for migration + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + // Migrate auth token + final authToken = prefs.getString('auth_token'); + if (authToken != null && authToken.isNotEmpty) { + await setAuthToken(authToken); + await prefs.remove('auth_token'); + } + + // Migrate MA auth token + final maAuthToken = prefs.getString('ma_auth_token'); + if (maAuthToken != null && maAuthToken.isNotEmpty) { + await setMaAuthToken(maAuthToken); + await prefs.remove('ma_auth_token'); + } + + // Migrate auth credentials + final authCredentials = prefs.getString('auth_credentials'); + if (authCredentials != null && authCredentials.isNotEmpty) { + try { + final decoded = jsonDecode(authCredentials) as Map; + await setAuthCredentials(decoded); + await prefs.remove('auth_credentials'); + } catch (_) { + // Invalid JSON, just remove it + await prefs.remove('auth_credentials'); + } + } + + // Migrate password + final password = prefs.getString('password'); + if (password != null && password.isNotEmpty) { + await setPassword(password); + await prefs.remove('password'); + } + + // Migrate ABS API token + final absApiToken = prefs.getString('abs_api_token'); + if (absApiToken != null && absApiToken.isNotEmpty) { + await setAbsApiToken(absApiToken); + await prefs.remove('abs_api_token'); + } + } +} diff --git a/lib/services/sendspin_service.dart b/lib/services/sendspin_service.dart index 2ac0563c..3d15bc19 100644 --- a/lib/services/sendspin_service.dart +++ b/lib/services/sendspin_service.dart @@ -87,6 +87,9 @@ class SendspinService { // Auth token for proxy authentication (MA 2.7.1+) String? _authToken; + // Connection deduplication guard - prevents multiple concurrent auth attempts + Completer? _connectionInProgress; + SendspinService(this.serverUrl); /// Set the MA auth token for proxy authentication @@ -100,6 +103,13 @@ class SendspinService { if (_isDisposed) return false; if (_state == SendspinConnectionState.connected) return true; + // Deduplicate concurrent connection attempts + if (_connectionInProgress != null) { + _logger.log('Sendspin: Connection already in progress, waiting...'); + return _connectionInProgress!.future; + } + _connectionInProgress = Completer(); + _updateState(SendspinConnectionState.connecting); try { @@ -126,13 +136,19 @@ class SendspinService { if (!connected) { _logger.log('Sendspin: External proxy connection failed'); _updateState(SendspinConnectionState.error); + _connectionInProgress?.complete(false); + _connectionInProgress = null; return false; } + _connectionInProgress?.complete(true); + _connectionInProgress = null; return true; } catch (e) { _logger.log('Sendspin: Connection error: $e'); _updateState(SendspinConnectionState.error); + _connectionInProgress?.complete(false); + _connectionInProgress = null; return false; } } @@ -142,6 +158,13 @@ class SendspinService { if (_isDisposed) return false; if (_state == SendspinConnectionState.connected) return true; + // Deduplicate concurrent connection attempts + if (_connectionInProgress != null) { + _logger.log('Sendspin: Connection already in progress, waiting...'); + return _connectionInProgress!.future; + } + _connectionInProgress = Completer(); + _updateState(SendspinConnectionState.connecting); try { @@ -154,13 +177,19 @@ class SendspinService { if (!connected) { _updateState(SendspinConnectionState.error); + _connectionInProgress?.complete(false); + _connectionInProgress = null; return false; } + _connectionInProgress?.complete(true); + _connectionInProgress = null; return true; } catch (e) { _logger.log('Sendspin: Connection error: $e'); _updateState(SendspinConnectionState.error); + _connectionInProgress?.complete(false); + _connectionInProgress = null; return false; } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index e7cf9c25..ee54e135 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,6 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; import 'device_id_service.dart'; +import 'secure_storage_service.dart'; class SettingsService { static const String _keyServerUrl = 'server_url'; @@ -34,6 +35,10 @@ class SettingsService { static const String _keyShowFavoriteAlbums = 'show_favorite_albums'; static const String _keyShowFavoriteArtists = 'show_favorite_artists'; static const String _keyShowFavoriteTracks = 'show_favorite_tracks'; + static const String _keyShowFavoritePlaylists = 'show_favorite_playlists'; + static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations'; + static const String _keyShowFavoritePodcasts = 'show_favorite_podcasts'; + static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs // Default row order @@ -47,6 +52,9 @@ class SettingsService { 'favorite-albums', 'favorite-artists', 'favorite-tracks', + 'favorite-playlists', + 'favorite-radio-stations', + 'favorite-podcasts', ]; // View Mode Settings @@ -65,6 +73,8 @@ class SettingsService { static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list' static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year' static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list' + static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list' + static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list' // Audiobookshelf Direct Integration Settings static const String _keyAbsServerUrl = 'abs_server_url'; @@ -80,6 +90,12 @@ class SettingsService { static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen + // Volume Precision Mode Settings + static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control + + // Podcast Cover Cache (iTunes URLs for high-res artwork) + static const String _keyPodcastCoverCache = 'podcast_cover_cache'; + static Future getServerUrl() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_keyServerUrl); @@ -127,66 +143,44 @@ class SettingsService { } } - // Get authentication token for stream requests + // Get authentication token for stream requests (securely stored) static Future getAuthToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyAuthToken); + return await SecureStorageService.getAuthToken(); } - // Set authentication token for stream requests + // Set authentication token for stream requests (securely stored) static Future setAuthToken(String? token) async { - final prefs = await SharedPreferences.getInstance(); - if (token == null || token.isEmpty) { - await prefs.remove(_keyAuthToken); - } else { - await prefs.setString(_keyAuthToken, token); - } + await SecureStorageService.setAuthToken(token); } - // Get Music Assistant native auth token (long-lived token) + // Get Music Assistant native auth token (long-lived token, securely stored) static Future getMaAuthToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyMaAuthToken); + return await SecureStorageService.getMaAuthToken(); } - // Set Music Assistant native auth token (long-lived token) + // Set Music Assistant native auth token (long-lived token, securely stored) static Future setMaAuthToken(String? token) async { - final prefs = await SharedPreferences.getInstance(); - if (token == null || token.isEmpty) { - await prefs.remove(_keyMaAuthToken); - } else { - await prefs.setString(_keyMaAuthToken, token); - } + await SecureStorageService.setMaAuthToken(token); } // Clear Music Assistant native auth token static Future clearMaAuthToken() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_keyMaAuthToken); + await SecureStorageService.clearMaAuthToken(); } - // Get authentication credentials (serialized auth strategy credentials) + // Get authentication credentials (serialized auth strategy credentials, securely stored) static Future?> getAuthCredentials() async { - final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString(_keyAuthCredentials); - if (json == null) return null; - try { - return jsonDecode(json) as Map; - } catch (e) { - return null; - } + return await SecureStorageService.getAuthCredentials(); } - // Set authentication credentials (serialized auth strategy credentials) + // Set authentication credentials (serialized auth strategy credentials, securely stored) static Future setAuthCredentials(Map credentials) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_keyAuthCredentials, jsonEncode(credentials)); + await SecureStorageService.setAuthCredentials(credentials); } // Clear authentication credentials static Future clearAuthCredentials() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_keyAuthCredentials); + await SecureStorageService.clearAuthCredentials(); } // Get username for authentication @@ -205,20 +199,14 @@ class SettingsService { } } - // Get password for authentication + // Get password for authentication (securely stored) static Future getPassword() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyPassword); + return await SecureStorageService.getPassword(); } - // Set password for authentication + // Set password for authentication (securely stored) static Future setPassword(String? password) async { - final prefs = await SharedPreferences.getInstance(); - if (password == null || password.isEmpty) { - await prefs.remove(_keyPassword); - } else { - await prefs.setString(_keyPassword, password); - } + await SecureStorageService.setPassword(password); } // Get built-in player ID (persistent UUID for this device) @@ -409,6 +397,13 @@ class SettingsService { static Future clearSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); + await SecureStorageService.clearAll(); // Also clear secure storage + } + + /// Migrate credentials from old SharedPreferences storage to secure storage. + /// Should be called once during app startup for existing users. + static Future migrateToSecureStorage() async { + await SecureStorageService.migrateFromSharedPreferences(); } // Home Screen Row Settings (Main rows - default on) @@ -504,6 +499,47 @@ class SettingsService { await prefs.setBool(_keyShowFavoriteTracks, show); } + static Future getShowFavoritePlaylists() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyShowFavoritePlaylists) ?? false; + } + + static Future setShowFavoritePlaylists(bool show) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyShowFavoritePlaylists, show); + } + + static Future getShowFavoriteRadioStations() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyShowFavoriteRadioStations) ?? false; + } + + static Future setShowFavoriteRadioStations(bool show) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyShowFavoriteRadioStations, show); + } + + static Future getShowFavoritePodcasts() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyShowFavoritePodcasts) ?? false; + } + + static Future setShowFavoritePodcasts(bool show) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyShowFavoritePodcasts, show); + } + + // Library Artists Filter - show only artists that have albums in library + static Future getShowOnlyArtistsWithAlbums() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false; + } + + static Future setShowOnlyArtistsWithAlbums(bool show) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show); + } + // Home Row Order static Future> getHomeRowOrder() async { final prefs = await SharedPreferences.getInstance(); @@ -511,7 +547,17 @@ class SettingsService { if (json != null) { try { final List decoded = jsonDecode(json); - return decoded.cast(); + final savedOrder = decoded.cast(); + + // Add any new rows from defaultHomeRowOrder that aren't in saved order + // This ensures new row types appear for existing users + final missingRows = defaultHomeRowOrder + .where((row) => !savedOrder.contains(row)) + .toList(); + if (missingRows.isNotEmpty) { + return [...savedOrder, ...missingRows]; + } + return savedOrder; } catch (_) { return List.from(defaultHomeRowOrder); } @@ -641,6 +687,26 @@ class SettingsService { await prefs.setString(_keyLibrarySeriesViewMode, mode); } + static Future getLibraryRadioViewMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyLibraryRadioViewMode) ?? 'list'; + } + + static Future setLibraryRadioViewMode(String mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyLibraryRadioViewMode, mode); + } + + static Future getLibraryPodcastsViewMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2'; + } + + static Future setLibraryPodcastsViewMode(String mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyLibraryPodcastsViewMode, mode); + } + // View Mode Settings - Series Audiobooks (series detail screen) static Future getSeriesAudiobooksSortOrder() async { final prefs = await SharedPreferences.getInstance(); @@ -674,13 +740,11 @@ class SettingsService { } static Future getAbsApiToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyAbsApiToken); + return await SecureStorageService.getAbsApiToken(); } static Future setAbsApiToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_keyAbsApiToken, token); + await SecureStorageService.setAbsApiToken(token); } static Future getAbsEnabled() async { @@ -696,8 +760,8 @@ class SettingsService { static Future clearAbsSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyAbsServerUrl); - await prefs.remove(_keyAbsApiToken); await prefs.remove(_keyAbsEnabled); + await SecureStorageService.setAbsApiToken(null); // Clear from secure storage } // Audiobookshelf Library Settings (via MA browse API) @@ -808,4 +872,49 @@ class SettingsService { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_keyHasCompletedOnboarding, completed); } + + // Volume Precision Mode Settings + + /// Get whether volume precision mode is enabled (default: true) + /// When enabled, holding still while adjusting volume enters precision mode + /// for fine-grained control (10x more precise) + static Future getVolumePrecisionMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyVolumePrecisionMode) ?? true; + } + + /// Set whether volume precision mode is enabled + static Future setVolumePrecisionMode(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyVolumePrecisionMode, enabled); + } + + // Podcast Cover Cache (iTunes URLs for high-res artwork) + // Stored as JSON: {"podcastId": "itunesUrl", ...} + + /// Get cached podcast cover URLs (iTunes high-res) + static Future> getPodcastCoverCache() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_keyPodcastCoverCache); + if (json == null) return {}; + try { + final decoded = jsonDecode(json) as Map; + return decoded.map((key, value) => MapEntry(key, value as String)); + } catch (_) { + return {}; + } + } + + /// Save podcast cover cache + static Future setPodcastCoverCache(Map cache) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache)); + } + + /// Add a single podcast cover to cache (for incremental updates) + static Future addPodcastCoverToCache(String podcastId, String url) async { + final cache = await getPodcastCoverCache(); + cache[podcastId] = url; + await setPodcastCoverCache(cache); + } } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index b074c45e..f3ed7c34 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -5,6 +5,7 @@ import '../models/media_item.dart'; import 'database_service.dart'; import 'debug_logger.dart'; import 'music_assistant_api.dart'; +import 'settings_service.dart'; /// Sync status for UI indicators enum SyncStatus { @@ -150,10 +151,14 @@ class SyncService with ChangeNotifier { try { _logger.log('🔄 Starting background library sync...'); + // Read artist filter setting - when ON, only fetch artists that have albums + final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums(); + _logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums'); + // Fetch fresh data from MA API (in parallel for speed) final results = await Future.wait([ api.getAlbums(limit: 1000), - api.getArtists(limit: 1000), + api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums), api.getAudiobooks(limit: 1000), api.getPlaylists(limit: 1000), ]); @@ -166,6 +171,12 @@ class SyncService with ChangeNotifier { _logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, ' '${audiobooks.length} audiobooks, ${playlists.length} playlists from MA'); + // Clear old cache before saving fresh data (removes stale items) + await _db.clearCacheForType('album'); + await _db.clearCacheForType('artist'); + await _db.clearCacheForType('audiobook'); + await _db.clearCacheForType('playlist'); + // Save to database cache await _saveAlbumsToCache(albums); await _saveArtistsToCache(artists); diff --git a/lib/theme/theme_provider.dart b/lib/theme/theme_provider.dart index 815b1fd7..2dbb432a 100644 --- a/lib/theme/theme_provider.dart +++ b/lib/theme/theme_provider.dart @@ -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 updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async { if (imageUrl == null || imageUrl.isEmpty) return; @@ -16,7 +17,7 @@ Future updateAdaptiveColorsFromImage(BuildContext context, String? imageUr final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl)); if (colorSchemes != null && context.mounted) { final themeProvider = context.read(); - 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 _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(); } } diff --git a/lib/utils/search/fuzzy_matcher.dart b/lib/utils/search/fuzzy_matcher.dart new file mode 100644 index 00000000..4cfd8e5f --- /dev/null +++ b/lib/utils/search/fuzzy_matcher.dart @@ -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 queryTokens, List 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 queryTokens, List 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; + } +} diff --git a/lib/utils/search/ngram_matcher.dart b/lib/utils/search/ngram_matcher.dart new file mode 100644 index 00000000..780a1cf8 --- /dev/null +++ b/lib/utils/search/ngram_matcher.dart @@ -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 generateBigrams(String text) { + if (text.length < 2) return text.isNotEmpty ? [text] : []; + + final bigrams = []; + 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 generateTrigrams(String text) { + if (text.length < 3) return text.isNotEmpty ? [text] : []; + + final trigrams = []; + 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; + } +} diff --git a/lib/utils/search/scoring_config.dart b/lib/utils/search/scoring_config.dart new file mode 100644 index 00000000..afc28dcb --- /dev/null +++ b/lib/utils/search/scoring_config.dart @@ -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(); +} diff --git a/lib/utils/search/search_scoring.dart b/lib/utils/search/search_scoring.dart new file mode 100644 index 00000000..22c9be23 --- /dev/null +++ b/lib/utils/search/search_scoring.dart @@ -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; + } +} diff --git a/lib/utils/search/text_normalizer.dart b/lib/utils/search/text_normalizer.dart new file mode 100644 index 00000000..28ae2e09 --- /dev/null +++ b/lib/utils/search/text_normalizer.dart @@ -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 tokens; + + /// Tokens with stopwords removed + final List 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 _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 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()); + } +} diff --git a/lib/widgets/album_card.dart b/lib/widgets/album_card.dart index 26db570a..3c332094 100644 --- a/lib/widgets/album_card.dart +++ b/lib/widgets/album_card.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget { final Album album; 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 AlbumCard({ super.key, required this.album, this.onTap, this.heroTagSuffix, + this.imageCacheSize, }); @override @@ -30,6 +35,10 @@ class _AlbumCardState extends State { bool _triedFallback = false; bool _maImageFailed = false; String? _cachedMaImageUrl; + Timer? _fallbackTimer; + + /// Delay before fetching fallback images to avoid requests during fast scroll + static const _fallbackDelay = Duration(milliseconds: 200); @override void initState() { @@ -37,6 +46,12 @@ class _AlbumCardState extends State { _initFallbackImage(); } + @override + void dispose() { + _fallbackTimer?.cancel(); + super.dispose(); + } + void _initFallbackImage() { // Check if MA has an image after first build, then fetch fallback if needed WidgetsBinding.instance.addPostFrameCallback((_) { @@ -47,6 +62,16 @@ class _AlbumCardState extends State { if (maImageUrl == null && !_triedFallback) { _triedFallback = true; + _scheduleFallbackFetch(); + } + }); + } + + /// Schedule fallback fetch with delay to avoid requests during fast scroll + void _scheduleFallbackFetch() { + _fallbackTimer?.cancel(); + _fallbackTimer = Timer(_fallbackDelay, () { + if (mounted) { _fetchFallbackImage(); } }); @@ -69,7 +94,7 @@ class _AlbumCardState extends State { if (!_triedFallback && !_maImageFailed) { _maImageFailed = true; _triedFallback = true; - _fetchFallbackImage(); + _scheduleFallbackFetch(); } } @@ -86,6 +111,9 @@ class _AlbumCardState extends State { // Use fallback if MA image failed or wasn't available final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl; + // PERF: Use appropriate cache size based on display size + final cacheSize = widget.imageCacheSize ?? 256; + return RepaintBoundary( child: GestureDetector( onTap: widget.onTap ?? () { @@ -118,8 +146,8 @@ class _AlbumCardState extends State { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, - memCacheWidth: 256, - memCacheHeight: 256, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (context, url) => const SizedBox(), diff --git a/lib/widgets/album_row.dart b/lib/widgets/album_row.dart index 9ea2e5fc..3949e3a4 100644 --- a/lib/widgets/album_row.dart +++ b/lib/widgets/album_row.dart @@ -38,6 +38,12 @@ class _AlbumRowState extends State 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 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) { @@ -126,6 +121,7 @@ class _AlbumRowState extends State with AutomaticKeepAliveClientMixin return ScrollConfiguration( behavior: const _StretchScrollBehavior(), child: ListView.builder( + clipBehavior: Clip.none, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12.0), itemCount: _albums.length, @@ -135,13 +131,16 @@ class _AlbumRowState extends State with AutomaticKeepAliveClientMixin addRepaintBoundaries: false, // Cards already have RepaintBoundary itemBuilder: (context, index) { final album = _albums[index]; - return Container( - key: ValueKey(album.uri ?? album.itemId), - width: cardWidth, - margin: const EdgeInsets.symmetric(horizontal: 6.0), - child: AlbumCard( - album: album, - heroTagSuffix: widget.heroTagSuffix, + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Container( + key: ValueKey(album.uri ?? album.itemId), + width: cardWidth, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + child: AlbumCard( + album: album, + heroTagSuffix: widget.heroTagSuffix, + ), ), ); }, diff --git a/lib/widgets/artist_avatar.dart b/lib/widgets/artist_avatar.dart index b3f506ca..851c24bc 100644 --- a/lib/widgets/artist_avatar.dart +++ b/lib/widgets/artist_avatar.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; @@ -32,6 +33,10 @@ class _ArtistAvatarState extends State { String? _fallbackImageUrl; bool _triedFallback = false; bool _maImageFailed = false; + Timer? _fallbackTimer; + + /// Delay before fetching fallback images to avoid requests during fast scroll + static const _fallbackDelay = Duration(milliseconds: 200); @override void initState() { @@ -39,6 +44,12 @@ class _ArtistAvatarState extends State { _loadImage(); } + @override + void dispose() { + _fallbackTimer?.cancel(); + super.dispose(); + } + Future _loadImage() async { final provider = context.read(); @@ -56,14 +67,23 @@ class _ArtistAvatarState extends State { return; } - // Fallback to external sources (MA returned null) - _fetchFallbackImage(); + // Fallback to external sources (MA returned null) - with delay + _scheduleFallbackFetch(); } - Future _fetchFallbackImage() async { + /// Schedule fallback fetch with delay to avoid requests during fast scroll + void _scheduleFallbackFetch() { if (_triedFallback) return; _triedFallback = true; + _fallbackTimer?.cancel(); + _fallbackTimer = Timer(_fallbackDelay, () { + if (mounted) { + _fetchFallbackImage(); + } + }); + } + Future _fetchFallbackImage() async { final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name); _fallbackImageUrl = fallbackUrl; @@ -79,7 +99,7 @@ class _ArtistAvatarState extends State { // When MA image fails to load, try Deezer fallback if (!_maImageFailed) { _maImageFailed = true; - _fetchFallbackImage(); + _scheduleFallbackFetch(); } } @@ -100,6 +120,9 @@ class _ArtistAvatarState extends State { width: widget.radius * 2, height: widget.radius * 2, fit: BoxFit.cover, + // PERF: Use imageSize for memory cache to reduce decode overhead + memCacheWidth: widget.imageSize, + memCacheHeight: widget.imageSize, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (context, url) => Container( diff --git a/lib/widgets/artist_card.dart b/lib/widgets/artist_card.dart index 81e32808..37affb89 100644 --- a/lib/widgets/artist_card.dart +++ b/lib/widgets/artist_card.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget { final Artist artist; final VoidCallback? onTap; final String? heroTagSuffix; - final double? imageSize; + /// Image decode size in pixels. Defaults to 256. + /// Use smaller values (e.g., 128) for list views, larger for grids. + final int? imageCacheSize; const ArtistCard({ super.key, required this.artist, this.onTap, this.heroTagSuffix, - this.imageSize, + this.imageCacheSize, }); @override @@ -34,6 +37,10 @@ class _ArtistCardState extends State { bool _triedFallback = false; bool _maImageFailed = false; String? _cachedMaImageUrl; + Timer? _fallbackTimer; + + /// Delay before fetching fallback images to avoid requests during fast scroll + static const _fallbackDelay = Duration(milliseconds: 200); @override void initState() { @@ -42,6 +49,12 @@ class _ArtistCardState extends State { _initFallbackImage(); } + @override + void dispose() { + _fallbackTimer?.cancel(); + super.dispose(); + } + void _initFallbackImage() { // We'll check if MA has an image after first build, then fetch fallback if needed WidgetsBinding.instance.addPostFrameCallback((_) { @@ -52,7 +65,17 @@ class _ArtistCardState extends State { if (maImageUrl == null && !_triedFallback) { _triedFallback = true; - _logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard'); + _logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard'); + _scheduleFallbackFetch(); + } + }); + } + + /// Schedule fallback fetch with delay to avoid requests during fast scroll + void _scheduleFallbackFetch() { + _fallbackTimer?.cancel(); + _fallbackTimer = Timer(_fallbackDelay, () { + if (mounted) { _fetchFallbackImage(); } }); @@ -63,8 +86,8 @@ class _ArtistCardState extends State { if (!_triedFallback && !_maImageFailed) { _maImageFailed = true; _triedFallback = true; - _logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard'); - _fetchFallbackImage(); + _logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard'); + _scheduleFallbackFetch(); } } @@ -81,6 +104,9 @@ class _ArtistCardState extends State { // Use fallback if MA image failed or wasn't available final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl; + // PERF: Use appropriate cache size based on display size + final cacheSize = widget.imageCacheSize ?? 256; + return RepaintBoundary( child: GestureDetector( onTap: widget.onTap ?? () { @@ -115,8 +141,8 @@ class _ArtistCardState extends State { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, - memCacheWidth: 256, - memCacheHeight: 256, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, placeholder: (context, url) => const SizedBox(), @@ -163,6 +189,7 @@ class _ArtistCardState extends State { style: textTheme.titleSmall?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w500, + height: 1.15, ), ), ), diff --git a/lib/widgets/artist_row.dart b/lib/widgets/artist_row.dart index e5139336..7f1a8c87 100644 --- a/lib/widgets/artist_row.dart +++ b/lib/widgets/artist_row.dart @@ -38,6 +38,12 @@ class _ArtistRowState extends State 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 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) { diff --git a/lib/widgets/audiobook_row.dart b/lib/widgets/audiobook_row.dart index 75ac645f..c4898a65 100644 --- a/lib/widgets/audiobook_row.dart +++ b/lib/widgets/audiobook_row.dart @@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget { final Future> Function() loadAudiobooks; final String? heroTagSuffix; final double? rowHeight; + /// Optional: synchronous getter for cached data (for instant display) + final List? 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,9 +31,10 @@ class AudiobookRow extends StatefulWidget { } class _AudiobookRowState extends State with AutomaticKeepAliveClientMixin { - late Future> _audiobooksFuture; - List? _cachedAudiobooks; + List _audiobooks = []; + bool _isLoading = true; bool _hasLoaded = false; + bool _hasPrecachedAudiobooks = false; @override bool get wantKeepAlive => true; @@ -38,25 +42,116 @@ class _AudiobookRowState extends State 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(); + } + + Future _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); + } } - void _loadAudiobooksOnce() { - if (!_hasLoaded) { - _audiobooksFuture = widget.loadAudiobooks().then((audiobooks) { - _cachedAudiobooks = audiobooks; - return audiobooks; - }); - _hasLoaded = true; + /// Pre-cache audiobook images so Hero animations are smooth on first tap + void _precacheAudiobookImages(List audiobooks) { + if (!mounted || _hasPrecachedAudiobooks) return; + _hasPrecachedAudiobooks = true; + + final maProvider = context.read(); + + // Only precache first ~10 visible items + final audiobooksToCache = audiobooks.take(10); + + for (final audiobook in audiobooksToCache) { + final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256); + if (imageUrl != null) { + precacheImage( + CachedNetworkImageProvider(imageUrl), + context, + ).catchError((_) { + // Silently ignore precache errors + return false; + }); + } } } 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(); @@ -82,62 +177,7 @@ class _AudiobookRowState extends State with AutomaticKeepAliveClie ), ), Expanded( - child: FutureBuilder>( - 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), ), ], ), diff --git a/lib/widgets/expandable_player.dart b/lib/widgets/expandable_player.dart index 035c6a88..cdbdb756 100644 --- a/lib/widgets/expandable_player.dart +++ b/lib/widgets/expandable_player.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -11,6 +12,7 @@ import '../services/animation_debugger.dart'; import '../theme/palette_helper.dart'; import '../theme/theme_provider.dart'; import '../l10n/app_localizations.dart'; +import '../services/settings_service.dart'; import 'animated_icon_button.dart'; import 'global_player_overlay.dart'; import 'volume_control.dart'; @@ -61,6 +63,8 @@ class ExpandablePlayerState extends State // Queue panel slide animation late AnimationController _queuePanelController; late Animation _queuePanelAnimation; + // Cached slide position animation (avoids recreating Tween.animate every frame) + late Animation _queueSlideAnimation; // Adaptive theme colors extracted from album art ColorScheme? _lightColorScheme; @@ -70,17 +74,32 @@ class ExpandablePlayerState extends State // Queue state PlayerQueue? _queue; bool _isLoadingQueue = false; + bool _isQueueDragging = false; // True while queue item is being dragged + bool _queuePanelTargetOpen = false; // Target state for queue panel (separate from animation value) // Progress tracking - uses PositionTracker stream as single source of truth StreamSubscription? _positionSubscription; + // Queue items subscription - refreshes queue when MA pushes updates (e.g., radio mode adds tracks) + StreamSubscription>? _queueItemsSubscription; final ValueNotifier _progressNotifier = ValueNotifier(0); final ValueNotifier _seekPositionNotifier = ValueNotifier(null); + // Pre-computed static colors to avoid object creation during animation frames + static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3) + + // PERF Phase 1: Cached SliderThemeData - created once, reused every frame + static const SliderThemeData _sliderTheme = SliderThemeData( + trackHeight: 4, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + overlayShape: RoundSliderOverlayShape(overlayRadius: 20), + trackShape: RoundedRectSliderTrackShape(), + ); + // Dimensions - static const double _collapsedHeight = 64.0; + static double get _collapsedHeight => MiniPlayerLayout.height; static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar) static const double _collapsedBorderRadius = 16.0; - static const double _collapsedArtSize = 64.0; + static double get _collapsedArtSize => MiniPlayerLayout.artSize; static const double _bottomNavHeight = 56.0; static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture @@ -92,7 +111,10 @@ class ExpandablePlayerState extends State // Slide animation for device switching - now supports finger-following late AnimationController _slideController; - double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right + // ValueNotifier avoids setState during high-frequency drag updates + final ValueNotifier _slideOffsetNotifier = ValueNotifier(0.0); + double get _slideOffset => _slideOffsetNotifier.value; + set _slideOffset(double value) => _slideOffsetNotifier.value = value; bool _isSliding = false; bool _isDragging = false; // True while finger is actively dragging @@ -104,14 +126,58 @@ class ExpandablePlayerState extends State // When true, we hide main content and show peek content at center bool _inTransition = false; + // Volume swipe state (only active when device reveal is visible) + bool _isDraggingVolume = false; + double _dragVolumeLevel = 0.0; + int _lastVolumeUpdateTime = 0; + int _lastVolumeDragEndTime = 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 + + // Volume precision mode state + bool _inVolumePrecisionMode = false; + Timer? _volumePrecisionTimer; + Offset? _lastVolumeDragPosition; + double _lastVolumeLocalX = 0.0; // Last local X position during drag + bool _volumePrecisionModeEnabled = true; // From settings + double _volumePrecisionZoomCenter = 0.0; // Volume level when precision mode started + double _volumePrecisionStartX = 0.0; // Finger X position when precision mode started + 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; // Zoomed range (10% = left edge to right edge) + // Track favorite state for current track bool _isCurrentTrackFavorite = false; String? _lastTrackUri; // Track which track we last checked favorite status for // Cached title height to avoid TextPainter.layout() every animation frame + // Only invalidate when track name changes (screen width doesn't change during animation) double? _cachedExpandedTitleHeight; String? _lastMeasuredTrackName; - double? _lastMeasuredTitleWidth; + + // PERF: Cached MaterialRectCenterArcTween for art animation + // Recreated only when screen dimensions change, not every frame + MaterialRectCenterArcTween? _artRectTween; + Size? _lastScreenSize; + double? _lastTopPadding; + + // PERF: Pre-cached BoxShadow objects to avoid allocation per frame + static const BoxShadow _miniPlayerShadow = BoxShadow( + color: Color(0x4D000000), // 30% black + blurRadius: 8, + offset: Offset(0, 2), + ); + static const BoxShadow _artShadowExpanded = BoxShadow( + color: Color(0x40000000), // 25% black + blurRadius: 20, + offset: Offset(0, 8), + ); + + // PERF Phase 5: Cached fade animations - created once, reused every frame + // These replace inline .drive(Tween().chain(CurveTween())) which created new objects per frame + late Animation _fadeIn50to100; // Fades in during second half of animation // Gesture-driven expansion state bool _isVerticalDragging = false; @@ -140,22 +206,34 @@ class ExpandablePlayerState extends State reverseCurve: Curves.easeInCubic, ); + // PERF Phase 5: Cache fade animation - avoids creating Tween/CurveTween objects every frame + // Used for elements that fade in during second half of expansion (t=0.5 to t=1.0) + _fadeIn50to100 = _expandAnimation.drive( + Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)), + ), + ); + // Notify listeners of expansion progress changes _controller.addListener(_notifyExpansionProgress); // Animation debugging - record every frame _controller.addListener(_recordAnimationFrame); - // Queue panel animation + // Queue panel animation - uses spring physics directly (no CurvedAnimation) + // Spring simulation already provides natural physics-based easing + // CurvedAnimation would distort the spring output and cause jerky motion during swipe _queuePanelController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); - _queuePanelAnimation = CurvedAnimation( - parent: _queuePanelController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); + // Use controller directly - spring physics provide the easing + _queuePanelAnimation = _queuePanelController; + // Cache the slide animation to avoid recreating Tween.animate() every frame + _queueSlideAnimation = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(_queuePanelController); // Slide animation for device switching - used for snap/spring animations _slideController = AnimationController( @@ -170,6 +248,7 @@ class ExpandablePlayerState extends State _loadQueue(); } else if (status == AnimationStatus.dismissed) { // Close queue panel when player collapses + _queuePanelController.duration = _queueCloseDuration; _queuePanelController.reverse(); } }); @@ -177,6 +256,10 @@ class ExpandablePlayerState extends State // Subscribe to position tracker stream - single source of truth for playback position _subscribeToPositionTracker(); + // Subscribe to queue_items_updated events from Music Assistant + // This keeps queue in sync when MA pushes changes (e.g., radio mode adds tracks) + _subscribeToQueueEvents(); + // Auto-refresh queue when panel is open _queuePanelController.addStatusListener((status) { if (status == AnimationStatus.completed) { @@ -185,6 +268,35 @@ class ExpandablePlayerState extends State _stopQueueRefreshTimer(); } }); + + // Load precision mode setting + _loadVolumePrecisionModeSetting(); + } + + Future _loadVolumePrecisionModeSetting() async { + final enabled = await SettingsService.getVolumePrecisionMode(); + if (mounted) { + _volumePrecisionModeEnabled = enabled; + } + } + + void _enterVolumePrecisionMode() { + if (_inVolumePrecisionMode) return; + HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode + setState(() { + _inVolumePrecisionMode = true; + _volumePrecisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center + _volumePrecisionStartX = _lastVolumeLocalX; // Capture finger position at entry + }); + } + + void _exitVolumePrecisionMode() { + _volumePrecisionTimer?.cancel(); + _volumePrecisionTimer = null; + if (!_inVolumePrecisionMode) return; + setState(() { + _inVolumePrecisionMode = false; + }); } Timer? _queueRefreshTimer; @@ -209,8 +321,11 @@ class ExpandablePlayerState extends State _controller.dispose(); _queuePanelController.dispose(); _slideController.dispose(); + _slideOffsetNotifier.dispose(); _positionSubscription?.cancel(); + _queueItemsSubscription?.cancel(); _queueRefreshTimer?.cancel(); + _volumePrecisionTimer?.cancel(); _progressNotifier.dispose(); _seekPositionNotifier.dispose(); super.dispose(); @@ -223,6 +338,19 @@ class ExpandablePlayerState extends State // Animation durations - asymmetric for snappier collapse static const Duration _expandDuration = Duration(milliseconds: 280); static const Duration _collapseDuration = Duration(milliseconds: 200); + static const Duration _queueOpenDuration = Duration(milliseconds: 320); + static const Duration _queueCloseDuration = Duration(milliseconds: 220); + + // Spring description for queue panel animations + // Higher damping ratio prevents oscillation and ensures clean settling + // Critical damping = 2 * sqrt(stiffness * mass) = 2 * sqrt(550) ≈ 47 + // Using damping slightly above critical for overdamped (no bounce) behavior + // PERF: Increased stiffness from 400→550 for snappier animation + static const SpringDescription _queueSpring = SpringDescription( + mass: 1.0, + stiffness: 550.0, + damping: 50.0, // Overdamped - no oscillation, clean settle + ); void expand() { if (_isVerticalDragging) return; @@ -235,10 +363,17 @@ class ExpandablePlayerState extends State void collapse() { if (_isVerticalDragging) return; + // If queue panel is open, close it first instead of collapsing player + // This prevents both from closing on a single back press + if (isQueuePanelOpen) { + closeQueuePanel(); + return; + } AnimationDebugger.startSession('playerCollapse'); // Instantly hide queue panel when collapsing to avoid visual glitches - // during Android's predictive back gesture + // during Android's predictive back gesture (only reached if queue already closed) _queuePanelController.value = 0; + _queuePanelTargetOpen = false; _controller.duration = _collapseDuration; _controller.reverse().then((_) { AnimationDebugger.endSession(); @@ -300,6 +435,7 @@ class ExpandablePlayerState extends State if (currentValue > 0.0) { AnimationDebugger.startSession('playerCollapse'); _queuePanelController.value = 0; + _queuePanelTargetOpen = false; _controller.duration = _collapseDuration; _controller.reverse().then((_) { AnimationDebugger.endSession(); @@ -314,12 +450,24 @@ class ExpandablePlayerState extends State Color? _currentExpandedBgColor; Color? get currentExpandedBgColor => _currentExpandedBgColor; + Color? _currentExpandedPrimaryColor; + + // PERF Phase 4: Track last notified value to avoid unnecessary object creation + double _lastNotifiedProgress = -1; void _notifyExpansionProgress() { - playerExpansionNotifier.value = PlayerExpansionState( - _controller.value, - _currentExpandedBgColor, - ); + final progress = _controller.value; + // PERF Phase 4: Only notify when progress changes by at least 0.01 (1%) + // This reduces object allocation while maintaining smooth visual transitions + if ((progress - _lastNotifiedProgress).abs() >= 0.01 || + progress == 0.0 || progress == 1.0) { + _lastNotifiedProgress = progress; + playerExpansionNotifier.value = PlayerExpansionState( + progress, + _currentExpandedBgColor, + _currentExpandedPrimaryColor, + ); + } } void _subscribeToPositionTracker() { @@ -333,6 +481,26 @@ class ExpandablePlayerState extends State }); } + void _subscribeToQueueEvents() { + _queueItemsSubscription?.cancel(); + final maProvider = context.read(); + final api = maProvider.api; + if (api == null) return; + + // Subscribe to queue_items_updated events from Music Assistant + // This keeps queue UI in sync when MA adds/removes items (e.g., radio mode) + _queueItemsSubscription = api.queueItemsUpdatedEvents.listen((event) { + if (!mounted) return; + final playerId = event['queue_id'] as String?; + final selectedPlayer = maProvider.selectedPlayer; + // Only refresh if the event is for our current player + if (playerId != null && selectedPlayer != null && playerId == selectedPlayer.playerId) { + debugPrint('QueuePanel: Received queue_items_updated event, refreshing queue'); + _loadQueue(); + } + }); + } + Future _loadQueue() async { if (_isLoadingQueue) return; @@ -507,15 +675,77 @@ class ExpandablePlayerState extends State void _toggleQueuePanel() { if (_queuePanelController.isAnimating) return; - if (_queuePanelController.value == 0) { - _queuePanelController.forward(); + // Use threshold check instead of exact equality (spring may not land exactly at 0/1) + if (_queuePanelController.value < 0.1) { + _openQueuePanelWithSpring(); } else { - _queuePanelController.reverse(); + _closeQueuePanelWithSpring(); + } + } + + /// Open queue panel with spring physics for natural feel + void _openQueuePanelWithSpring() { + HapticFeedback.lightImpact(); + // setState ensures PopScope rebuilds with correct canPop value + setState(() { + _queuePanelTargetOpen = true; + }); + // Use overdamped spring - settles cleanly without oscillation or snap + final simulation = SpringSimulation( + _queueSpring, + _queuePanelController.value, + 1.0, + 0.0, // velocity + ); + _queuePanelController.animateWith(simulation); + } + + /// Close queue panel with spring physics + /// [withHaptic]: Set to false for Android back gesture (system provides haptic) + void _closeQueuePanelWithSpring({double velocity = 0.0, bool withHaptic = true}) { + // setState ensures PopScope rebuilds with correct canPop value + setState(() { + _queuePanelTargetOpen = false; + }); + if (withHaptic) { + HapticFeedback.lightImpact(); } + // Use overdamped spring for snappy close without oscillation + // Slightly stiffer than open spring for quicker settle + // PERF: Increased stiffness from 450→600 for snappier close + const closeSpring = SpringDescription( + mass: 1.0, + stiffness: 600.0, + damping: 52.0, // Overdamped - no bounce, clean settle + ); + final simulation = SpringSimulation( + closeSpring, + _queuePanelController.value, + 0.0, + velocity, + ); + _queuePanelController.animateWith(simulation); } bool get isQueuePanelOpen => _queuePanelController.value > 0.5; + /// Whether queue panel is intended to be open (target state, not animation value) + /// Use this for back gesture handling to avoid timing issues during animations + bool get isQueuePanelTargetOpen => _queuePanelTargetOpen; + + /// Close queue panel if open (for external access via GlobalPlayerOverlay) + /// [withHaptic]: Set to false for Android back gesture (system provides haptic) + void closeQueuePanel({bool withHaptic = true}) { + // Use target state, not animation value, to handle rapid open-close + // Allow closing even during animation by stopping it first + if (_queuePanelTargetOpen) { + if (_queuePanelController.isAnimating) { + _queuePanelController.stop(); + } + _closeQueuePanelWithSpring(withHaptic: withHaptic); + } + } + /// Update favorite status when track changes void _updateFavoriteStatus(dynamic currentTrack) { if (currentTrack == null) { @@ -577,9 +807,10 @@ class ExpandablePlayerState extends State final mappings = currentTrack.providerMappings as List?; if (mappings != null && mappings.isNotEmpty) { // Find a non-library provider mapping + // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz") for (final mapping in mappings) { if (mapping.available == true && mapping.providerInstance != 'library') { - actualProvider = mapping.providerInstance; + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; break; } @@ -588,7 +819,7 @@ class ExpandablePlayerState extends State if (actualProvider == 'library') { for (final mapping in mappings) { if (mapping.available == true) { - actualProvider = mapping.providerInstance; + actualProvider = mapping.providerDomain; actualItemId = mapping.itemId; break; } @@ -764,32 +995,35 @@ class ExpandablePlayerState extends State final normalizedDelta = delta / containerWidth; final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0); - // Update peek player based on drag direction (must be inside setState to trigger rebuild) - setState(() { - _slideOffset = newSlideOffset; - if (_slideOffset != 0) { - _updatePeekPlayerState(maProvider, _slideOffset); - } - }); + // Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds) + _slideOffset = newSlideOffset; + + // Only trigger setState when peek player changes (not every frame) + if (_slideOffset != 0) { + _updatePeekPlayerState(maProvider, _slideOffset); + } } - /// Update peek player state variables (called inside setState) + /// Update peek player state variables - only calls setState when peek player changes void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) { // dragDirection < 0 means swiping left (next player) // dragDirection > 0 means swiping right (previous player) final isNext = dragDirection < 0; final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext); + // Only setState when peek player actually changes (prevents unnecessary rebuilds) if (newPeekPlayer?.playerId != _peekPlayer?.playerId) { - _peekPlayer = newPeekPlayer; - // Get the peek player's current track image if available - if (_peekPlayer != null) { - _peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId); - _peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null; - } else { - _peekTrack = null; - _peekImageUrl = null; - } + setState(() { + _peekPlayer = newPeekPlayer; + // Get the peek player's current track image if available + if (_peekPlayer != null) { + _peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId); + _peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null; + } else { + _peekTrack = null; + _peekImageUrl = null; + } + }); } } @@ -844,9 +1078,8 @@ class ExpandablePlayerState extends State void animateToTarget() { if (!mounted) return; final curvedValue = Curves.easeOutCubic.transform(_slideController.value); - setState(() { - _slideOffset = startOffset + (targetOffset - startOffset) * curvedValue; - }); + // ValueNotifier triggers AnimatedBuilder rebuild - no setState needed + _slideOffset = startOffset + (targetOffset - startOffset) * curvedValue; } _slideController.addListener(animateToTarget); @@ -872,8 +1105,8 @@ class ExpandablePlayerState extends State // Move peek content to center position (slideOffset = 0) while keeping it visible. // The main content is hidden via _inTransition flag. // This creates a seamless visual - peek content is already at center. + _slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild setState(() { - _slideOffset = 0.0; _isSliding = false; // Keep _inTransition = true and peek data intact }); @@ -967,19 +1200,20 @@ class ExpandablePlayerState extends State void animateBack() { if (!mounted) return; final curvedValue = Curves.easeOutBack.transform(_slideController.value); - setState(() { - _slideOffset = startOffset * (1.0 - curvedValue); - }); + // ValueNotifier triggers AnimatedBuilder rebuild - no setState needed + _slideOffset = startOffset * (1.0 - curvedValue); } _slideController.addListener(animateBack); - _slideController.duration = const Duration(milliseconds: 300); + _slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back _slideController.forward().then((_) { if (!mounted) return; _slideController.removeListener(animateBack); + // Update slideOffset via ValueNotifier (triggers rebuild) + _slideOffset = 0.0; + // Other state changes still need setState setState(() { - _slideOffset = 0.0; _isSliding = false; _peekPlayer = null; _peekImageUrl = null; @@ -1033,22 +1267,48 @@ class ExpandablePlayerState extends State }); } - return AnimatedBuilder( - animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]), - builder: (context, _) { - // If no track is playing, show device selector bar - if (currentTrack == null) { - return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider); + // Handle Android back button - close queue panel first, then collapse player + // Use _queuePanelTargetOpen (intent) instead of animation value for reliable back handling + // This prevents race conditions where animation value crosses threshold during gesture + return PopScope( + canPop: !_queuePanelTargetOpen && !isExpanded, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + if (_queuePanelTargetOpen) { + // Always close queue panel on back, even if animating + // Stop any existing animation and start close + if (_queuePanelController.isAnimating) { + _queuePanelController.stop(); + } + // No haptic - Android back gesture provides its own haptic feedback + _closeQueuePanelWithSpring(withHaptic: false); + } else if (isExpanded) { + // Only collapse if not already animating + if (!_controller.isAnimating) { + collapse(); + } + } } - return _buildMorphingPlayer( - context, - maProvider, - selectedPlayer, - currentTrack, - imageUrl, - themeProvider, - ); }, + child: AnimatedBuilder( + // PERF: Only include _expandAnimation and _slideOffsetNotifier + // Queue panel has its own AnimatedBuilder - don't rebuild entire player on queue animation + animation: Listenable.merge([_expandAnimation, _slideOffsetNotifier]), + builder: (context, _) { + // If no track is playing, show device selector bar + if (currentTrack == null) { + return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider); + } + return _buildMorphingPlayer( + context, + maProvider, + selectedPlayer, + currentTrack, + imageUrl, + themeProvider, + ); + }, + ), ); }, ); @@ -1061,10 +1321,14 @@ class ExpandablePlayerState extends State dynamic selectedPlayer, ThemeProvider themeProvider, ) { - final screenSize = MediaQuery.of(context).size; - final bottomPadding = MediaQuery.of(context).padding.bottom; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; + // PERF Phase 1: Batch MediaQuery and Theme lookups + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + // Use viewPadding to match BottomNavigationBar's height calculation + final bottomPadding = mediaQuery.viewPadding.bottom; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; // Get adaptive colors if available final adaptiveScheme = themeProvider.adaptiveTheme @@ -1095,6 +1359,8 @@ class ExpandablePlayerState extends State right: _collapsedMargin, bottom: adjustedBottomOffset, child: GestureDetector( + // Tap to dismiss player reveal (matching _buildMorphingPlayer behavior) + onTap: widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : null, onVerticalDragUpdate: (details) { if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) { widget.onRevealPlayers!(); @@ -1135,6 +1401,7 @@ class ExpandablePlayerState extends State _handleHorizontalDragEnd(details, maProvider); }, + onPowerToggle: () => maProvider.togglePower(selectedPlayer.playerId), ), ), ); @@ -1148,11 +1415,17 @@ class ExpandablePlayerState extends State String? imageUrl, ThemeProvider themeProvider, ) { - final screenSize = MediaQuery.of(context).size; - final bottomPadding = MediaQuery.of(context).padding.bottom; - final topPadding = MediaQuery.of(context).padding.top; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; + // PERF Phase 1: Batch MediaQuery lookups - single InheritedWidget lookup + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + // Use viewPadding (not padding) to match BottomNavigationBar's height calculation + // viewPadding represents permanent system UI, padding can change (e.g., keyboard) + final bottomPadding = mediaQuery.viewPadding.bottom; + final topPadding = mediaQuery.padding.top; + // PERF Phase 1: Batch Theme lookups + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; // Animation progress final t = _expandAnimation.value; @@ -1169,12 +1442,10 @@ class ExpandablePlayerState extends State // Create a darker shade for the "unplayed" portion of progress bar final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!; final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212); - // Only update if we have adaptive colors, otherwise keep previous value - if (adaptiveScheme != null) { - _currentExpandedBgColor = expandedBg; - } else if (_currentExpandedBgColor == null) { - _currentExpandedBgColor = expandedBg; // First time fallback - } + final expandedPrimary = adaptiveScheme?.primary; + // Always update to current value - don't preserve stale adaptive colors + _currentExpandedBgColor = expandedBg; + _currentExpandedPrimaryColor = expandedPrimary; // When collapsed, use the darker unplayed color as base (progress bar will overlay the played portion) // When expanded, transition to the normal background final backgroundColor = Color.lerp(t < 0.5 ? collapsedBgUnplayed : collapsedBg, expandedBg, t)!; @@ -1182,16 +1453,33 @@ class ExpandablePlayerState extends State final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null ? adaptiveScheme.onPrimaryContainer : colorScheme.onPrimaryContainer; - final expandedTextColor = adaptiveScheme?.onSurface ?? Colors.white; + // Use colorScheme.onSurface as fallback instead of Colors.white for light theme support + final expandedTextColor = adaptiveScheme?.onSurface ?? colorScheme.onSurface; final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!; - final primaryColor = adaptiveScheme?.primary ?? Colors.white; + // Use colorScheme.primary as fallback instead of Colors.white for light theme support + final primaryColor = adaptiveScheme?.primary ?? colorScheme.primary; + + // PERF Phase 4: Pre-compute commonly used color opacities to reduce withOpacity() allocations per frame + // These are used multiple times throughout the widget tree during animation + final textColor50 = textColor.withOpacity(0.5); + final textColor60 = textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity); + final textColor70 = textColor.withOpacity(0.7); + final textColor45 = textColor.withOpacity(0.45); + final primaryColor20 = primaryColor.withOpacity(0.2); + final primaryColor70 = primaryColor.withOpacity(0.7); + + // PERF Phase 5: Pre-compute Alignment and FontWeight to avoid lerp calls per text element + // These are used by title, artist, and player name text elements + final textAlignment = Alignment.lerp(Alignment.centerLeft, Alignment.center, t)!; + final titleFontWeight = FontWeight.lerp(MiniPlayerLayout.primaryFontWeight, FontWeight.w600, t); // Always position above bottom nav bar + // Overlap by 2px when expanded to eliminate any subpixel rendering gaps final bottomNavSpace = _bottomNavHeight + bottomPadding; final collapsedBottomOffset = bottomNavSpace + _collapsedMargin; - final expandedBottomOffset = bottomNavSpace; - final expandedHeight = screenSize.height - bottomNavSpace; + final expandedBottomOffset = bottomNavSpace - 2; + final expandedHeight = screenSize.height - bottomNavSpace + 2; // Apply slide offset to hide mini player (slides down off-screen) // Apply bounce offset for reveal animation (small downward movement) @@ -1228,22 +1516,33 @@ class ExpandablePlayerState extends State // Art sizing - larger on bigger screens, max 85% of width final maxArtSize = screenSize.width - (contentPadding * 2); final expandedArtSize = (maxArtSize * 0.92).clamp(280.0, 400.0); - final artSize = _lerpDouble(_collapsedArtSize, expandedArtSize, t); final artBorderRadius = _lerpDouble(0, 12, t); // Square in mini player, rounded when expanded - // Art position + // Art position - uses MaterialRectCenterArcTween for Hero-like curved arc path + // This creates a natural arc trajectory instead of straight diagonal movement + // PERF: Cache the tween - only recreate when screen dimensions change final collapsedArtLeft = 0.0; + final collapsedArtTop = (_collapsedHeight - _collapsedArtSize) / 2; final expandedArtLeft = (screenSize.width - expandedArtSize) / 2; - final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t); - - final collapsedArtTop = 0.0; final expandedArtTop = topPadding + headerHeight + 16; - final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t); + + if (_artRectTween == null || _lastScreenSize != screenSize || _lastTopPadding != topPadding) { + final collapsedArtRect = Rect.fromLTWH(collapsedArtLeft, collapsedArtTop, _collapsedArtSize, _collapsedArtSize); + final expandedArtRect = Rect.fromLTWH(expandedArtLeft, expandedArtTop, expandedArtSize, expandedArtSize); + _artRectTween = MaterialRectCenterArcTween(begin: collapsedArtRect, end: expandedArtRect); + _lastScreenSize = screenSize; + _lastTopPadding = topPadding; + } + final artRect = _artRectTween!.lerp(t)!; + final artLeft = artRect.left; + final artTop = artRect.top; + final artSize = artRect.width; // Typography - uses shared MiniPlayerLayout constants for collapsed state final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t); final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t); + // Text position - left edge position (alignment handles centering smoothly) final expandedTitleLeft = contentPadding; final titleLeft = _lerpDouble(MiniPlayerLayout.textLeft, expandedTitleLeft, t); @@ -1262,9 +1561,8 @@ class ExpandablePlayerState extends State letterSpacing: -0.5, height: 1.2, ); - // Only recalculate if track name or width changed + // Only recalculate when track changes (screen width doesn't change during animation) if (_lastMeasuredTrackName != currentTrack.name || - _lastMeasuredTitleWidth != expandedTitleWidth || _cachedExpandedTitleHeight == null) { final titlePainter = TextPainter( text: TextSpan(text: currentTrack.name, style: titleStyle), @@ -1273,7 +1571,6 @@ class ExpandablePlayerState extends State )..layout(maxWidth: expandedTitleWidth); _cachedExpandedTitleHeight = titlePainter.height; _lastMeasuredTrackName = currentTrack.name; - _lastMeasuredTitleWidth = expandedTitleWidth; } final expandedTitleHeight = _cachedExpandedTitleHeight!; @@ -1281,9 +1578,12 @@ class ExpandablePlayerState extends State final titleToArtistGap = 12.0; final artistToAlbumGap = 8.0; final artistHeight = 22.0; // Approximate height for 18px font - final albumHeight = currentTrack.album != null ? 20.0 : 0.0; // Album line or nothing + // Album/chapter line visibility must match render condition: + // Show when: (album exists OR audiobook) AND NOT podcast + final showAlbumLine = (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast; + final albumHeight = showAlbumLine ? 20.0 : 0.0; final trackInfoBlockHeight = expandedTitleHeight + titleToArtistGap + artistHeight + - (currentTrack.album != null ? artistToAlbumGap + albumHeight : 0.0); + (showAlbumLine ? artistToAlbumGap + albumHeight : 0.0); // Controls section heights (from bottom up): // - Volume slider: 48px @@ -1293,7 +1593,8 @@ class ExpandablePlayerState extends State // - Progress bar + times: ~70px // Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding // Position progress bar so controls section is anchored at bottom - final bottomSafeArea = MediaQuery.of(context).padding.bottom; + // PERF Phase 1: Use already-batched bottomPadding instead of separate MediaQuery lookup + final bottomSafeArea = bottomPadding; final controlsSectionHeight = 222.0; // Total height of progress + controls + volume final playButtonHalfHeight = 36.0; // Half of the 72px play button container final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight; @@ -1312,6 +1613,12 @@ class ExpandablePlayerState extends State final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap; final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t); + // Player name - animated to move with other text elements + // Starts at tertiary position, animates toward artist position as it fades out + final collapsedPlayerNameTop = MiniPlayerLayout.tertiaryTop; + final expandedPlayerNameTop = expandedArtistTop; // Animates toward artist final position + final playerNameTop = _lerpDouble(collapsedPlayerNameTop, expandedPlayerNameTop, t); + // Album - subtle, below artist final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap; @@ -1331,9 +1638,6 @@ class ExpandablePlayerState extends State // Volume - anchored near bottom with breathing room final volumeTop = expandedControlsTop + 88; - // Queue panel slide amount (0 = hidden, 1 = fully visible) - final queueT = _queuePanelAnimation.value; - // Check if we have multiple players for swipe gesture final availablePlayers = _getAvailablePlayersSorted(maProvider); final hasMultiplePlayers = availablePlayers.length > 1; @@ -1350,9 +1654,11 @@ class ExpandablePlayerState extends State child: GestureDetector( // Use translucent to allow child widgets (like buttons) to receive taps behavior: HitTestBehavior.translucent, - // Only handle tap when collapsed - when expanded, let children handle their own taps - onTap: isExpanded ? null : expand, + // Handle tap: when device list is visible, dismiss it; when collapsed, expand + onTap: isExpanded ? null : (widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : expand), onVerticalDragStart: (details) { + // Ignore while queue item is being dragged + if (_isQueueDragging) return; // For expanded player or queue panel: start tracking immediately // For collapsed player: defer decision until we know swipe direction if (isExpanded || isQueuePanelOpen) { @@ -1360,6 +1666,9 @@ class ExpandablePlayerState extends State } }, onVerticalDragUpdate: (details) { + // Ignore while queue item is being dragged + if (_isQueueDragging) return; + final delta = details.primaryDelta ?? 0; // Handle queue panel close @@ -1390,14 +1699,99 @@ class ExpandablePlayerState extends State _handleVerticalDragUpdate(details); }, onVerticalDragEnd: (details) { + // Ignore while queue item is being dragged + if (_isQueueDragging) return; // Finish gesture-driven expansion _handleVerticalDragEnd(details); }, + onVerticalDragCancel: () { + // Reset state if gesture is cancelled (e.g., system takes over) + _isVerticalDragging = false; + }, onHorizontalDragStart: (details) { _horizontalDragStartX = details.globalPosition.dx; + // Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena) + // Start volume drag if device reveal is visible + if (widget.isDeviceRevealVisible && !isExpanded) { + // For consecutive swipes, use local volume (API may not have updated player state yet) + final now = DateTime.now().millisecondsSinceEpoch; + final timeSinceLastDrag = now - _lastVolumeDragEndTime; + final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs; + + // Use local volume if we have an override AND within window + final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow; + + final startVolume = useLocalVolume + ? _dragVolumeLevel // Continue from where last swipe ended + : (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // Fresh from player + + setState(() { + _isDraggingVolume = true; + _dragVolumeLevel = startVolume; + }); + _lastVolumeDragPosition = details.globalPosition; + HapticFeedback.lightImpact(); + } }, onHorizontalDragUpdate: (details) { - // Only handle in collapsed mode with multiple players + // Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena) + // Volume swipe when device reveal is visible + if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) { + // Check for stillness to trigger precision mode (only if enabled in settings) + final currentPosition = details.globalPosition; + if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) { + final movement = (currentPosition - _lastVolumeDragPosition!).distance; + + if (movement < _precisionStillnessThreshold) { + // Finger is still - start precision timer if not already running + if (_volumePrecisionTimer == null && !_inVolumePrecisionMode) { + _volumePrecisionTimer = Timer( + Duration(milliseconds: _precisionTriggerMs), + _enterVolumePrecisionMode, + ); + } + } else { + // Finger moved - cancel timer (but don't exit precision mode if already in it) + _volumePrecisionTimer?.cancel(); + _volumePrecisionTimer = null; + } + } + _lastVolumeDragPosition = currentPosition; + _lastVolumeLocalX = details.localPosition.dx; + + double newVolume; + + if (_inVolumePrecisionMode) { + // PRECISION MODE: Movement from entry point maps to zoomed range + // Full width of movement = precisionSensitivity (10%) change + // e.g., at 40% center: moving full width right = 50%, full width left = 30% + final offsetX = details.localPosition.dx - _volumePrecisionStartX; + final normalizedOffset = offsetX / collapsedWidth; // -1.0 to +1.0 range + final volumeChange = normalizedOffset * _precisionSensitivity; + newVolume = (_volumePrecisionZoomCenter + volumeChange).clamp(0.0, 1.0); + } else { + // NORMAL MODE: Delta-based movement (full width = 100%) + final dragDelta = details.delta.dx; + final volumeDelta = dragDelta / collapsedWidth; + newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0); + } + + if ((newVolume - _dragVolumeLevel).abs() > 0.001) { + setState(() { + _dragVolumeLevel = newVolume; + }); + // Throttle API calls to prevent flooding (faster in precision mode) + final now = DateTime.now().millisecondsSinceEpoch; + final throttleMs = _inVolumePrecisionMode ? _precisionThrottleMs : _volumeThrottleMs; + if (now - _lastVolumeUpdateTime >= throttleMs) { + _lastVolumeUpdateTime = now; + maProvider.setVolume(selectedPlayer.playerId, (newVolume * 100).round()); + } + } + return; + } + + // Only handle player swipe in collapsed mode with multiple players if (isExpanded || !hasMultiplePlayers) return; // Ignore drags that started in the edge dead zone @@ -1410,6 +1804,23 @@ class ExpandablePlayerState extends State _handleHorizontalDragUpdate(details, maProvider, collapsedWidth); }, onHorizontalDragEnd: (details) { + // Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena) + // End volume drag + if (_isDraggingVolume) { + // Send final volume on release + maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round()); + _lastVolumeDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes + _hasLocalVolumeOverride = true; // Mark that we have a local volume value + _exitVolumePrecisionMode(); + _lastVolumeDragPosition = null; + setState(() { + _isDraggingVolume = false; + }); + HapticFeedback.lightImpact(); + _horizontalDragStartX = null; + return; + } + // Ignore swipes that started near the edges (Android back gesture zone) final screenWidth = MediaQuery.of(context).size.width; final startedInDeadZone = _horizontalDragStartX != null && @@ -1420,32 +1831,61 @@ class ExpandablePlayerState extends State if (startedInDeadZone) return; if (isExpanded) { - // Expanded mode: swipe to open/close queue + // Expanded mode: swipe LEFT to open queue (swipe right to close is handled by QueuePanel's Listener) if (details.primaryVelocity != null) { if (details.primaryVelocity! < -300 && !isQueuePanelOpen) { _toggleQueuePanel(); - } else if (details.primaryVelocity! > 300 && isQueuePanelOpen) { - _toggleQueuePanel(); } + // NOTE: Swipe right to close is NOT handled here - QueuePanel's Listener + // handles its own swipe-to-close via onSwipeEnd callback to avoid double-trigger } } else if (hasMultiplePlayers) { // Collapsed mode: use finger-following handler _handleHorizontalDragEnd(details, maProvider); } }, + onHorizontalDragCancel: () { + // Reset state if gesture is cancelled (e.g., system takes over) + _horizontalDragStartX = null; + // Queue panel swipe is handled by QueuePanel's Listener + if (_isDraggingVolume) { + _exitVolumePrecisionMode(); + _lastVolumeDragPosition = null; + setState(() => _isDraggingVolume = false); + } + if (_isDragging) { + _isDragging = false; + _slideOffset = 0.0; + setState(() { + _peekPlayer = null; + _peekImageUrl = null; + _peekTrack = null; + }); + } + }, child: Container( - decoration: BoxDecoration( + // PERF Phase 4: Only show shadow when meaningfully visible (t < 0.5) + // This avoids BoxDecoration allocation for majority of animation frames + // Shadow fades out quickly during first half of expansion + decoration: t < 0.5 ? BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), - border: selectedPlayer.isGrouped && t < 0.5 - ? Border.all(color: _groupBorderColor, width: 1.5) - : null, - ), + boxShadow: const [_miniPlayerShadow], + ) : null, + // Use foregroundDecoration for border so it renders ON TOP of content + // This prevents the album art from clipping the yellow synced border + foregroundDecoration: maProvider.isPlayerManuallySynced(selectedPlayer.playerId) && t < 0.5 + ? BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: _groupBorderColor, width: 1.5), + ) + : null, child: Material( color: backgroundColor, borderRadius: BorderRadius.circular(borderRadius), - elevation: _lerpDouble(4, 0, t), - shadowColor: Colors.black.withOpacity(0.3), - clipBehavior: Clip.antiAlias, + elevation: 0, // PERF Phase 3: Fixed elevation, shadow handled by Container + // Use hardEdge when fully expanded (no border radius) to avoid anti-alias artifacts + // Use antiAlias when collapsed/animating to smooth rounded corners + clipBehavior: borderRadius > 0.5 ? Clip.antiAlias : Clip.hardEdge, child: SizedBox( width: width, height: height, @@ -1473,18 +1913,21 @@ class ExpandablePlayerState extends State // Fade out as we expand - use color alpha instead of Opacity widget final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0); final progressAreaWidth = width - _collapsedArtSize; - return ClipRRect( - borderRadius: BorderRadius.only( - topRight: Radius.circular(borderRadius), - bottomRight: Radius.circular(borderRadius), - ), - clipBehavior: Clip.hardEdge, - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: progressAreaWidth * progress, - height: height, - color: collapsedBg.withOpacity(progressOpacity), + // RepaintBoundary isolates frequent progress updates from parent animation + return RepaintBoundary( + child: ClipRRect( + borderRadius: BorderRadius.only( + topRight: Radius.circular(borderRadius), + bottomRight: Radius.circular(borderRadius), + ), + clipBehavior: Clip.hardEdge, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: progressAreaWidth * progress, + height: height, + color: collapsedBg.withOpacity(progressOpacity), + ), ), ), ); @@ -1511,49 +1954,46 @@ class ExpandablePlayerState extends State // FALLBACK: Show main content if transition is active but peek content unavailable // This prevents showing only the progress bar with no content // GPU PERF: Use conditional instead of Opacity to avoid saveLayer + // PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated) if (!(_inTransition && t < 0.1 && _peekPlayer != null)) Positioned( - left: artLeft + miniPlayerSlideOffset, + left: artLeft, top: artTop, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - // Use onTapUp instead of onTap - resolves immediately and wins - // the gesture arena against the outer vertical drag detector - onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null, - child: Container( - width: artSize, - height: artSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(artBorderRadius), - // GPU PERF: Fixed blur/offset, only animate shadow opacity - boxShadow: t > 0.3 - ? [ - BoxShadow( - color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ] - : null, - ), - // Use RepaintBoundary to isolate art repaints during animation - child: RepaintBoundary( - child: ClipRRect( + child: Transform.translate( + offset: Offset(miniPlayerSlideOffset, 0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Use onTapUp instead of onTap - resolves immediately and wins + // the gesture arena against the outer vertical drag detector + onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null, + child: Container( + width: artSize, + height: artSize, + decoration: BoxDecoration( borderRadius: BorderRadius.circular(artBorderRadius), - child: imageUrl != null - ? CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - // Fixed cache size to avoid mid-animation cache thrashing - memCacheWidth: 512, - memCacheHeight: 512, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholderFadeInDuration: Duration.zero, - placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t), - errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t), - ) - : _buildPlaceholderArt(colorScheme, t), + // PERF Phase 4: Only show shadow when near-expanded (t > 0.7) + // Avoids BoxShadow allocation during most of animation + boxShadow: t > 0.7 ? const [_artShadowExpanded] : null, + ), + // Use RepaintBoundary to isolate art repaints during animation + child: RepaintBoundary( + child: ClipRRect( + borderRadius: BorderRadius.circular(artBorderRadius), + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + // Fixed cache size to avoid mid-animation cache thrashing + memCacheWidth: 512, + memCacheHeight: 512, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t), + errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t), + ) + : _buildPlaceholderArt(colorScheme, t), + ), ), ), ), @@ -1567,63 +2007,77 @@ class ExpandablePlayerState extends State // FALLBACK: Show if transition active but no peek content available // GPU PERF: Use conditional instead of Opacity to avoid saveLayer // Hint text - vertically centered in mini player + // PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated) if (widget.isHintVisible && t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null)) Positioned( - left: titleLeft + miniPlayerSlideOffset, + left: titleLeft, // Center vertically: (64 - ~20) / 2 = 22 top: (MiniPlayerLayout.height - 20) / 2, - child: SizedBox( - width: titleWidth, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lightbulb_outline, - size: 16, - color: textColor, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - S.of(context)!.pullToSelectPlayers, - style: TextStyle( - color: textColor, - fontSize: titleFontSize, - fontWeight: MiniPlayerLayout.primaryFontWeight, + child: Transform.translate( + offset: Offset(miniPlayerSlideOffset, 0), + child: SizedBox( + width: titleWidth, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lightbulb_outline, + size: 16, + color: textColor, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + S.of(context)!.pullToSelectPlayers, + style: TextStyle( + color: textColor, + fontSize: titleFontSize, + fontWeight: MiniPlayerLayout.primaryFontWeight, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ), ), ), ), // Track title - with slide animation when collapsed // Hidden when hint is visible + // Uses Align.lerp for smooth left-to-center transition (textAlign can't be animated) + // PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated) if (!(widget.isHintVisible && t < 0.5) && !(_inTransition && t < 0.1 && _peekPlayer != null)) Positioned( - left: titleLeft + miniPlayerSlideOffset, + left: titleLeft, top: titleTop, - child: SizedBox( - width: titleWidth, - child: Text( - // Show player name when device reveal visible and collapsed - (widget.isDeviceRevealVisible && t < 0.5) - ? selectedPlayer.name - : currentTrack.name, - style: TextStyle( - color: textColor, - fontSize: titleFontSize, - fontWeight: t > 0.5 ? FontWeight.w600 : MiniPlayerLayout.primaryFontWeight, - letterSpacing: t > 0.5 ? -0.5 : 0, - height: t > 0.5 ? 1.2 : null, // Only use line height when expanded + child: Transform.translate( + offset: Offset(miniPlayerSlideOffset, 0), + child: SizedBox( + width: titleWidth, + child: Align( + // PERF Phase 5: Use pre-computed alignment + alignment: textAlignment, + child: Text( + currentTrack.name, + style: TextStyle( + color: textColor, + fontSize: titleFontSize, + // PERF Phase 5: Use pre-computed font weight + fontWeight: titleFontWeight, + // Lerp letter spacing smoothly (0 to -0.5) + letterSpacing: _lerpDouble(0, -0.5, t), + // Lerp line height smoothly (1.0 default to 1.2) + height: _lerpDouble(1.0, 1.2, t), + ), + textAlign: TextAlign.left, // Keep static, Align handles centering + // Use 1 line when collapsed to prevent overlap with artist line, + // expand to 2 lines during animation for long titles + maxLines: t > 0.3 ? 2 : 1, + overflow: TextOverflow.ellipsis, + ), ), - textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, - maxLines: t > 0.5 ? 2 : 1, - softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation - overflow: TextOverflow.ellipsis, ), ), ), @@ -1634,27 +2088,67 @@ class ExpandablePlayerState extends State // For audiobooks: show author from audiobook context // Hidden during transition to prevent flash // FALLBACK: Show if transition active but no peek content available - // GPU PERF: Use conditional instead of Opacity to avoid saveLayer + // Uses Align.lerp for smooth left-to-center transition + // PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated) if (!(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5)) Positioned( - left: titleLeft + miniPlayerSlideOffset, + left: titleLeft, top: artistTop, - child: SizedBox( - width: titleWidth, - child: Text( - // Always show artist/author (was showing "Now Playing" when device reveal visible) - maProvider.isPlayingAudiobook - ? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor) - : currentTrack.artistsString, - style: TextStyle( - color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity), - fontSize: artistFontSize, - fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal, + child: Transform.translate( + offset: Offset(miniPlayerSlideOffset, 0), + child: SizedBox( + width: titleWidth, + child: Align( + // PERF Phase 5: Use pre-computed alignment + alignment: textAlignment, + child: Text( + // Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible) + maProvider.isPlayingAudiobook + ? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor) + : maProvider.isPlayingPodcast + ? (maProvider.currentPodcastName ?? S.of(context)!.podcasts) + : currentTrack.artistsString, + style: TextStyle( + // PERF Phase 4: Use Color.lerp between pre-computed colors + color: Color.lerp(textColor60, textColor70, t), + fontSize: artistFontSize, + ), + textAlign: TextAlign.left, // Keep static, Align handles centering + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + + // Player name - third line, fades out during first half of expansion + // Uses staggered opacity: fully visible at t=0, fully faded at t=0.4 + // Position animates smoothly throughout + // PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated) + if (t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5)) + Positioned( + left: titleLeft, + top: playerNameTop, + child: Transform.translate( + offset: Offset(miniPlayerSlideOffset, 0), + child: SizedBox( + width: titleWidth, + child: Align( + // PERF Phase 5: Use pre-computed alignment + alignment: textAlignment, + child: Text( + selectedPlayer.name, + style: TextStyle( + // Staggered fade: 1.0 at t=0, 0.0 at t=0.4 + color: textColor.withOpacity((1.0 - t / 0.4).clamp(0.0, 1.0)), + fontSize: MiniPlayerLayout.tertiaryFontSize, + ), + textAlign: TextAlign.left, // Keep static, Align handles centering + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, - maxLines: 1, - softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation - overflow: TextOverflow.ellipsis, ), ), ), @@ -1662,7 +2156,8 @@ class ExpandablePlayerState extends State // Album name OR Chapter name (expanded only) // GPU PERF: Use color alpha instead of Opacity widget // For audiobooks: show current chapter; for music: show album - if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook)) + // For podcasts: hide album line since podcast name is already shown in artist position + if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast) Positioned( left: contentPadding, right: contentPadding, @@ -1688,26 +2183,23 @@ class ExpandablePlayerState extends State Positioned( left: contentPadding, right: contentPadding, - top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0), + top: expandedAlbumTop + (maProvider.isPlayingAudiobook ? 24 : (currentTrack.album != null ? 24 : 0)), child: FadeTransition( - opacity: _expandAnimation.drive( - Tween(begin: 0.0, end: 1.0).chain( - CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)), - ), - ), + // PERF Phase 5: Use cached animation instead of creating new Tween every frame + opacity: _fadeIn50to100, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.graphic_eq_rounded, size: 14, - color: primaryColor.withOpacity(0.7), + color: primaryColor70, // PERF: Use cached color ), const SizedBox(width: 6), Text( maProvider.currentAudioFormat ?? S.of(context)!.pcmAudio, style: TextStyle( - color: primaryColor.withOpacity(0.7), + color: primaryColor70, // PERF: Use cached color fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.3, @@ -1726,11 +2218,8 @@ class ExpandablePlayerState extends State right: contentPadding, top: expandedProgressTop, child: FadeTransition( - opacity: _expandAnimation.drive( - Tween(begin: 0.0, end: 1.0).chain( - CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)), - ), - ), + // PERF Phase 5: Use cached animation instead of creating new Tween every frame + opacity: _fadeIn50to100, child: ValueListenableBuilder( valueListenable: _progressNotifier, builder: (context, elapsedTime, child) { @@ -1742,13 +2231,9 @@ class ExpandablePlayerState extends State children: [ SizedBox( height: 48, // Increase touch target height + // PERF Phase 1: Use cached SliderThemeData child: SliderTheme( - data: SliderThemeData( - trackHeight: 4, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), - trackShape: const RoundedRectSliderTrackShape(), - ), + data: _sliderTheme, child: Slider( value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(), max: currentTrack.duration!.inSeconds.toDouble(), @@ -1771,7 +2256,7 @@ class ExpandablePlayerState extends State } }, activeColor: primaryColor, - inactiveColor: primaryColor.withOpacity(0.2), + inactiveColor: primaryColor20, // PERF: Use cached color ), ), ), @@ -1783,7 +2268,7 @@ class ExpandablePlayerState extends State Text( _formatDuration(currentProgress.toInt()), style: TextStyle( - color: textColor.withOpacity(0.5), + color: textColor50, // PERF: Use cached color fontSize: 13, // Increased from 11 to 13 fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], @@ -1792,7 +2277,7 @@ class ExpandablePlayerState extends State Text( _formatDuration(currentTrack.duration!.inSeconds), style: TextStyle( - color: textColor.withOpacity(0.5), + color: textColor50, // PERF: Use cached color fontSize: 13, // Increased from 11 to 13 fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], @@ -1811,37 +2296,157 @@ class ExpandablePlayerState extends State ), // Playback controls - with slide animation when collapsed + // Uses curved interpolation to smoothly transition from right-aligned to centered + // Use skip 30s controls for audiobooks and podcasts Positioned( - left: t > 0.5 ? 0 : null, - right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset, + // Full width positioning, alignment handled by child Align widget + left: 0, + right: 0, top: controlsTop, - child: maProvider.isPlayingAudiobook - ? _buildAudiobookControls( - maProvider: maProvider, - selectedPlayer: selectedPlayer, - textColor: textColor, - primaryColor: primaryColor, - backgroundColor: backgroundColor, - skipButtonSize: skipButtonSize, - playButtonSize: playButtonSize, - playButtonContainerSize: playButtonContainerSize, - t: t, - expandedElementsOpacity: expandedElementsOpacity, - ) - : Row( + child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast) + // When device reveal is visible (player list shown), use compact controls + // for audiobooks/podcasts: Play, Forward 30, Power + ? widget.isDeviceRevealVisible && t < 0.5 + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play/Pause - compact like PlayerCard + Transform.translate( + offset: const Offset(3, 0), + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + icon: Icon( + selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: textColor, + size: 28, + ), + onPressed: () => maProvider.playPauseSelectedPlayer(), + padding: EdgeInsets.zero, + ), + ), + ), + // Forward 30 seconds + Transform.translate( + offset: const Offset(6, 0), + child: IconButton( + icon: Icon( + Icons.forward_30_rounded, + color: textColor, + size: 28, + ), + onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + // Power button + IconButton( + icon: Icon( + Icons.power_settings_new_rounded, + color: selectedPlayer.powered ? textColor : textColor50, + size: 20, + ), + onPressed: () => maProvider.togglePower(selectedPlayer.playerId), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 4), + ], + ) + : _buildAudiobookControls( + maProvider: maProvider, + selectedPlayer: selectedPlayer, + textColor: textColor, + primaryColor: primaryColor, + backgroundColor: backgroundColor, + skipButtonSize: skipButtonSize, + playButtonSize: playButtonSize, + playButtonContainerSize: playButtonContainerSize, + t: t, + expandedElementsOpacity: expandedElementsOpacity, + ) + // When device reveal is visible (player list shown), use compact controls + // like PlayerCard: Play, Next, Power - matching other players in the list + : widget.isDeviceRevealVisible && t < 0.5 + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Play/Pause - compact like PlayerCard + // Touch target increased to 44dp for accessibility + Transform.translate( + offset: const Offset(3, 0), + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + icon: Icon( + selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: textColor, + size: 28, + ), + onPressed: () => maProvider.playPauseSelectedPlayer(), + padding: EdgeInsets.zero, + ), + ), + ), + // Skip next - nudged right like PlayerCard + Transform.translate( + offset: const Offset(6, 0), + child: IconButton( + icon: Icon( + Icons.skip_next_rounded, + color: textColor, + size: 28, + ), + onPressed: () => maProvider.nextTrackSelectedPlayer(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + // Power button - smallest, like PlayerCard + IconButton( + icon: Icon( + Icons.power_settings_new_rounded, + color: selectedPlayer.powered ? textColor : textColor50, + size: 20, + ), + onPressed: () => maProvider.togglePower(selectedPlayer.playerId), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 4), + ], + ) + // Wrap in Align for smooth transition from right to center + : Align( + // Smooth alignment: lerp from right (1.0) to center (0.0) + alignment: Alignment.lerp( + Alignment(1.0 - (collapsedControlsRight / (width / 2)), 0), // Right with margin + Alignment.center, + t, + )!, + child: Row( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, children: [ - // Shuffle (expanded only) - // GPU PERF: Use color alpha instead of Opacity - if (t > 0.5 && expandedElementsOpacity > 0.1) - _buildSecondaryButton( - icon: Icons.shuffle_rounded, - color: (_queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5)) - .withOpacity(expandedElementsOpacity), - onPressed: _isLoadingQueue ? null : _toggleShuffle, - ), - if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), + // Shuffle - animate size from 0 to prevent jerk when appearing + // Always render but with animated width to smoothly grow into place + SizedBox( + width: _lerpDouble(0, 44, t), // Animate width from 0 to 44 + height: 44, + child: t > 0.3 ? Opacity( + opacity: expandedElementsOpacity, + child: _buildSecondaryButton( + icon: Icons.shuffle_rounded, + color: _queue?.shuffle == true ? primaryColor : textColor50, + onPressed: _isLoadingQueue ? null : _toggleShuffle, + ), + ) : null, + ), + SizedBox(width: _lerpDouble(0, 20, t)), // Previous _buildControlButton( @@ -1876,21 +2481,26 @@ class ExpandablePlayerState extends State useAnimation: t > 0.5, ), - // Repeat (expanded only) - // GPU PERF: Use color alpha instead of Opacity - if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)), - if (t > 0.5 && expandedElementsOpacity > 0.1) - _buildSecondaryButton( - icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, - color: (_queue?.repeatMode != null && _queue!.repeatMode != 'off' - ? primaryColor - : textColor.withOpacity(0.5)) - .withOpacity(expandedElementsOpacity), - onPressed: _isLoadingQueue ? null : _cycleRepeat, - ), + // Repeat - animate size from 0 to prevent jerk when appearing + SizedBox(width: _lerpDouble(0, 20, t)), + SizedBox( + width: _lerpDouble(0, 44, t), // Animate width from 0 to 44 + height: 44, + child: t > 0.3 ? Opacity( + opacity: expandedElementsOpacity, + child: _buildSecondaryButton( + icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, + color: _queue?.repeatMode != null && _queue!.repeatMode != 'off' + ? primaryColor + : textColor50, + onPressed: _isLoadingQueue ? null : _cycleRepeat, + ), + ) : null, + ), ], ), ), + ), // Volume control (expanded only) // GPU PERF: Use FadeTransition instead of Opacity @@ -1900,12 +2510,9 @@ class ExpandablePlayerState extends State right: 48, top: volumeTop, child: FadeTransition( - opacity: _expandAnimation.drive( - Tween(begin: 0.0, end: 1.0).chain( - CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)), - ), - ), - child: const VolumeControl(compact: false), + // PERF Phase 5: Use cached animation instead of creating new Tween every frame + opacity: _fadeIn50to100, + child: VolumeControl(compact: false, accentColor: primaryColor), ), ), @@ -1926,39 +2533,61 @@ class ExpandablePlayerState extends State ), ), - // Favorite button (expanded only) - hide when queue panel is open - // GPU PERF: Use icon color alpha instead of Opacity - if (t > 0.3 && queueT < 0.5) - Positioned( - top: topPadding + 4, - right: 52, - child: IconButton( - icon: Icon( - _isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border, - color: (_isCurrentTrackFavorite ? Colors.red : textColor) - .withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)), - size: 24, - ), - onPressed: () => _toggleCurrentTrackFavorite(currentTrack), - padding: const EdgeInsets.all(12), - ), - ), - - // Queue button (expanded only) - hide when queue panel is open - // GPU PERF: Use icon color alpha instead of Opacity - if (t > 0.3 && queueT < 0.5) - Positioned( - top: topPadding + 4, - right: 4, - child: IconButton( - icon: Icon( - Icons.queue_music_rounded, - color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)), - size: 24, - ), - onPressed: _toggleQueuePanel, - padding: const EdgeInsets.all(12), - ), + // Favorite + Queue buttons (expanded only) - fade when queue panel opens + // PERF: Own AnimatedBuilder - only rebuilds these 2 buttons on queue animation + if (t > 0.3) + AnimatedBuilder( + animation: _queuePanelAnimation, + builder: (context, _) { + final queueFade = _queuePanelAnimation.value; + // Hide completely when queue > 0.5 + if (queueFade >= 0.5) return const SizedBox.shrink(); + final fadeOpacity = (1 - queueFade * 2).clamp(0.0, 1.0); + final expandOpacity = ((t - 0.3) / 0.7).clamp(0.0, 1.0); + return Stack( + children: [ + // Favorite button + Positioned( + top: topPadding + 4, + right: 52, + child: TweenAnimationBuilder( + key: ValueKey(_isCurrentTrackFavorite), + tween: Tween(begin: 1.3, end: 1.0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + builder: (context, scale, child) => Transform.scale( + scale: scale, + child: child, + ), + child: IconButton( + icon: Icon( + _isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border, + color: (_isCurrentTrackFavorite ? Colors.red : textColor) + .withOpacity(expandOpacity * fadeOpacity), + size: 24, + ), + onPressed: () => _toggleCurrentTrackFavorite(currentTrack), + padding: const EdgeInsets.all(12), + ), + ), + ), + // Queue button + Positioned( + top: topPadding + 4, + right: 4, + child: IconButton( + icon: Icon( + Icons.queue_music_rounded, + color: textColor.withOpacity(expandOpacity * fadeOpacity), + size: 24, + ), + onPressed: _toggleQueuePanel, + padding: const EdgeInsets.all(12), + ), + ), + ], + ); + }, ), // Player name (expanded only) @@ -1987,38 +2616,105 @@ class ExpandablePlayerState extends State // Queue/Chapters panel (slides in from right) // For audiobooks: show chapters panel // For music: show queue panel - if (t > 0.5 && queueT > 0) + // PERF: Own AnimatedBuilder - only rebuilds queue panel section on queue animation + // Main player doesn't rebuild when queue slides in/out + if (t > 0.5) Positioned.fill( - child: RepaintBoundary( - child: SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(_queuePanelAnimation), - child: maProvider.isPlayingAudiobook - ? ChaptersPanel( - maProvider: maProvider, - audiobook: maProvider.currentAudiobook, - textColor: textColor, - primaryColor: primaryColor, - backgroundColor: expandedBg, - topPadding: topPadding, - onClose: _toggleQueuePanel, - ) - : QueuePanel( - maProvider: maProvider, - queue: _queue, - isLoading: _isLoadingQueue, - textColor: textColor, - primaryColor: primaryColor, - backgroundColor: expandedBg, - topPadding: topPadding, - onClose: _toggleQueuePanel, - onRefresh: _loadQueue, + child: AnimatedBuilder( + animation: _queuePanelAnimation, + builder: (context, child) { + final queueProgress = _queuePanelAnimation.value; + return Offstage( + offstage: queueProgress == 0, + child: child, + ); + }, + // PERF: Child is not rebuilt - only Offstage wrapper updates + child: RepaintBoundary( + child: SlideTransition( + // PERF: Use cached animation instead of Tween.animate() every frame + position: _queueSlideAnimation, + child: maProvider.isPlayingAudiobook + ? ChaptersPanel( + maProvider: maProvider, + audiobook: maProvider.currentAudiobook, + textColor: textColor, + primaryColor: primaryColor, + backgroundColor: expandedBg, + topPadding: topPadding, + onClose: _toggleQueuePanel, + ) + : QueuePanel( + maProvider: maProvider, + queue: _queue, + isLoading: _isLoadingQueue, + textColor: textColor, + primaryColor: primaryColor, + backgroundColor: expandedBg, + topPadding: topPadding, + onClose: _toggleQueuePanel, + onRefresh: _loadQueue, + onDraggingChanged: (isDragging) { + _isQueueDragging = isDragging; + }, + onSwipeStart: () { + // Haptic feedback when swipe gesture recognized + HapticFeedback.selectionClick(); + }, + onSwipeUpdate: (_) { + // No finger tracking - just wait for swipe end + // This avoids jank from direct value manipulation + }, + onSwipeEnd: (velocity, totalDx) { + // Decide based on velocity and total displacement + final screenWidth = MediaQuery.of(context).size.width; + final swipeProgress = totalDx / screenWidth; + final shouldClose = velocity > 150 || swipeProgress > 0.25; + + if (shouldClose) { + _closeQueuePanelWithSpring(); + } + // If not closing, panel stays open (no snap-back needed since we didn't move it) + }, + ), + ), + ), + ), + ), + + // Volume swipe overlay (covers entire mini player when dragging volume) + // Only visible when device reveal is open and user is dragging + // Positioned last in Stack so it renders ON TOP of all content + // Uses AnimatedOpacity for smooth fade in/out transition + if (t < 0.5) + Positioned.fill( + child: IgnorePointer( + ignoring: !_isDraggingVolume, + child: AnimatedOpacity( + opacity: _isDraggingVolume ? 1.0 : 0.0, + duration: const Duration(milliseconds: 120), + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Stack( + children: [ + // Unfilled (darker) background + Container(color: collapsedBgUnplayed), + // Filled (lighter) portion based on volume + Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _dragVolumeLevel.clamp(0.0, 1.0), + heightFactor: 1.0, + child: Container(color: collapsedBg), + ), ), + ], + ), + ), ), ), ), + ], ), ), @@ -2056,12 +2752,15 @@ class ExpandablePlayerState extends State final hasTrack = _peekTrack != null && peekImageUrl != null; final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown); final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice; + // Show player name as third line only when playing + final peekPlayerName = hasTrack ? (peekPlayer?.name ?? '') : null; return Transform.translate( offset: Offset(peekBaseOffset, 0), child: MiniPlayerContent( primaryText: peekTrackName, secondaryText: peekArtistName, + tertiaryText: peekPlayerName, imageUrl: hasTrack ? peekImageUrl : null, playerName: peekPlayer?.name ?? '', backgroundColor: backgroundColor, @@ -2124,11 +2823,14 @@ class ExpandablePlayerState extends State } Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) { + // Use theme-aware colors for both collapsed and expanded states + final expandedBgColor = colorScheme.surfaceContainerHighest; + final expandedIconColor = colorScheme.onSurface.withOpacity(0.24); return Container( - color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t), + color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t), child: Icon( Icons.music_note_rounded, - color: Color.lerp(colorScheme.onSurfaceVariant, Colors.white24, t), + color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t), size: _lerpDouble(24, 120, t), ), ); @@ -2176,7 +2878,8 @@ class ExpandablePlayerState extends State } /// Build audiobook-specific playback controls - /// Layout: [Prev Chapter] [-30s] [Play/Pause] [+30s] [Next Chapter] + /// Collapsed: [-30s] [Play/Pause] [+30s] + /// Expanded: [Prev Chapter] [-30s] [-10s] [Play/Pause] [+10s] [+30s] [Next Chapter] Widget _buildAudiobookControls({ required MusicAssistantProvider maProvider, required dynamic selectedPlayer, @@ -2190,19 +2893,20 @@ class ExpandablePlayerState extends State required double expandedElementsOpacity, }) { final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false; + final isExpanded = t > 0.5; return Row( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, + mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end, children: [ // Previous Chapter (expanded only, if chapters available) - if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters) + if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters) _buildSecondaryButton( icon: Icons.skip_previous_rounded, color: textColor.withOpacity(expandedElementsOpacity), onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId), ), - if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), + if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), // Rewind 30 seconds _buildControlButton( @@ -2210,9 +2914,21 @@ class ExpandablePlayerState extends State color: textColor, size: skipButtonSize, onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30), - useAnimation: t > 0.5, + useAnimation: isExpanded, ), - SizedBox(width: _lerpDouble(0, 20, t)), + + // Rewind 10 seconds (expanded only, same size as 30s) + if (isExpanded) ...[ + SizedBox(width: _lerpDouble(0, 8, t)), + _buildControlButton( + icon: Icons.replay_10_rounded, + color: textColor, + size: skipButtonSize, + onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -10), + useAnimation: true, + ), + ], + SizedBox(width: _lerpDouble(0, 12, t)), // Play/Pause _buildPlayButton( @@ -2226,7 +2942,19 @@ class ExpandablePlayerState extends State onPressed: () => maProvider.playPauseSelectedPlayer(), onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId), ), - SizedBox(width: _lerpDouble(0, 20, t)), + SizedBox(width: _lerpDouble(0, 12, t)), + + // Forward 10 seconds (expanded only, same size as 30s) + if (isExpanded) ...[ + _buildControlButton( + icon: Icons.forward_10_rounded, + color: textColor, + size: skipButtonSize, + onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 10), + useAnimation: true, + ), + SizedBox(width: _lerpDouble(0, 8, t)), + ], // Forward 30 seconds _buildControlButton( @@ -2234,12 +2962,12 @@ class ExpandablePlayerState extends State color: textColor, size: skipButtonSize, onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30), - useAnimation: t > 0.5, + useAnimation: isExpanded, ), // Next Chapter (expanded only, if chapters available) - if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), - if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters) + if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), + if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters) _buildSecondaryButton( icon: Icons.skip_next_rounded, color: textColor.withOpacity(expandedElementsOpacity), @@ -2282,12 +3010,11 @@ class ExpandablePlayerState extends State return SizedBox( width: 44, height: 44, - child: IconButton( - icon: Icon(icon), + child: AnimatedIconButton( + icon: icon, color: color, iconSize: 22, onPressed: onPressed, - padding: EdgeInsets.zero, ), ); } diff --git a/lib/widgets/global_player_overlay.dart b/lib/widgets/global_player_overlay.dart index d8291918..3d21e178 100644 --- a/lib/widgets/global_player_overlay.dart +++ b/lib/widgets/global_player_overlay.dart @@ -8,7 +8,9 @@ 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'; /// Cached color with contrast adjustment @@ -56,23 +58,24 @@ class BottomSpacing { /// Height of the bottom navigation bar static const double navBarHeight = 56.0; - /// Height of mini player when visible (64px height + 12px margin) - static const double miniPlayerHeight = 76.0; + /// Height of mini player when visible (height + 12px margin) + static double get miniPlayerHeight => MiniPlayerLayout.height + 12.0; /// Space needed when only nav bar is visible (with some extra padding) static const double navBarOnly = navBarHeight + 16.0; /// Space needed when mini player is also visible - static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0; + static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0; } -/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color +/// ValueNotifier for player expansion progress (0.0 to 1.0) and colors class PlayerExpansionState { 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(0.0, null)); +final playerExpansionNotifier = ValueNotifier(PlayerExpansionState(0.0, null, null)); /// Wrapper widget that provides a global player overlay above all navigation. /// @@ -98,6 +101,21 @@ class GlobalPlayerOverlay extends StatefulWidget { 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; @@ -472,74 +490,44 @@ class _GlobalPlayerOverlayState extends State builder: (context, _) { return Consumer( 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( 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, @@ -553,7 +541,50 @@ class _GlobalPlayerOverlayState extends State ] : 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, + ), + ], + ), ); }, ); @@ -642,7 +673,8 @@ class _GlobalPlayerOverlayState extends State left: 24, right: 24, // Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap - bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32, + // Use viewPadding to match BottomNavigationBar's height calculation + bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32, child: FadeTransition( opacity: CurvedAnimation( parent: _welcomeFadeController, @@ -683,7 +715,8 @@ class _GlobalPlayerOverlayState extends State PlayerRevealOverlay( key: _revealKey, onDismiss: _hidePlayerReveal, - miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12, + // Use viewPadding to match BottomNavigationBar's height calculation + miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12, miniPlayerHeight: 64, showOnboardingHints: _isOnboardingReveal, ), diff --git a/lib/widgets/letter_scrollbar.dart b/lib/widgets/letter_scrollbar.dart new file mode 100644 index 00000000..cc884319 --- /dev/null +++ b/lib/widgets/letter_scrollbar.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; + +/// A scrollbar that shows a letter popup when dragging, for fast navigation +/// through alphabetically sorted lists. +class LetterScrollbar extends StatefulWidget { + /// The scrollable child widget (ListView, GridView, etc.) + final Widget child; + + /// 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 + final List items; + + /// 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; + + const LetterScrollbar({ + super.key, + required this.child, + required this.controller, + required this.items, + this.onScrollToIndex, + this.onDragStateChanged, + }); + + @override + State createState() => _LetterScrollbarState(); +} + +class _LetterScrollbarState extends State { + bool _isDragging = false; + String _currentLetter = ''; + double _dragPosition = 0; + double _scrollFraction = 0; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onScrollChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onScrollChanged); + super.dispose(); + } + + void _onScrollChanged() { + if (!_isDragging && widget.controller.hasClients) { + 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; + }); + } + } + } + } + + // Build a map of letter -> first index for that letter + Map get _letterIndexMap { + final map = {}; + 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; + } + } + } + return map; + } + + String _getLetterAtPosition(double position, double maxHeight) { + if (widget.items.isEmpty || maxHeight <= 0) 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 item = widget.items[index]; + if (item.isNotEmpty) { + return item[0].toUpperCase(); + } + } + 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); + } + + void _handleDragUpdate(DragUpdateDetails details) { + setState(() { + _dragPosition = details.localPosition.dy; + }); + _updateLetterAndScroll(details.localPosition.dy); + } + + void _handleDragEnd(DragEndDetails details) { + setState(() { + _isDragging = false; + }); + widget.onDragStateChanged?.call(false); + } + + void _updateLetterAndScroll(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); + } + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Stack( + children: [ + // The scrollable content + widget.child, + + // The draggable scrollbar area (right edge) - wider grab area + Positioned( + right: 0, + top: 0, + bottom: 0, + width: 32, // Wider grab area + child: GestureDetector( + onVerticalDragStart: _handleDragStart, + onVerticalDragUpdate: _handleDragUpdate, + onVerticalDragEnd: _handleDragEnd, + onTapDown: (details) { + _handleDragStart(DragStartDetails( + localPosition: details.localPosition, + globalPosition: details.globalPosition, + )); + }, + onTapUp: (_) { + setState(() { + _isDragging = false; + }); + widget.onDragStateChanged?.call(false); + }, + behavior: HitTestBehavior.translucent, + child: LayoutBuilder( + builder: (context, constraints) { + final trackHeight = constraints.maxHeight; + final thumbHeight = _isDragging ? 60.0 : 40.0; + final availableTrack = trackHeight - thumbHeight; + final thumbTop = availableTrack * _scrollFraction; + + return Stack( + children: [ + // Thumb that tracks scroll position - use AnimatedPositioned for smooth motion + AnimatedPositioned( + duration: const Duration(milliseconds: 50), + curve: Curves.linear, + top: thumbTop, + right: 4, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: _isDragging ? 6 : 4, + height: thumbHeight, + decoration: BoxDecoration( + color: _isDragging + ? colorScheme.primary + : colorScheme.onSurface.withOpacity(0.3), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ], + ); + }, + ), + ), + ), + + // The letter popup bubble + if (_isDragging && _currentLetter.isNotEmpty) + Positioned( + right: 48, + top: _dragPosition - 28, + child: Material( + elevation: 4, + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 56, + height: 56, + alignment: Alignment.center, + child: Text( + _currentLetter, + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/player/device_selector_bar.dart b/lib/widgets/player/device_selector_bar.dart index 73ea649b..9e5e4189 100644 --- a/lib/widgets/player/device_selector_bar.dart +++ b/lib/widgets/player/device_selector_bar.dart @@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget { final GestureDragStartCallback? onHorizontalDragStart; final GestureDragUpdateCallback? onHorizontalDragUpdate; final GestureDragEndCallback? onHorizontalDragEnd; + final VoidCallback? onPowerToggle; const DeviceSelectorBar({ super.key, @@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget { this.onHorizontalDragStart, this.onHorizontalDragUpdate, this.onHorizontalDragEnd, + this.onPowerToggle, }); @override @@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget { width: width, slideOffset: slideOffset, isHint: swipeHint != null, + onPowerToggle: onPowerToggle, + isPoweredOn: selectedPlayer.powered, ), ], ), diff --git a/lib/widgets/player/mini_player_content.dart b/lib/widgets/player/mini_player_content.dart index ffb97490..cc2f9788 100644 --- a/lib/widgets/player/mini_player_content.dart +++ b/lib/widgets/player/mini_player_content.dart @@ -3,18 +3,27 @@ import 'package:cached_network_image/cached_network_image.dart'; /// Shared constants for mini player layout class MiniPlayerLayout { - static const double height = 64.0; - static const double artSize = 64.0; + static const double height = 72.0; + static const double artSize = 72.0; static const double textLeftOffset = 10.0; // Gap between art and text - static const double textLeft = artSize + textLeftOffset; // 74px - static const double primaryTop = 13.0; - static const double secondaryTop = 33.0; + static const double textLeft = artSize + textLeftOffset; // 82px + // 3-line layout: track, artist, player + 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 + static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14 + static const double secondaryTop2Line = 40.0; // raised slightly from 42 static const double textRightPadding = 12.0; + static const double powerButtonSize = 40.0; // Power button tap area static const double iconSize = 28.0; static const double iconOpacity = 0.4; static const double secondaryTextOpacity = 0.6; - static const double primaryFontSize = 16.0; + static const double primaryFontSize = 16.0; // 3-line playing mode + static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode static const double secondaryFontSize = 14.0; + static const double tertiaryFontSize = 14.0; static const FontWeight primaryFontWeight = FontWeight.w500; } @@ -30,6 +39,10 @@ class MiniPlayerContent extends StatelessWidget { /// If null, primary text will be vertically centered final String? secondaryText; + /// Tertiary text line (player name when playing) + /// If provided, uses 3-line layout; if null, uses centered 2-line layout + final String? tertiaryText; + /// Album art URL - if null, shows device icon final String? imageUrl; @@ -57,10 +70,17 @@ class MiniPlayerContent extends StatelessWidget { /// Progress value 0.0 to 1.0 (only used if showProgress is true) final double progress; + /// Callback for power button tap (only shown in 2-line mode when provided) + final VoidCallback? onPowerToggle; + + /// Whether the player is currently powered on + final bool isPoweredOn; + const MiniPlayerContent({ super.key, required this.primaryText, this.secondaryText, + this.tertiaryText, this.imageUrl, required this.playerName, required this.backgroundColor, @@ -70,19 +90,35 @@ class MiniPlayerContent extends StatelessWidget { this.isHint = false, this.showProgress = false, this.progress = 0.0, + this.onPowerToggle, + this.isPoweredOn = true, }); @override Widget build(BuildContext context) { final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty; + final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty; + final is2LineMode = !hasTertiaryLine; + final showPowerButton = is2LineMode && onPowerToggle != null; final slidePixels = slideOffset * width; - // Calculate text width (leaves room for right padding) - final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding; + // Use 3-line layout when tertiary exists, 2-line centered otherwise + final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line; + final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line; + + // Calculate right padding (extra space for power button in 2-line mode) + final rightPadding = showPowerButton + ? MiniPlayerLayout.powerButtonSize + 8.0 + : MiniPlayerLayout.textRightPadding; // Darkened background for icon area (matches DeviceSelectorBar) final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!; + // Font size for primary text (larger in 2-line mode) + final primaryFontSize = is2LineMode + ? MiniPlayerLayout.primaryFontSize2Line + : MiniPlayerLayout.primaryFontSize; + return SizedBox( width: width, height: MiniPlayerLayout.height, @@ -99,10 +135,10 @@ class MiniPlayerContent extends StatelessWidget { child: Container(color: backgroundColor), ), - // Art / Icon area + // Art / Icon area - centered vertically Positioned( left: slidePixels, - top: 0, + top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2, child: SizedBox( width: MiniPlayerLayout.artSize, height: MiniPlayerLayout.artSize, @@ -125,14 +161,14 @@ class MiniPlayerContent extends StatelessWidget { Positioned( left: MiniPlayerLayout.textLeft + slidePixels, top: hasSecondaryLine - ? MiniPlayerLayout.primaryTop - : (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2, - right: MiniPlayerLayout.textRightPadding - slidePixels, + ? primaryTop + : (MiniPlayerLayout.height - primaryFontSize) / 2, + right: rightPadding - slidePixels, child: Text( primaryText, style: TextStyle( color: textColor, - fontSize: MiniPlayerLayout.primaryFontSize, + fontSize: primaryFontSize, fontWeight: MiniPlayerLayout.primaryFontWeight, ), maxLines: 1, @@ -144,8 +180,8 @@ class MiniPlayerContent extends StatelessWidget { if (hasSecondaryLine) Positioned( left: MiniPlayerLayout.textLeft + slidePixels, - top: MiniPlayerLayout.secondaryTop, - right: MiniPlayerLayout.textRightPadding - slidePixels, + top: secondaryTop, + right: rightPadding - slidePixels, child: isHint ? Row( mainAxisSize: MainAxisSize.min, @@ -179,6 +215,44 @@ class MiniPlayerContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), + + // Tertiary text line (player name, only for 3-line layout) + if (hasTertiaryLine) + Positioned( + left: MiniPlayerLayout.textLeft + slidePixels, + top: MiniPlayerLayout.tertiaryTop, + right: MiniPlayerLayout.textRightPadding - slidePixels, + child: Text( + tertiaryText!, + style: TextStyle( + color: textColor, + fontSize: MiniPlayerLayout.tertiaryFontSize, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Power button (only for 2-line mode when callback provided) + // Matches PlayerCard power button style + if (showPowerButton) + Positioned( + right: 8.0 - slidePixels, + top: 0, + bottom: 0, + child: Center( + child: IconButton( + onPressed: onPowerToggle, + icon: Icon( + Icons.power_settings_new_rounded, + color: isPoweredOn ? textColor : textColor.withOpacity(0.5), + size: 20, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + ), ], ), ); diff --git a/lib/widgets/player/player_card.dart b/lib/widgets/player/player_card.dart index 493d57b5..45c46dc3 100644 --- a/lib/widgets/player/player_card.dart +++ b/lib/widgets/player/player_card.dart @@ -1,12 +1,19 @@ +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. -class PlayerCard extends StatelessWidget { +/// Supports horizontal swipe to adjust volume with a two-tone overlay. +class PlayerCard extends StatefulWidget { final dynamic player; final dynamic trackInfo; final String? albumArtUrl; @@ -20,10 +27,24 @@ class PlayerCard extends StatelessWidget { final VoidCallback? onPlayPause; final VoidCallback? onSkipNext; final VoidCallback? onPower; + final ValueChanged? onVolumeChange; + final ValueChanged? 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, @@ -39,174 +60,434 @@ class PlayerCard extends StatelessWidget { this.onPlayPause, this.onSkipNext, this.onPower, + this.onVolumeChange, + this.onPrecisionModeChanged, }); + @override + State createState() => _PlayerCardState(); +} + +class _PlayerCardState extends State { + bool _isDraggingVolume = false; + 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 _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) { // Match mini player dimensions - const double cardHeight = Dimensions.miniPlayerHeight; // 64px + const double cardHeight = Dimensions.miniPlayerHeight; const double artSize = cardHeight; const double borderRadius = Radii.xl; // 16px - same as mini player - return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Container( - height: cardHeight, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(borderRadius), - border: isGrouped - ? Border.all(color: groupBorderColor, width: 1.5) - : null, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Row( - children: [ - // Album art or speaker icon - same size for consistent text alignment - SizedBox( - width: artSize, - height: artSize, - child: albumArtUrl != null - ? CachedNetworkImage( - imageUrl: albumArtUrl!, - fit: BoxFit.cover, - memCacheWidth: 128, - memCacheHeight: 128, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (_, __) => _buildSpeakerIcon(), - errorWidget: (_, __, ___) => _buildSpeakerIcon(), - ) - : _buildSpeakerIcon(), - ), + // Colors for volume overlay (same as mini player progress bar) + final filledColor = widget.backgroundColor; + final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!; - // Player info - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Player name - Row( - children: [ - // Status indicator - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getStatusColor(), - ), - ), - Expanded( - child: Text( - player.name, - style: TextStyle( - color: textColor, - fontSize: 16, - fontWeight: FontWeight.w500, - fontFamily: 'Roboto', - decoration: TextDecoration.none, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // 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; + + return GestureDetector( + onTap: _isDraggingVolume ? null : widget.onTap, + onLongPress: _isDraggingVolume ? null : widget.onLongPress, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + onHorizontalDragCancel: _onDragCancel, + // Use Stack to render border ON TOP of content, preventing album art clipping + child: Stack( + children: [ + // Main card content (hidden when dragging volume) + if (!_isDraggingVolume) + _buildCardContent(cardHeight, artSize, borderRadius), + + // Volume overlay (shown when dragging) + if (_isDraggingVolume) + Container( + height: cardHeight, + decoration: BoxDecoration( + color: unfilledColor, + borderRadius: BorderRadius.circular(borderRadius), + // PERF Phase 4: Use pre-cached static BoxShadow + boxShadow: const [PlayerCard.cardShadow], + ), + clipBehavior: Clip.antiAlias, + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _dragVolumeLevel.clamp(0.0, 1.0), + heightFactor: 1.0, + child: Container( + decoration: BoxDecoration( + color: filledColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(borderRadius), + bottomLeft: Radius.circular(borderRadius), ), ), - ], - ), - const SizedBox(height: 2), - // Track name or status - Text( - _getSubtitle(context), - style: TextStyle( - color: textColor.withOpacity(0.6), - fontSize: 14, - fontFamily: 'Roboto', - decoration: TextDecoration.none, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ], + ), ), - ), - ), - // Transport controls - compact sizing to align with mini player - // Play/Pause and Next only shown when powered with content - if (player.available && player.powered && trackInfo != null) ...[ - // Play/Pause - slight nudge right - Transform.translate( - offset: const Offset(3, 0), - child: SizedBox( - width: 28, - height: 28, - child: IconButton( - icon: Icon( - isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, - color: textColor, - size: 28, + // Border overlay - renders ON TOP to prevent clipping by album art + if (widget.isGrouped) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: PlayerCard.groupBorderColor, width: 1.5), ), - onPressed: onPlayPause, - padding: EdgeInsets.zero, ), ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildCardContent(double cardHeight, double artSize, double borderRadius) { + return Container( + height: cardHeight, + decoration: BoxDecoration( + color: widget.backgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + // PERF Phase 4: Use pre-cached static BoxShadow + boxShadow: const [PlayerCard.cardShadow], + ), + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + // Album art or speaker icon - same size for consistent text alignment + SizedBox( + width: artSize, + height: artSize, + child: widget.albumArtUrl != null + ? CachedNetworkImage( + imageUrl: widget.albumArtUrl!, + fit: BoxFit.cover, + memCacheWidth: 128, + memCacheHeight: 128, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (_, __) => _buildSpeakerIcon(), + errorWidget: (_, __, ___) => _buildSpeakerIcon(), + ) + : _buildSpeakerIcon(), + ), + + // Player info + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Player name + Row( + children: [ + // Status indicator + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getStatusColor(), + ), + ), + Expanded( + child: Text( + widget.player.name, + style: TextStyle( + color: widget.textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + fontFamily: 'Roboto', + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + // Track name or status + Text( + _getSubtitle(context), + style: TextStyle( + color: widget.textColor.withOpacity(0.6), + fontSize: 14, + fontFamily: 'Roboto', + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - // Skip next - nudged right to close gap with power - Transform.translate( - offset: const Offset(6, 0), + ), + ), + + // Transport controls - compact sizing to align with mini player + // Play/Pause and Next only shown when powered with content + if (widget.player.available && widget.player.powered && widget.trackInfo != null) ...[ + // Play/Pause - slight nudge right + // Touch target increased to 44dp for accessibility (icon remains 28) + Transform.translate( + offset: const Offset(3, 0), + child: SizedBox( + width: 44, + height: 44, child: IconButton( icon: Icon( - Icons.skip_next_rounded, - color: textColor, + widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: widget.textColor, size: 28, ), - onPressed: onSkipNext, + onPressed: widget.onPlayPause, padding: EdgeInsets.zero, - constraints: const BoxConstraints(), ), ), - ], - // Power button - smallest - if (player.available) - IconButton( + ), + // Skip next - nudged right to close gap with power + Transform.translate( + offset: const Offset(6, 0), + child: IconButton( icon: Icon( - Icons.power_settings_new_rounded, - color: player.powered ? textColor : textColor.withOpacity(0.5), - size: 20, + Icons.skip_next_rounded, + color: widget.textColor, + size: 28, ), - onPressed: onPower, + onPressed: widget.onSkipNext, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), - - const SizedBox(width: 4), + ), ], - ), + // Power button - smallest + if (widget.player.available) + IconButton( + icon: Icon( + Icons.power_settings_new_rounded, + color: widget.player.powered ? widget.textColor : widget.textColor.withOpacity(0.5), + size: 20, + ), + onPressed: widget.onPower, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + + const SizedBox(width: 4), + ], ), ); } + void _onDragStart(DragStartDetails details) { + // 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 = startVolume; + }); + _lastDragPosition = details.globalPosition; + HapticFeedback.lightImpact(); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_isDraggingVolume || _cardWidth <= 0) return; + + // Track local X position for precision mode + _lastLocalX = details.localPosition.dx; + + // 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) { + setState(() { + _dragVolumeLevel = newVolume; + }); + + // Throttle API calls to prevent flooding (faster in precision mode) + final now = DateTime.now().millisecondsSinceEpoch; + final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs; + if (now - _lastVolumeUpdateTime >= throttleMs) { + _lastVolumeUpdateTime = now; + widget.onVolumeChange?.call(newVolume); + } + } + } + + 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; + }); + HapticFeedback.lightImpact(); + } + + void _onDragCancel() { + _exitPrecisionMode(); + _lastDragPosition = null; + setState(() { + _isDraggingVolume = false; + }); + } + Widget _buildSpeakerIcon() { // Darker shade of the card background to match album art square - final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!; + final iconBgColor = Color.lerp(widget.backgroundColor, Colors.black, 0.15)!; return Container( color: iconBgColor, child: Center( child: Icon( Icons.speaker_rounded, - color: textColor.withOpacity(0.4), + color: widget.textColor.withOpacity(0.4), size: 28, ), ), @@ -214,30 +495,30 @@ class PlayerCard extends StatelessWidget { } Color _getStatusColor() { - if (!player.available) { + if (!widget.player.available) { return Colors.grey.withOpacity(0.5); } - if (!player.powered) { + if (!widget.player.powered) { return Colors.grey; } - if (isPlaying) { + if (widget.isPlaying) { return Colors.green; } - if (trackInfo != null) { + if (widget.trackInfo != null) { return Colors.orange; // Has content but paused } return Colors.grey.shade400; // Idle } String _getSubtitle(BuildContext context) { - if (!player.available) { + if (!widget.player.available) { return S.of(context)!.playerStateUnavailable; } - if (!player.powered) { + if (!widget.player.powered) { return S.of(context)!.playerStateOff; } - if (trackInfo != null) { - return trackInfo.name ?? S.of(context)!.playerStateIdle; + if (widget.trackInfo != null) { + return widget.trackInfo.name ?? S.of(context)!.playerStateIdle; } return S.of(context)!.playerStateIdle; } diff --git a/lib/widgets/player/player_controls.dart b/lib/widgets/player/player_controls.dart index e1f6035f..8f2e4669 100644 --- a/lib/widgets/player/player_controls.dart +++ b/lib/widgets/player/player_controls.dart @@ -59,19 +59,25 @@ class PlayerControls extends StatelessWidget { if (isExpanded && expandedElementsOpacity > 0.1) _buildSecondaryButton( icon: Icons.shuffle_rounded, - color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5)) - .withOpacity(expandedElementsOpacity), + // Fix: compute final opacity once to avoid multiplicative opacity bug + color: shuffle == true + ? primaryColor.withOpacity(expandedElementsOpacity) + : textColor.withOpacity(0.5 * expandedElementsOpacity), onPressed: isLoadingQueue ? null : onToggleShuffle, ), if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)), // Previous - _buildControlButton( - icon: Icons.skip_previous_rounded, - color: textColor, - size: skipButtonSize, - onPressed: onPrevious, - useAnimation: isExpanded, + Semantics( + button: true, + label: 'Previous track', + child: _buildControlButton( + icon: Icons.skip_previous_rounded, + color: textColor, + size: skipButtonSize, + onPressed: onPrevious, + useAnimation: isExpanded, + ), ), SizedBox(width: _lerpDouble(0, 20, t)), @@ -80,12 +86,16 @@ class PlayerControls extends StatelessWidget { SizedBox(width: _lerpDouble(0, 20, t)), // Next - _buildControlButton( - icon: Icons.skip_next_rounded, - color: textColor, - size: skipButtonSize, - onPressed: onNext, - useAnimation: isExpanded, + Semantics( + button: true, + label: 'Next track', + child: _buildControlButton( + icon: Icons.skip_next_rounded, + color: textColor, + size: skipButtonSize, + onPressed: onNext, + useAnimation: isExpanded, + ), ), // Repeat (expanded only) @@ -94,10 +104,10 @@ class PlayerControls extends StatelessWidget { if (isExpanded && expandedElementsOpacity > 0.1) _buildSecondaryButton( icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, - color: (repeatMode != null && repeatMode != 'off' - ? primaryColor - : textColor.withOpacity(0.5)) - .withOpacity(expandedElementsOpacity), + // Fix: compute final opacity once to avoid multiplicative opacity bug + color: (repeatMode != null && repeatMode != 'off') + ? primaryColor.withOpacity(expandedElementsOpacity) + : textColor.withOpacity(0.5 * expandedElementsOpacity), onPressed: isLoadingQueue ? null : onCycleRepeat, ), ], @@ -151,21 +161,26 @@ class PlayerControls extends StatelessWidget { final bgColor = Color.lerp(Colors.transparent, primaryColor, progress); final iconColor = Color.lerp(textColor, backgroundColor, progress); - return GestureDetector( - onLongPress: onStop, - child: Container( - width: playButtonContainerSize, - height: playButtonContainerSize, - decoration: BoxDecoration( - color: bgColor, - shape: BoxShape.circle, - ), - child: IconButton( - icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded), - color: iconColor, - iconSize: playButtonSize, - onPressed: onPlayPause, - padding: EdgeInsets.zero, + return Semantics( + button: true, + label: isPlaying ? 'Pause' : 'Play', + hint: 'Long press to stop', + child: GestureDetector( + onLongPress: onStop, + child: Container( + width: playButtonContainerSize, + height: playButtonContainerSize, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded), + color: iconColor, + iconSize: playButtonSize, + onPressed: onPlayPause, + padding: EdgeInsets.zero, + ), ), ), ); diff --git a/lib/widgets/player/player_reveal_overlay.dart b/lib/widgets/player/player_reveal_overlay.dart index 402f47ec..02fa2b31 100644 --- a/lib/widgets/player/player_reveal_overlay.dart +++ b/lib/widgets/player/player_reveal_overlay.dart @@ -54,6 +54,13 @@ class PlayerRevealOverlayState extends State // 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(); @@ -231,6 +238,9 @@ class PlayerRevealOverlayState extends State 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( builder: (context, maProvider, child) { @@ -267,6 +277,31 @@ class PlayerRevealOverlayState extends State } }); + // 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) { @@ -307,9 +342,29 @@ class PlayerRevealOverlayState extends State // Show different hint for onboarding vs regular use final isOnboarding = widget.showOnboardingHints; - final hintText = isOnboarding - ? S.of(context)!.selectPlayerHint - : S.of(context)!.holdToSync; + + // PERF Phase 4: Use pre-computed hintColor + Widget buildHintRow(IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: hintColor, + ), + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + color: hintColor, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } return Transform.translate( offset: Offset(0, hintSlideOffset), @@ -317,25 +372,16 @@ class PlayerRevealOverlayState extends State padding: const EdgeInsets.only(bottom: 12), child: Material( type: MaterialType.transparency, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline, - size: 18, - color: colorScheme.onSurface.withOpacity(0.7), - ), - const SizedBox(width: 6), - Text( - hintText, - style: TextStyle( - color: colorScheme.onSurface.withOpacity(0.7), - fontSize: 16, - fontWeight: FontWeight.w500, + child: isOnboarding + ? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync), + const SizedBox(height: 4), + buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume), + ], ), - ), - ], - ), ), ), ); @@ -344,31 +390,23 @@ class PlayerRevealOverlayState extends State // 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); @@ -377,32 +415,33 @@ class PlayerRevealOverlayState extends State 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: player.isGrouped, - 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), + onSkipNext: () => maProvider.nextTrack(data.player.playerId), + onPower: () => maProvider.togglePower(data.player.playerId), + onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()), ), ), ); @@ -411,28 +450,14 @@ class PlayerRevealOverlayState extends State ) 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) @@ -443,43 +468,33 @@ class PlayerRevealOverlayState extends State 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: player.isGrouped, - 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); - }, + onSkipNext: () => maProvider.nextTrack(data.player.playerId), + onPower: () => maProvider.togglePower(data.player.playerId), + onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()), ), ), ); @@ -496,3 +511,24 @@ class PlayerRevealOverlayState extends State ); } } + +/// 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, + }); +} diff --git a/lib/widgets/player/queue_panel.dart b/lib/widgets/player/queue_panel.dart index faa7bdf8..d4bb7472 100644 --- a/lib/widgets/player/queue_panel.dart +++ b/lib/widgets/player/queue_panel.dart @@ -1,12 +1,19 @@ +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 '../../models/player.dart'; import '../../theme/design_tokens.dart'; import '../common/empty_state.dart'; -/// Panel that displays the current playback queue -class QueuePanel extends StatelessWidget { +/// Panel that displays the current playback queue with drag-to-reorder +/// and swipe-left-to-delete functionality. +/// +/// Uses custom AnimatedList implementation instead of great_list_view +/// to avoid grey screen bugs (ReorderableListView) and drag duplication bugs (great_list_view). +class QueuePanel extends StatefulWidget { final MusicAssistantProvider maProvider; final PlayerQueue? queue; final bool isLoading; @@ -16,6 +23,10 @@ class QueuePanel extends StatelessWidget { final double topPadding; final VoidCallback onClose; final VoidCallback onRefresh; + final ValueChanged? onDraggingChanged; + final VoidCallback? onSwipeStart; + final ValueChanged? onSwipeUpdate; // dx delta from start + final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement const QueuePanel({ super.key, @@ -28,151 +39,739 @@ class QueuePanel extends StatelessWidget { required this.topPadding, required this.onClose, required this.onRefresh, + this.onDraggingChanged, + this.onSwipeStart, + this.onSwipeUpdate, + this.onSwipeEnd, }); + @override + State createState() => _QueuePanelState(); +} + +class _QueuePanelState extends State { + List _items = []; + final GlobalKey _stackKey = GlobalKey(); + + // Drag state + int? _dragIndex; + int? _dragStartIndex; + double _dragY = 0; // Y position of dragged item relative to Stack + double _dragStartY = 0; // Y position when drag started (global) + double _dragOffsetInItem = 0; // Offset of touch point within item + QueueItem? _dragItem; + double _itemHeight = 64.0; + bool _pendingReorder = false; // True while waiting for API confirmation + Timer? _pendingReorderTimer; + + // Optimistic update for tap-to-skip + int? _optimisticCurrentIndex; + + // Swipe-to-close tracking (raw pointer events to bypass gesture arena) + Offset? _swipeStart; + Offset? _swipeLast; + int? _swipeLastTime; // milliseconds since epoch + bool _isSwiping = false; + bool _swipeLocked = false; // Lock direction once established + static const _swipeMinDistance = 8.0; // Min distance to start tracking (reduced for responsiveness) + static const _edgeDeadZone = 48.0; // Dead zone for Android back gesture (increased) + + // Track last touch start position for edge detection in Dismissible + // Static so it persists across widget rebuilds + static double? _lastPointerDownX; + static double? _lastScreenWidth; + + // Velocity tracking with multiple samples for smoother calculation + final List<_VelocitySample> _velocitySamples = []; + static const _maxVelocitySamples = 5; + + + @override + void initState() { + super.initState(); + _items = List.from(widget.queue?.items ?? []); + } + + @override + void didUpdateWidget(QueuePanel oldWidget) { + super.didUpdateWidget(oldWidget); + + // Clear optimistic state when server catches up + if (_optimisticCurrentIndex != null) { + final serverIndex = widget.queue?.currentIndex; + if (serverIndex == _optimisticCurrentIndex) { + // Server caught up - clear optimistic state + _optimisticCurrentIndex = null; + } + } + + // Don't update items while dragging or waiting for reorder confirmation + if (_dragIndex != null || _pendingReorder) return; + + final newItems = widget.queue?.items ?? []; + + // Only sync when player changes or item count changes (additions/deletions) + // Don't sync on order changes - trust our local order after user reorders + // This prevents stale server state from overwriting recent local changes + final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId; + final countChanged = newItems.length != _items.length; + + if (playerChanged || countChanged) { + setState(() { + _items = List.from(newItems); + }); + } + } + + @override + void dispose() { + _pendingReorderTimer?.cancel(); + super.dispose(); + } + + String _formatDuration(Duration? duration) { + if (duration == null) return ''; + final totalSeconds = duration.inSeconds; + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + void _handleDelete(QueueItem item, int index) async { + // Store item for potential rollback + final deletedItem = item; + final deletedIndex = index; + + // Optimistic update - remove from local list + setState(() { + _items.removeAt(index); + }); + + // Call API with error handling + final playerId = widget.queue?.playerId; + if (playerId != null) { + try { + await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId); + debugPrint('QueuePanel: Delete successful for ${item.track.name}'); + } catch (e) { + debugPrint('QueuePanel: Error deleting queue item: $e'); + // Rollback: re-insert the item or refresh from server + // Refresh is safer as queue may have changed + widget.onRefresh(); + } + } + } + + void _handleClearQueue() async { + final playerId = widget.queue?.playerId; + if (playerId == null) return; + + // Optimistic update - clear local list + setState(() { + _items.clear(); + }); + + // Call API + try { + await widget.maProvider.api?.queueCommandClear(playerId); + } catch (e) { + debugPrint('QueuePanel: Error clearing queue: $e'); + // Refresh to restore if failed + widget.onRefresh(); + } + } + + void _resetSwipeState() { + _swipeStart = null; + _swipeLast = null; + _swipeLastTime = null; + _isSwiping = false; + _swipeLocked = false; + _velocitySamples.clear(); + // NOTE: Don't reset _lastPointerDownX - it's needed by confirmDismiss + // which may be called after pointer up + } + + /// Check if x position is in the edge dead zone (Android back gesture area) + static bool _isInEdgeZone(double x, double screenWidth) { + return x < _edgeDeadZone || x > screenWidth - _edgeDeadZone; + } + + /// Check if last touch started in edge zone (for Dismissible to check) + static bool get lastTouchWasInEdgeZone { + if (_lastPointerDownX == null || _lastScreenWidth == null) return false; + return _isInEdgeZone(_lastPointerDownX!, _lastScreenWidth!); + } + + /// Build an invisible edge absorber that blocks horizontal drags from triggering Dismissible + Widget _buildEdgeAbsorber({required bool left}) { + return Positioned( + left: left ? 0 : null, + right: left ? null : 0, + top: 0, + bottom: 0, + width: _edgeDeadZone, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) {}, // Absorb horizontal drags + onHorizontalDragUpdate: (_) {}, + onHorizontalDragEnd: (_) {}, + ), + ); + } + + void _addVelocitySample(Offset position, int timeMs) { + _velocitySamples.add(_VelocitySample(position, timeMs)); + if (_velocitySamples.length > _maxVelocitySamples) { + _velocitySamples.removeAt(0); + } + } + + double _calculateAverageVelocity() { + if (_velocitySamples.length < 2) return 0.0; + + // Use weighted average of recent samples (more recent = higher weight) + double totalVelocity = 0.0; + double totalWeight = 0.0; + + for (int i = 1; i < _velocitySamples.length; i++) { + final prev = _velocitySamples[i - 1]; + final curr = _velocitySamples[i]; + final dt = curr.timeMs - prev.timeMs; + if (dt > 0 && dt < 100) { // Only use samples within 100ms + final dx = curr.position.dx - prev.position.dx; + final velocity = (dx / dt) * 1000; // px/s + final weight = i.toDouble(); // Later samples get higher weight + totalVelocity += velocity * weight; + totalWeight += weight; + } + } + + return totalWeight > 0 ? totalVelocity / totalWeight : 0.0; + } + + void _startDrag(int index, BuildContext itemContext, Offset globalPosition) { + if (_dragIndex != null) return; + + // Clear any pending swipe state to prevent conflicts + _resetSwipeState(); + + final RenderBox itemBox = itemContext.findRenderObject() as RenderBox; + final Offset itemGlobalPos = itemBox.localToGlobal(Offset.zero); + _itemHeight = itemBox.size.height; + + // Get Stack's global position for proper coordinate conversion + final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; + final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero; + + // Calculate initial position relative to Stack + _dragY = itemGlobalPos.dy - stackGlobalPos.dy; + _dragStartY = globalPosition.dy; + // Remember where in the item the touch started (for smooth following) + _dragOffsetInItem = globalPosition.dy - itemGlobalPos.dy; + + setState(() { + _dragIndex = index; + _dragStartIndex = index; + _dragItem = _items[index]; + }); + + // Haptic feedback on drag start + HapticFeedback.mediumImpact(); + + // Notify parent that drag started + widget.onDraggingChanged?.call(true); + } + + void _updateDragPointer(Offset globalPosition) { + if (_dragIndex == null) return; + + // Get Stack's global position + final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; + final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero; + + // Calculate overlay position relative to Stack, accounting for touch offset + _dragY = globalPosition.dy - stackGlobalPos.dy - _dragOffsetInItem; + + // Calculate which index we're hovering over based on movement from start + final totalOffset = globalPosition.dy - _dragStartY; + final indexOffset = (totalOffset / _itemHeight).round(); + final targetIndex = (_dragStartIndex! + indexOffset).clamp(0, _items.length - 1); + + if (targetIndex != _dragIndex) { + // Reorder items in the list + setState(() { + final item = _items.removeAt(_dragIndex!); + _items.insert(targetIndex, item); + _dragIndex = targetIndex; + }); + // Haptic feedback on each reorder + HapticFeedback.selectionClick(); + } else { + // Just update position + setState(() {}); + } + } + + void _endDrag() async { + if (_dragIndex == null || _dragStartIndex == null) return; + + final newIndex = _dragIndex!; + final originalIndex = _dragStartIndex!; + final item = _items[newIndex]; + final positionChanged = originalIndex != newIndex; + + // Cancel any existing timer to prevent stacking + _pendingReorderTimer?.cancel(); + + setState(() { + _dragIndex = null; + _dragStartIndex = null; + _dragItem = null; + // Block didUpdateWidget while waiting for server confirmation + if (positionChanged) _pendingReorder = true; + }); + + // Haptic feedback on drop + HapticFeedback.lightImpact(); + + // Notify parent that drag ended + widget.onDraggingChanged?.call(false); + + // Call API if position changed + if (positionChanged) { + final playerId = widget.queue?.playerId; + // API uses relative shift: positive = down, negative = up + final posShift = newIndex - originalIndex; + debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})'); + if (playerId != null) { + try { + await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift); + debugPrint('QueuePanel: Move API call completed successfully'); + // Allow updates again after a delay for server state to propagate + _pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () { + if (mounted) { + setState(() { + _pendingReorder = false; + }); + } + }); + } catch (e) { + debugPrint('QueuePanel: Move API error: $e'); + // Clear pending state immediately on error + if (mounted) { + setState(() { + _pendingReorder = false; + }); + } + // Refresh queue from server to get correct state + widget.onRefresh(); + } + } else { + debugPrint('QueuePanel: playerId is null, cannot move'); + // Clear pending state + if (mounted) { + setState(() { + _pendingReorder = false; + }); + } + } + } + } + + void _cancelDrag() { + if (_dragStartIndex == null) return; + + // Restore original position + if (_dragIndex != null && _dragIndex != _dragStartIndex) { + setState(() { + final item = _items.removeAt(_dragIndex!); + _items.insert(_dragStartIndex!, item); + }); + } + + setState(() { + _dragIndex = null; + _dragStartIndex = null; + _dragItem = null; + }); + + // Notify parent that drag ended + widget.onDraggingChanged?.call(false); + } + + void _handleTapToSkip(QueueItem item, int index, bool isCurrentItem) async { + if (isCurrentItem) return; + + final playerId = widget.queue?.playerId; + if (playerId == null) return; + + // Optimistic update: immediately show tapped track as current + setState(() { + _optimisticCurrentIndex = index; + }); + + // Call API + try { + await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId); + } catch (e) { + debugPrint('QueuePanel: Error playing index: $e'); + } + + // Clear optimistic state after delay - server state will take over + await Future.delayed(const Duration(milliseconds: 1500)); + if (mounted) { + setState(() { + _optimisticCurrentIndex = null; + }); + } + } + @override Widget build(BuildContext context) { - return Container( - color: backgroundColor, - child: Column( - children: [ - // Header - Container( - padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md), - onPressed: onClose, - padding: Spacing.paddingAll12, - ), - const Spacer(), - Text( - 'Queue', - style: TextStyle( - color: textColor, - fontSize: 18, - fontWeight: FontWeight.w600, + // Use Listener for raw pointer events to bypass gesture arena + // This allows swipe-to-close to work even over ListView/Dismissible + return Listener( + onPointerDown: (event) { + // Store touch position for edge detection (used by Dismissible confirmDismiss) + // Use static variables so they persist across rebuilds + _lastPointerDownX = event.position.dx; + _lastScreenWidth = MediaQuery.of(context).size.width; + + // Check if touch started in edge zone (Android back gesture area) + final startedInEdgeZone = _isInEdgeZone(event.position.dx, _lastScreenWidth!); + + // Don't track swipe while dragging a queue item + if (_dragIndex == null && !startedInEdgeZone) { + _swipeStart = event.position; + _swipeLast = event.position; + _swipeLastTime = DateTime.now().millisecondsSinceEpoch; + _isSwiping = false; + _swipeLocked = false; + _velocitySamples.clear(); + _addVelocitySample(event.position, _swipeLastTime!); + } else if (startedInEdgeZone) { + // Clear swipe state for edge touches + _swipeStart = null; + } + }, + onPointerMove: (event) { + // Ignore swipes from edge zone (let Android back gesture handle it) + if (_swipeStart == null || _dragIndex != null) return; + + final dx = event.position.dx - _swipeStart!.dx; + final dy = (event.position.dy - _swipeStart!.dy).abs(); + final now = DateTime.now().millisecondsSinceEpoch; + + // Track all move events for velocity calculation + _addVelocitySample(event.position, now); + _swipeLast = event.position; + _swipeLastTime = now; + + // Once direction is locked, maintain it (prevents accidental cancellation) + if (_swipeLocked && _isSwiping) { + widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity)); + return; + } + + // Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2) + final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2; + + if (isHorizontal && dx > 0) { + // Horizontal swipe right - close gesture + if (!_isSwiping) { + _isSwiping = true; + _swipeLocked = true; // Lock direction once swipe starts + widget.onSwipeStart?.call(); + } + widget.onSwipeUpdate?.call(dx); + } else if (!_swipeLocked && dx.abs() > _swipeMinDistance) { + // Direction established as vertical - don't start swipe + _swipeLocked = true; // Lock as non-swipe + } + }, + onPointerUp: (event) { + if (_isSwiping && _swipeStart != null) { + final velocity = _calculateAverageVelocity(); + final totalDx = event.position.dx - _swipeStart!.dx; + widget.onSwipeEnd?.call(velocity, totalDx); + } + _resetSwipeState(); + }, + onPointerCancel: (_) { + if (_isSwiping) { + widget.onSwipeEnd?.call(0, 0); // Cancel - no action + } + _resetSwipeState(); + }, + child: Container( + color: widget.backgroundColor, + child: Column( + children: [ + // Header + Container( + padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md), + onPressed: widget.onClose, + padding: Spacing.paddingAll12, ), - ), - const Spacer(), - IconButton( - icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm), - onPressed: onRefresh, - padding: Spacing.paddingAll12, - ), - ], + const Spacer(), + Text( + 'Queue', + style: TextStyle( + color: widget.textColor, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + // Clear queue button + IconButton( + icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm), + onPressed: _handleClearQueue, + padding: Spacing.paddingAll12, + tooltip: 'Clear queue', + ), + ], + ), ), - ), - // Queue content - Expanded( - child: isLoading - ? Center(child: CircularProgressIndicator(color: primaryColor)) - : queue == null || queue!.items.isEmpty - ? _buildEmptyState(context) - : _buildQueueList(), - ), - ], + // Queue content + Expanded( + child: widget.isLoading + ? Center(child: CircularProgressIndicator(color: widget.primaryColor)) + : widget.queue == null || _items.isEmpty + ? EmptyState.queue(context: context) + : Listener( + // Capture pointer events anywhere while dragging + onPointerMove: (event) { + if (_dragIndex != null) { + _updateDragPointer(event.position); + } + }, + onPointerUp: (event) { + if (_dragIndex != null) { + _endDrag(); + } + }, + onPointerCancel: (event) { + if (_dragIndex != null) { + _cancelDrag(); + } + }, + child: Stack( + key: _stackKey, + children: [ + _buildQueueList(), + // Dragged item overlay + if (_dragIndex != null && _dragItem != null) + Positioned( + left: 8, + right: 8, + top: _dragY, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(8), + child: _buildQueueItemContent(_dragItem!, _dragIndex!, false, false), + ), + ), + // Edge gesture absorbers - block horizontal drags from screen edges + // This prevents Android back gesture from triggering Dismissible + _buildEdgeAbsorber(left: true), + _buildEdgeAbsorber(left: false), + ], + ), + ), + ), + ], + ), ), ); } - Widget _buildEmptyState(BuildContext context) { - return EmptyState.queue(context: context); - } - Widget _buildQueueList() { - final currentIndex = queue!.currentIndex ?? 0; - final items = queue!.items; + // Use optimistic index if set, otherwise use server state + final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0; return ListView.builder( + key: const PageStorageKey('queue_list'), padding: Spacing.paddingH8, - cacheExtent: 500, - addAutomaticKeepAlives: false, // Items don't need individual keep-alive - addRepaintBoundaries: false, // We add RepaintBoundary manually - itemCount: items.length, + // Disable scrolling while dragging to prevent gesture conflict + physics: _dragIndex != null + ? const NeverScrollableScrollPhysics() + : const AlwaysScrollableScrollPhysics(), + itemCount: _items.length, itemBuilder: (context, index) { - final item = items[index]; + final item = _items[index]; final isCurrentItem = index == currentIndex; final isPastItem = index < currentIndex; - final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80); - - return RepaintBoundary( - child: Opacity( - opacity: isPastItem ? 0.5 : 1.0, - child: Container( - margin: EdgeInsets.symmetric(vertical: Spacing.xxs), - decoration: BoxDecoration( - color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: ListTile( - dense: true, - leading: ClipRRect( - borderRadius: BorderRadius.circular(Radii.sm), - child: SizedBox( - width: 44, - height: 44, - child: imageUrl != null - ? CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - // PERF: 4x cache size for 44x44 display, zero fade for scrolling - memCacheWidth: 176, - memCacheHeight: 176, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - placeholder: (context, url) => Container( - color: textColor.withOpacity(0.1), - child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), - ), - errorWidget: (context, url, error) => Container( - color: textColor.withOpacity(0.1), - child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), - ), - ) - : Container( - color: textColor.withOpacity(0.1), - child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), - ), + final isDragging = _dragIndex == index; + + return Opacity( + opacity: isDragging ? 0.3 : 1.0, + child: _buildDismissibleItem(item, index, isCurrentItem, isPastItem), + ); + }, + ); + } + + Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) { + return Dismissible( + key: ValueKey(item.queueItemId), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: Colors.red.shade700, + borderRadius: BorderRadius.zero, + ), + child: const Icon(Icons.delete_outline, color: Colors.white, size: 24), + ), + confirmDismiss: (direction) async { + // Block dismissal if swipe started from screen edge (Android back gesture) + if (lastTouchWasInEdgeZone) return false; + // Don't allow dismissing the currently playing item + return !isCurrentItem; + }, + onDismissed: (direction) => _handleDelete(item, index), + child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem), + ); + } + + Widget _buildQueueItemWithDragHandle(QueueItem item, int index, bool isCurrentItem, bool isPastItem) { + return Builder( + builder: (itemContext) => _buildQueueItemContent( + item, + index, + isCurrentItem, + isPastItem, + dragHandle: isCurrentItem + ? SizedBox( + width: 48, + height: 48, + child: Center( + child: Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20), ), - ), - title: Text( - item.track.name, - style: TextStyle( - color: isCurrentItem ? primaryColor : textColor, - fontSize: 14, - fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal, + ) + : SizedBox( + width: 48, + height: 48, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (event) { + _startDrag(index, itemContext, event.position); + }, + // Move/up/cancel handled by parent Listener on Stack + child: Center( + child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - subtitle: item.track.artists != null && item.track.artists!.isNotEmpty - ? Text( - item.track.artists!.first.name, - style: TextStyle( - color: textColor.withOpacity(0.6), - fontSize: 12, + ), + ); + } + + Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) { + final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80); + final duration = _formatDuration(item.track.duration); + + return RepaintBoundary( + child: Opacity( + opacity: isPastItem ? 0.5 : 1.0, + child: Container( + margin: isCurrentItem ? const EdgeInsets.symmetric(horizontal: 8, vertical: 2) : EdgeInsets.zero, + decoration: BoxDecoration( + color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor, + borderRadius: isCurrentItem ? BorderRadius.circular(12) : BorderRadius.zero, + ), + child: ListTile( + dense: true, + // Compensate for current item's 8px margin on both sides + // Left: Non-current: 8+0+16=24px, Current: 8+8+8=24px + // Right: Non-current: 8+0+8+14=30px, Current: 8+8+0+14=30px + contentPadding: EdgeInsets.only( + left: isCurrentItem ? 8 : 16, + right: isCurrentItem ? 0 : 8, + ), + leading: ClipRRect( + borderRadius: BorderRadius.circular(Radii.sm), + child: SizedBox( + width: 44, + height: 44, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + memCacheWidth: 176, + memCacheHeight: 176, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => Container( + color: widget.textColor.withOpacity(0.1), + child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20), + ), + errorWidget: (context, url, error) => Container( + color: widget.textColor.withOpacity(0.1), + child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20), + ), + ) + : Container( + color: widget.textColor.withOpacity(0.1), + child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: isCurrentItem - ? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20) - : null, - onTap: () { - // TODO: Jump to this track in queue - // Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint - // Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it - // Alternative: Could use multiple next() calls but that's inefficient and unreliable - }, + ), ), + title: Text( + item.track.name, + style: TextStyle( + color: isCurrentItem ? widget.primaryColor : widget.textColor, + fontSize: 14, + fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: item.track.artists != null && item.track.artists!.isNotEmpty + ? Text( + item.track.artists!.first.name, + style: TextStyle( + color: widget.textColor.withOpacity(0.6), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (duration.isNotEmpty) + Text( + duration, + style: TextStyle( + color: widget.textColor.withOpacity(0.5), + fontSize: 12, + ), + ), + dragHandle ?? Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20), + ], + ), + onTap: () => _handleTapToSkip(item, index, isCurrentItem), ), - ), - ); - }, + ), + ), ); } } + +/// Helper class for velocity tracking samples +class _VelocitySample { + final Offset position; + final int timeMs; + + _VelocitySample(this.position, this.timeMs); +} diff --git a/lib/widgets/player_selector.dart b/lib/widgets/player_selector.dart index 45f986ac..d4d70d5e 100644 --- a/lib/widgets/player_selector.dart +++ b/lib/widgets/player_selector.dart @@ -226,8 +226,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> { ); } - // Check if this player is part of a sync group - final isGrouped = player.isGrouped; + // Check if this player is manually synced (not a pre-configured group) + final isGrouped = maProvider.isPlayerManuallySynced(player.playerId); // Pastel yellow for grouped players const groupBorderColor = Color(0xFFFFF59D); diff --git a/lib/widgets/playlist_card.dart b/lib/widgets/playlist_card.dart new file mode 100644 index 00000000..e6cf677a --- /dev/null +++ b/lib/widgets/playlist_card.dart @@ -0,0 +1,122 @@ +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 '../theme/theme_provider.dart'; +import '../utils/page_transitions.dart'; + +class PlaylistCard extends StatelessWidget { + 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 + Widget build(BuildContext context) { + final maProvider = context.read(); + final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; + final cacheSize = imageCacheSize ?? 256; + + return RepaintBoundary( + child: GestureDetector( + onTap: onTap ?? () { + // Update adaptive colors immediately on tap + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PlaylistDetailsScreen( + playlist: playlist, + heroTagSuffix: heroTagSuffix, + initialImageUrl: imageUrl, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Playlist artwork + AspectRatio( + aspectRatio: 1.0, + child: Hero( + tag: HeroTags.playlistCover + (playlist.uri ?? 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 + (playlist.uri ?? playlist.itemId) + suffix, + child: Material( + color: Colors.transparent, + child: Text( + playlist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + // Owner/track count + Text( + playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/playlist_row.dart b/lib/widgets/playlist_row.dart new file mode 100644 index 00000000..81c2c2cf --- /dev/null +++ b/lib/widgets/playlist_row.dart @@ -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> Function() loadPlaylists; + final String? heroTagSuffix; + final double? rowHeight; + final List? Function()? getCachedPlaylists; + + const PlaylistRow({ + super.key, + required this.title, + required this.loadPlaylists, + this.heroTagSuffix, + this.rowHeight, + this.getCachedPlaylists, + }); + + @override + State createState() => _PlaylistRowState(); +} + +class _PlaylistRowState extends State with AutomaticKeepAliveClientMixin { + List _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 _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 playlists) { + if (!mounted) return; + final maProvider = context.read(); + + 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, + ); + } +} diff --git a/lib/widgets/podcast_card.dart b/lib/widgets/podcast_card.dart new file mode 100644 index 00000000..e9110b8f --- /dev/null +++ b/lib/widgets/podcast_card.dart @@ -0,0 +1,140 @@ +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 '../theme/theme_provider.dart'; +import '../utils/page_transitions.dart'; + +class PodcastCard extends StatelessWidget { + 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 + Widget build(BuildContext context) { + final maProvider = context.read(); + // Use provider's getPodcastImageUrl which includes iTunes cache + final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; + + // PERF: Use appropriate cache size based on display size + final cacheSize = imageCacheSize ?? 256; + + return RepaintBoundary( + child: GestureDetector( + onTap: onTap ?? () { + // Update adaptive colors immediately on tap + updateAdaptiveColorsFromImage(context, imageUrl); + Navigator.push( + context, + FadeSlidePageRoute( + child: PodcastDetailScreen( + podcast: podcast, + heroTagSuffix: heroTagSuffix, + initialImageUrl: imageUrl, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Podcast artwork - square with rounded corners + AspectRatio( + aspectRatio: 1.0, + child: Hero( + tag: HeroTags.podcastCover + (podcast.uri ?? 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 + (podcast.uri ?? podcast.itemId) + suffix, + child: Material( + color: Colors.transparent, + child: Text( + podcast.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + // Podcast author (if available) + Text( + _getAuthor(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(); + } +} diff --git a/lib/widgets/podcast_row.dart b/lib/widgets/podcast_row.dart new file mode 100644 index 00000000..e85dae02 --- /dev/null +++ b/lib/widgets/podcast_row.dart @@ -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> Function() loadPodcasts; + final String? heroTagSuffix; + final double? rowHeight; + /// Optional: synchronous getter for cached data (for instant display) + final List? Function()? getCachedPodcasts; + + const PodcastRow({ + super.key, + required this.title, + required this.loadPodcasts, + this.heroTagSuffix, + this.rowHeight, + this.getCachedPodcasts, + }); + + @override + State createState() => _PodcastRowState(); +} + +class _PodcastRowState extends State with AutomaticKeepAliveClientMixin { + List _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 _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 podcasts) { + if (!mounted) return; + final maProvider = context.read(); + + // 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, + ); + } +} diff --git a/lib/widgets/radio_station_card.dart b/lib/widgets/radio_station_card.dart new file mode 100644 index 00000000..4fe55079 --- /dev/null +++ b/lib/widgets/radio_station_card.dart @@ -0,0 +1,109 @@ +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'; + +class RadioStationCard extends StatelessWidget { + 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 + Widget build(BuildContext context) { + final maProvider = context.read(); + final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; + final cacheSize = imageCacheSize ?? 256; + + return RepaintBoundary( + child: GestureDetector( + onTap: onTap ?? () { + HapticFeedback.mediumImpact(); + // Play the radio station on selected player + final selectedPlayer = maProvider.selectedPlayer; + if (selectedPlayer != null) { + maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Radio station artwork - circular for radio + AspectRatio( + aspectRatio: 1.0, + child: Hero( + tag: HeroTags.radioCover + (radioStation.uri ?? 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 + (radioStation.uri ?? radioStation.itemId) + suffix, + child: Material( + color: Colors.transparent, + child: Text( + radioStation.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.15, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/radio_station_row.dart b/lib/widgets/radio_station_row.dart new file mode 100644 index 00000000..a62506bb --- /dev/null +++ b/lib/widgets/radio_station_row.dart @@ -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> Function() loadRadioStations; + final String? heroTagSuffix; + final double? rowHeight; + final List? Function()? getCachedRadioStations; + + const RadioStationRow({ + super.key, + required this.title, + required this.loadRadioStations, + this.heroTagSuffix, + this.rowHeight, + this.getCachedRadioStations, + }); + + @override + State createState() => _RadioStationRowState(); +} + +class _RadioStationRowState extends State with AutomaticKeepAliveClientMixin { + List _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 _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 stations) { + if (!mounted) return; + final maProvider = context.read(); + + 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, + ); + } +} diff --git a/lib/widgets/series_row.dart b/lib/widgets/series_row.dart index e452f4ff..c0266f67 100644 --- a/lib/widgets/series_row.dart +++ b/lib/widgets/series_row.dart @@ -12,12 +12,15 @@ class SeriesRow extends StatefulWidget { final String title; final Future> Function() loadSeries; final double? rowHeight; + /// Optional: synchronous getter for cached data (for instant display) + final List? 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 with AutomaticKeepAliveClientMixin { - late Future> _seriesFuture; - List? _cachedSeries; + List _series = []; + bool _isLoading = true; bool _hasLoaded = false; // Cache for series cover images @@ -43,16 +46,34 @@ class _SeriesRowState extends State 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 _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 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 with AutomaticKeepAliveClientMixi } } + /// Precache series cover images for smooth hero animations + void _precacheSeriesCovers(List 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 _extractSeriesColors(String seriesId, List covers) async { if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return; @@ -127,10 +163,68 @@ class _SeriesRowState extends State 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(); @@ -156,73 +250,7 @@ class _SeriesRowState extends State with AutomaticKeepAliveClientMixi ), ), Expanded( - child: FutureBuilder>( - 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(), + ), ), ), ), diff --git a/lib/widgets/track_row.dart b/lib/widgets/track_row.dart index 39f47311..480f9739 100644 --- a/lib/widgets/track_row.dart +++ b/lib/widgets/track_row.dart @@ -9,12 +9,15 @@ class TrackRow extends StatefulWidget { final String title; final Future> Function() loadTracks; final double? rowHeight; + /// Optional: synchronous getter for cached data (for instant display) + final List? 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 with AutomaticKeepAliveClientMixin { - late Future> _tracksFuture; - List? _cachedTracks; + List _tracks = []; + bool _isLoading = true; bool _hasLoaded = false; @override @@ -32,25 +35,111 @@ class _TrackRowState extends State 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 _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 tracks) { + if (!mounted) return; + final maProvider = context.read(); + + 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 with AutomaticKeepAliveClientMixin ), ), Expanded( - child: FutureBuilder>( - 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), ), ], ), diff --git a/lib/widgets/volume_control.dart b/lib/widgets/volume_control.dart index abe60ec7..8162cd37 100644 --- a/lib/widgets/volume_control.dart +++ b/lib/widgets/volume_control.dart @@ -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 createState() => _VolumeControlState(); @@ -25,11 +27,34 @@ class _VolumeControlState extends State { 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 _initLocalPlayer() async { @@ -43,7 +68,7 @@ class _VolumeControlState extends State { } _volumeListener = FlutterVolumeController.addListener((volume) { - if (mounted && _isLocalPlayer) { + if (mounted && _isLocalPlayer && !_isDragging) { setState(() { _systemVolume = volume; }); @@ -51,12 +76,99 @@ class _VolumeControlState extends State { }); } + Future _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 _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(); @@ -68,118 +180,262 @@ class _VolumeControlState extends State { _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; - }); - }, - 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; - }); - } - } - } + 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(), + ), + ), + ), + ], + ); }, ), - ), ), ), - 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; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6a19a7d4..5d86b53c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: ensemble description: A minimalistic Music Assistant client built with Flutter publish_to: 'none' -version: 2.7.3-beta +version: 2.8.7-beta+45-remote-access environment: sdk: '>=3.0.0 <4.0.0' @@ -32,6 +32,7 @@ dependencies: # Storage shared_preferences: ^2.2.2 + flutter_secure_storage: ^9.2.2 drift: ^2.22.0 drift_flutter: 0.2.0 path_provider: ^2.1.5