Compare commits

...
224 Commits
Author SHA1 Message Date
Chris 44f35d1a26 WIP on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
Chris 6918569a82 index on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
ChrisandClaude Opus 4.5 8cf35cfe78 Add robust reconnection for remote mode
Bridge improvements:
- Increase max reconnect attempts to 10 with exponential backoff (2s-60s)
- Auto-recover from failed state when app tries to connect
- Health check triggers retry every 60s when in failed state
- Track last reconnect attempt time for smarter retries

App improvements:
- Retry 5 times with exponential backoff on resume (remote mode)
- Add 30s periodic reconnect timer when disconnected in remote mode
- Timer auto-stops on successful connection
- Multiple retry layers ensure eventual recovery

This fixes the issue where network drops would leave the app
permanently disconnected even after network recovery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:40:53 +00:00
ChrisandClaude Opus 4.5 42bc1ed16c Fix remote connection stability issues
- Add 30s server hello timeout to prevent indefinite hangs
- Queue ICE candidates until remote description is set
- Fix stuck "connecting" state blocking future connections
- Properly notify clients when WebRTC fails with error codes
- Close WS clients immediately on WebRTC failure (not cache stale hello)
- Reject new connections during reconnection or failed state
- Add _resetConnection() for clean retry scenarios

WebSocket close codes:
- 4000: WebRTC not healthy
- 4001: Connection lost, reconnecting
- 4002: Reconnection in progress
- 4003: Connection failed
- 4004: Bridge terminated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:54:38 +00:00
ChrisandClaude Opus 4.5 d908cf93a2 Remove HA add-on note - too busy
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:03:43 +00:00
ChrisandClaude Opus 4.5 e21e1eb4fd Update remote ID hint format and add HA add-on note
- Change placeholder to realistic format: ABCD1234-EFGH5-6IJKL-MNOP7890
- Add note: "For Home Assistant add-on users only"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:43:07 +00:00
ChrisandClaude Opus 4.5 9aa81ef09f Make QR button same height as remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:33:55 +00:00
ChrisandClaude Opus 4.5 8d2adb6fc5 Style QR button to match remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:28:13 +00:00
ChrisandClaude Opus 4.5 99ee131712 Move QR scanner to separate button beside remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:15:27 +00:00
ChrisandClaude Opus 4.5 f51ea9cb22 Add QR code scanner for Remote Access ID
- Add QR scanner screen with camera preview and scan overlay
- Wire up QR button to remote ID field on login screen
- Add mobile_scanner dependency
- Add localization strings for scanner UI
- Handle various QR code formats:
  - Raw remote ID
  - ma://remote/<id> URL scheme
  - music-assistant.io URLs with cloudInstanceId param

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:04:42 +00:00
ChrisandClaude Opus 4.5 9773f28cf7 Fix ICE server URL parsing for Nabu Casa users
The signaling server returns URLs as an array for HA Cloud subscribers
but as a string for others. Handle both formats in IceServer.fromJson.

Fixes #31

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:51:32 +00:00
ChrisandClaude Opus 4.5 2856ca1e8b Implement remote connection audit fixes
- WebRTCConnection: Complete pending requests with error on disconnect/peer disconnect
- ErrorHandler: Add WebRTC-specific error types (remote, dataChannel, signaling)
- RemoteBridge: Add RemoteBridgeErrorType, RemoteBridgeState, ConnectionMetrics
  - State and error streams for UI integration
  - Track total reconnection count
- MusicAssistantAPI: Age-based cleanup for pending requests
  - New _PendingRequest class with timestamp
  - Auto-cleanup stale requests older than 2 minutes
- SendspinService: Exponential backoff for reconnection (3s, 6s, 12s... up to 60s)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:42:43 +00:00
ChrisandClaude Opus 4.5 c832502c49 Add default MA port 8095 for local IP addresses
When users enter a local IP address without specifying a port,
auth detection now defaults to port 8095 (MA's default port).
This fixes connection issues for HAOS add-on users who previously
had to manually specify :8095.

Behavior:
- 192.168.4.178 → http://192.168.4.178:8095
- 192.168.4.178:8095 → http://192.168.4.178:8095 (unchanged)
- 192.168.4.178:80 → http://192.168.4.178:80 (explicit port preserved)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:29:48 +00:00
ChrisandClaude Opus 4.5 675779c108 Add remote auto-reconnect on app restart
- Save remote access ID when connecting via remote mode
- Clear remote access ID when connecting via local/direct mode
- On startup, detect if saved URL is a localhost bridge URL
- Re-establish bridge automatically using saved credentials
- Fixes app unable to connect after restart in remote mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:33:00 +00:00
ChrisandClaude Opus 4.5 54a9e6e2b4 Add WebRTC auto-reconnection and increase ping frequency
- Increase signaling ping frequency from 30s to 15s
- Add automatic WebRTC reconnection when connection drops
- Exponential backoff for reconnect attempts (up to 5 tries)
- Preserve WS clients during reconnection for seamless recovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:24:53 +00:00
ChrisandClaude Opus 4.5 870acabad1 Add Sendspin audio tunneling through WebRTC bridge for remote local player
- Add second WebRTC data channel for Sendspin audio streaming
- Extend RemoteBridge to handle /sendspin path separately from API
- Route Sendspin text and binary messages through dedicated channel
- Update provider to connect Sendspin via bridge in remote mode
- Fix authentication flow (save credentials before connecting)
- Add sequential sync requests for remote mode stability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:08:17 +00:00
ChrisandClaude Opus 4.5 d3a5542c68 Remove init.gradle from CI - conflicts with Gradle settings mode
GitHub Actions doesn't need Maven mirrors workaround.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:09:10 +00:00
ChrisandClaude Opus 4.5 b93918219f Update CI to Flutter 3.38.0 for intl compatibility
Flutter 3.24 pins intl to 0.19.0 via flutter_localizations,
but project requires intl ^0.20.0. Flutter 3.38 resolves this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:04:07 +00:00
ChrisandClaude Opus 4.5 a23f9ff05f Add remote access login UI and fix album art for remote mode
- Add Server URL / Remote Access ID toggle to login screen
- Add RemoteBridge connection flow for HA add-on users
- Add isRemoteMode flag to API to detect WebRTC tunnel connections
- Fix album art in remote mode by using external CDN URLs directly
  (Spotify, Tidal, Deezer, Apple Music, Last.fm, MusicBrainz)
- Add debug copy logs button to home screen
- Add localization strings for remote access UI

Note: Local/OpenSubsonic content won't have album art in remote mode
since HTTP image requests can't go through the WebRTC tunnel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 14:58:13 +00:00
ChrisandClaude Opus 4.5 1921a20884 Add remote bridge infrastructure and fix Flutter 3.38 compatibility issues
Remote Bridge:
- Add WebRTC-based remote connection infrastructure
- Add signaling client for MA remote access protocol
- Add RemoteBridge local WebSocket server for transparent tunneling
- Add RemoteConnectionManager for high-level remote API

Flutter 3.38 Fixes:
- Replace Material 3 Slider with custom implementation (fixes Overlay error)
- Custom progress bar and volume slider using Stack/Container
- Add seek debouncing to prevent command timeout on rapid seeks
- Fix position reporting in Sendspin client/state payload

Other Fixes:
- Fix tab swiping scrolling multiple tabs (use default PageScrollPhysics)
- Fix copy logs to use filtered entries instead of all logs
- Revert share logs to simple implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 13:40:51 +00:00
ChrisandClaude Opus 4.5 516faf6f6a chore: Update Gradle, Kotlin, and AGP for Flutter 3.38 compatibility
- Gradle: 8.0 → 8.9
- Android Gradle Plugin: 8.1.0 → 8.7.0
- Kotlin: 1.9.10 → 2.1.0
- intl: 0.19.0 → 0.20.0
- Fix CardTheme → CardThemeData (Flutter API change)

Required for Flutter 3.38.7 compatibility. The older versions cause
build failures with the current Flutter SDK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:21:32 +00:00
ChrisandClaude Opus 4.5 d7b7bbcf0d Fix Sendspin proxy auth for local network connections
Two issues fixed:
1. Version check was looking for MA 2.8.0+ but proxy was added in 2.7.1
2. Local proxy fallback wasn't sending auth token, causing connection rejection

Now local IP connections properly authenticate with the MA token when
falling back to the /sendspin proxy endpoint.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:11:02 +00:00
ChrisandClaude Opus 4.5 3503978185 Fix nav bar hidden when no player selected
Show the bottom navigation bar when connected even without a player
selected. This allows users to access Settings and Library to
troubleshoot connection issues or browse content.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:59:32 +00:00
ChrisandClaude Opus 4.5 020ed250f4 Fix library sorting, scrollbar display, and provider management
- Fix audiobook year sorting (ascending/descending support)
- Fix server-handled sort orders (play_count, last_played, etc.) to preserve server order
- Fix letter scrollbar showing irrelevant letters for non-alphabetical sorts
- Add ScrollbarDisplayMode enum for contextual scrollbar popups
- Fix playlists sorting by requesting sorted data from server
- Fix multiple accounts of same service not showing in provider toggles
- Fix stale provider IDs persisting in settings
- Use context.watch instead of Selector for albums/artists tabs
- Use Consumer for menu provider toggles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:10:27 +00:00
ChrisandClaude Opus 4.5 e5be64a6cd fix: Auto-stop foreground service after 30 min idle (fixes #51)
Add idle service timeout to stop the Android foreground service when no
playback occurs for 30 minutes. This saves battery by allowing the
service to be fully stopped rather than persisting indefinitely.

- Add stopService() method to MassivAudioHandler
- Add 30-minute idle timer in MusicAssistantProvider
- Timer starts when playback stops, cancels when playback resumes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:22:48 +00:00
Chris b85fadc551 Update swipe gestures and add provider filters 2026-01-20 20:12:10 +00:00
ChrisandClaude Opus 4.5 3b8339f94f docs: Add screenshots 11 and 12 to README
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:31:09 +00:00
ChrisandClaude Opus 4.5 c00fb3dc78 chore: Bump version to 2.9.1-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:27:03 +00:00
ChrisandClaude Opus 4.5 ff34479169 feat: Provider icons and library performance optimizations
Provider Icons:
- Add provider icon overlays to series detail screen (grid/list)
- Add provider icon overlays to library playlists (grid/list)
- Add ProviderIconOverlay widget and provider manifest model

Performance Optimizations:
- Fix empty setState in SyncService listener - only rebuild on sync complete
- Add cached sorting for albums, artists, authors (avoid O(n log n) per rebuild)
- Add cached track sort keys for LetterScrollbar
- Fix image cache size mismatches (author 128px, audiobook list 128px, series 150px)
- Simplify provider toggle setState to avoid double rebuild
- Remove redundant text Hero animations from card widgets
- Defer color extraction from card tap to detail screen initState
- Increase fallback image debounce from 200ms to 400ms

Other Changes:
- Update screenshots
- Add new localizations
- Various detail screen improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:24:37 +00:00
ChrisandClaude Opus 4.5 d7127ea7da chore: Bump version to 2.9.0-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:16:35 +00:00
ChrisandClaude Opus 4.5 29b4887d0d docs: Add player selector implementation plan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:04:58 +00:00
ChrisandClaude Opus 4.5 18603c34e1 Merge audit-fixes: Memory leaks, performance, and bug fixes
Phase 1 - Critical Memory Leaks & Safety:
- Fix pending API requests memory leak (max 100, cancel on reconnect)
- Fix ImageStream listener leaks in expandable player
- Add database batch operations (10x faster sync)
- Fix connection guard race condition
- Mark undersized touch targets as deprecated (WCAG)
- Extract SleepTimerProvider and ConnectionProvider skeletons

Phase 2 - UI/Widget Fixes:
- Add LRU cache eviction to MetadataService (bounded caches)
- Add staleness detection for async player state updates
- Add explicit animation controller listener cleanup

Phase 3 - Medium Priority:
- Add MediaItem equality operators (itemId + provider)
- Extract duration formatting utility (remove 3 duplicates)

Bug Fixes:
- Fix volume precision toggle not affecting player selector overlay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 08:11:53 +00:00
ChrisandClaude Opus 4.5 5ed2bf7725 Fix volume precision toggle not affecting player selector overlay
The ExpandablePlayer was loading the volume precision setting only once
in initState(), so changes made in settings weren't reflected until app
restart. PlayerCards in the overlay were loading fresh values on each
creation.

Fix: Add didUpdateWidget to reload the precision mode setting when the
device reveal overlay becomes visible. This ensures the setting is fresh
after the user may have changed it in settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:44:13 +00:00
ChrisandClaude Opus 4.5 67a99c88a0 Phase 3: Medium priority fixes - MediaItem equality and duration formatting
MediaItem Equality (3.4):
- Add == operator based on itemId and provider
- Add hashCode using Object.hash for correct Set/Map behavior
- Enables proper comparison in collections and deduplication

Duration Formatter Utility (3.5):
- Create lib/utils/duration_formatter.dart with reusable functions
- Add DurationFormatter extension on Duration
- Add formatDurationSeconds() and formatDurationOrEmpty() functions
- Replace 3 duplicate _formatDuration implementations:
  - expandable_player.dart
  - chapters_panel.dart
  - queue_panel.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:21:29 +00:00
ChrisandClaude Opus 4.5 ffbb41580b Phase 2: UI/Widget fixes and memory management
MetadataService Cache Eviction (2.4):
- Create LruCache<K,V> utility class with O(1) LRU operations
- Create NullableLruCache for caches storing null (failed lookups)
- Replace unbounded static maps with bounded LRU caches:
  - Description cache: 100 entries
  - Artist/album image caches: 200 entries each
  - Author image cache: 100 entries
  - Lyrics cache: 500 entries with manual eviction

Async Cancellation on Player Switch (2.2):
- Add staleness detection to _updatePlayerState()
- Capture player ID at operation start
- Check for player changes after each await
- Abort stale operations to prevent UI inconsistency

Scroll Controller Cleanup (2.3):
- Verified existing cleanup is correct
- Only tracks scroll controller has listener, properly removed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:17:53 +00:00
ChrisandClaude Opus 4.5 6942b56cfb Phase 1: Critical memory leaks, safety & performance fixes
Memory Leak Fixes:
- Add max pending requests limit (100) to prevent unbounded growth
- Cancel pending requests in _reconnect() before reconnecting
- Track ImageStream listeners in expandable_player for proper cleanup
- Add cleanup in dispose() for active image stream listeners

Database Performance:
- Add batchCacheItems() method using drift batch API
- Chunk batches at 500 items to avoid SQLite limits
- Refactor sync_service to use batch inserts (~10x faster)
- Add BatchCacheItem data class for type-safe batch operations

Race Condition Fix:
- Make connection guard check-and-set atomic
- Create completer before check to close race window

WCAG Accessibility:
- Mark ButtonSizes.sm/md as @Deprecated (below 44x44 minimum)
- Add ButtonSizes.minAccessible constant (48x48)
- Add clear documentation on WCAG Level AA/AAA requirements

Provider Decomposition (partial):
- Extract SleepTimerProvider with callback pattern
- Extract ConnectionProvider skeleton for future integration
- Prepare facade pattern for backward compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:13:40 +00:00
ChrisandClaude Opus 4.5 14ba524ed0 refactor: Replace Play On popups with Player Selector overlay
Use the existing PlayerRevealOverlay for "Play On" actions instead of
the PlayerPickerSheet popup modal. This provides a more consistent UX
by reusing the same player selection interface throughout the app.

- Add onPlayerSelected callback and contextHint to PlayerRevealOverlay
- Add showPlayerSelectorForAction helper to GlobalPlayerOverlay
- Refactor album, artist, playlist, podcast, audiobook, and search screens
- Add localization strings for context-specific hints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 06:45:57 +00:00
ChrisandClaude Opus 4.5 49fc2a77e7 fix: Multiple notification and UI fixes
- Clear notification when switching to idle player (fixes issue #42)
- Fix notification progress bar desync after seek and with Sendspin
  - Always use position tracker instead of PCM elapsed time
  - PCM elapsed resets to 0 on stream start, causing wrong position
- Fix invisible nav bar due to stale playerExpansionNotifier
  - Reset notifier when player hidden (disconnect/no player)
  - Reset notifier after collapse() completes even if already at 0
- Fix welcome backdrop showing dark color in light theme
  - Now uses theme-appropriate surface color

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 05:26:33 +00:00
ChrisandClaude Opus 4.5 6632fa14c6 feat: Add sleep timer and fix progress bar freeze bug
Sleep Timer:
- Add sleep timer panel with preset durations (15/30/45/60 min)
- Support finish current track option
- Show countdown in queue panel header
- Auto-stop playback when timer expires

Progress Bar Fix:
- Fix anchor refresh logic when server sends stale position data
- Allow anchor refresh even with suspicious reset when anchor is stale
- Use interpolated position to prevent backward jumps from bad server data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:47:31 +00:00
ChrisandClaude Opus 4.5 a0cac2174f chore: Bump version to 2.8.9-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:37:44 +00:00
ChrisandClaude Opus 4.5 056d1fb854 refactor: Move audiobook libraries toggle from settings to library menu
- Remove "Show only artists with albums" option from settings (now in library menu)
- Remove "Audiobook Libraries" section from settings screen
- Add ABS library filter to Books tab options menu
- Show spinner on menu button while syncing libraries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:33:27 +00:00
ChrisandClaude Opus 4.5 a4cc7115bf chore: Localization audit and debug cleanup
- Localize hardcoded strings in search_screen.dart (21 strings)
- Localize language names in settings_screen.dart
- Localize queue screen messages and tooltips
- Localize widget strings (queue_panel, debug_console)
- Add 15 new localization keys to app_en.arb
- Remove all debugPrint statements from widget files
  - expandable_player.dart (11 removed)
  - queue_panel.dart (8 removed)
  - player_selector.dart (2 removed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:58:31 +00:00
ChrisandClaude Opus 4.5 1f3188fa6c feat: Add player navigation to artist/album detail screens
- Tap artist name in expanded player to navigate to artist details
- Tap album name in expanded player to navigate to album details
- Use global navigator key for overlay context navigation
- Fix album details to use fresh album data for artist display
- Fix EmptyState widgets to be scrollable for PageView swipe detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:36:50 +00:00
ChrisandClaude Opus 4.5 9527ba341e fix: Hide providers not synced to library from filter list
- Provider counts now only count items where inLibrary=true
  (the provider that actually added the item to library)
- Providers with 0 items are hidden from the filter list
- Fixes issue where playback-only providers appeared with
  duplicate counts matching other accounts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:56:47 +00:00
ChrisandClaude Opus 4.5 79e9e02f53 docs: Add session documentation for optimistic updates and podcast filtering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:10:15 +00:00
ChrisandClaude Opus 4.5 d14a4b99c6 fix: Optimistic updates for library buttons and podcast provider filtering
- Add inLibrary field to ProviderMapping for accurate ownership tracking
- Fix podcast provider filtering to use inLibrary field
- Add itunes_podcasts to provider capabilities
- Convert album/artist/podcast detail screens to use optimistic updates
  (button updates immediately, reverts on API failure)
- Remove server-side provider filtering from search
- Add client-side provider filtering for search in-library mode
- Fix episode loading fallback for non-library podcasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:08:11 +00:00
ChrisandClaude Opus 4.5 7e72b08f44 docs: Add detailed documentation for per-provider sync implementation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:26:35 +00:00
ChrisandClaude Opus 4.5 801952482f feat: Add per-provider sync with source tracking for instant filtering
- Add sourceProviders column to database schema (v6) for tracking which
  provider instance each item came from
- Update SyncService to sync each enabled provider separately and tag
  items with their source provider
- Add client-side filtering methods that use source tracking to properly
  differentiate between multiple accounts of the same provider type
  (e.g., two Spotify accounts)
- Update library UI to use instant client-side filtering with background
  sync for data freshness

This enables instant UI updates when toggling provider filters, with
accurate filtering even for same-provider-type accounts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:23:58 +00:00
ChrisandClaude Opus 4.5 bae1154bcf feat: Add server-side sorting for library items
- Add orderBy parameter to API methods (getArtists, getAlbums, getTracks,
  getPlaylists, getRadioStations, getPodcasts)
- Add orderBy parameter to provider load methods
- Migrate legacy sort values (alpha → name, alpha_desc → name_desc, etc.)
- Use MA API sort values: name, name_desc, year, year_desc, timestamp_added,
  timestamp_added_desc, last_played, play_count, artist_name, duration
- Trigger server-side reload when sort option changes
- Add options menu overlay management (closes on navigation)
- Trust server-side sorting instead of re-sorting client-side

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 00:50:48 +00:00
ChrisandClaude Opus 4.5 0342caaf5c chore: Bump version to 2.8.8-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 09:51:22 +00:00
Chris LaycockandClaude Opus 4.5 d85dc9e592 fix: Improve queue transfer behavior and dropdown styling
- Change transfer dropdown background to lighter shade of queue panel
  background instead of dark album highlight color
- Add forceCollapse() to cleanly collapse player after transfer
  (closes queue panel instantly + collapses in one motion)
- Track queue's player ID to detect player changes and reload correct queue
- Fix transfer dropdown to use selected player ID for correct filtering
- Clear source player's track cache after transfer to prevent stale data
  in player selector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:33:50 +00:00
Your NameandClaude Opus 4.5 9338828ac7 feat: Add queue transfer between players
- Add transferQueue API method to move playback queue between players
- Add transfer queue button to queue screen with player picker
- Refactor queue panel with improved controls
- Minor library screen cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:04:04 +00:00
Your NameandClaude Opus 4.5 d920abd241 fix: Player list swipe dismiss now matches tap/back behavior
- Remove separate _dragOffset variable that caused cards to "shoot out"
  below mini player during swipe dismiss
- Directly control animation controller during drag for seamless handoff
- Fix bounce timing when user drags past bounce threshold before release

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:07:33 +00:00
Your NameandClaude Opus 4.5 02f7d501ff fix: Handle keystore signature changes gracefully
- Add decryption error handling in SecureStorageService
- Clear corrupted credentials when BadPaddingException occurs
- Add android:appCategory="audio" to manifest for proper app categorization
- Fix letter scrollbar proportional scrolling and bottom padding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:39:01 +00:00
Your NameandClaude Opus 4.5 5f72cca25e Fix author image fetching from Audnexus and Open Library
Two bugs were causing author photos to fail for well-known authors like
J.R.R. Tolkien and J.K. Rowling:

1. Audnexus API requires two-step fetch: The search endpoint only returns
   ASIN and name, not the image. Now correctly fetches author details by
   ASIN after search to get the image URL.

2. Open Library returns 302 redirects: The HEAD request check was only
   accepting 200 status codes, but Open Library redirects to archive.org.
   Now accepts 301/302/307/308 redirect responses as valid.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:00:23 +00:00
Your NameandClaude Opus 4.5 c2f75797e1 Fix hero animations for album grid views
Use consistent 256px image cache size for album grid cards to match
the detail screen cache size. This ensures smooth Hero animations
when navigating from grid view (both 2-column and 3-column) to the
album detail screen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:05:20 +00:00
Your NameandClaude Opus 4.5 3d982892de Improve tracks tab layout in library
- Make track name the primary line (title) instead of artist
- Move artist to subtitle line
- Add track duration on the right side
- Keep favorite icon (now before duration) for favorited tracks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:44:56 +00:00
Your NameandClaude Opus 4.5 c48f9d19c4 Improve search screen UI layout
- Add magnifying glass icon inside search bar
- Move library toggle outside search bar with circle background
- Style library toggle like library screen buttons (44x44, CircleBorder)
- Toggle highlights with primary color when active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:23 +00:00
Your NameandClaude Opus 4.5 e4f24766d2 Fix adaptive colors for library screen navigation
Add updateAdaptiveColorsFromImage calls before navigation to detail
screens in library tab. This ensures bottom nav colors update
immediately when navigating from:
- Artist tiles (list and grid views)
- Author navigation
- Audiobook/book navigation
- Series navigation (list and grid views)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:36:35 +00:00
Your NameandClaude Opus 4.5 d7ac8befac Add Wikidata source and fix author detail screen icon
1. Fix missing icon in author detail screen:
   - Use Stack pattern (icon underneath + image overlay) like library list
   - Ensures icon always visible when no image
   - Consistent Hero animation between screens

2. Add Wikidata as author image source:
   - Structured data from Wikipedia, better for exact person matching
   - Uses P18 (image) property to get Wikimedia Commons photos
   - Searches top 3 results to find one with an image
   - Added between Open Library and Wikipedia in priority

Source priority:
1. Audnexus (audiobook-focused)
2. Open Library (book database)
3. Wikidata (structured Wikipedia data) - NEW
4. Wikipedia (search-based fallback)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:16:56 +00:00
Your NameandClaude Opus 4.5 8a2a3b8a14 Make comma-separated author detection safer
- Check for common suffixes (Jr., Sr., III, PhD, etc.)
- Check if second part looks like initials (J.R.R., etc.)
- Require BOTH parts to have multiple words for multi-author split
- Don't cache null on failure - fall through to try full name

This prevents incorrectly splitting "Name, Jr." or "Lastname, First"
while still handling "Neil Gaiman, Dirk Maggs".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:01:41 +00:00
Your NameandClaude Opus 4.5 ea1133c4d6 Revert Wikipedia multi-query - caused worse results
The multiple search queries (exact name, then with suffixes) was
finding wrong pages. Reverting to original "name writer author"
search which was tuned to find author pages.

Keep the comma-separated author handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:00:02 +00:00
Your NameandClaude Opus 4.5 cb2595e36a Improve author image lookup for combined names and Wikipedia
1. Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
   - Detects multi-author format vs "Lastname, Firstname" format
   - Tries to get image for each author until one succeeds

2. Improve Wikipedia search strategy
   - Try exact name first (works best for famous authors)
   - Then try with "author", "writer", "novelist" suffixes
   - This helps find authors whose pages might not rank for generic search

Should help find: Anne Rice, Philip Pullman, Paulo Coelho, P.G. Wodehouse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:43:55 +00:00
Your NameandClaude Opus 4.5 70e983de51 Add periods to standalone single-letter initials
After normalizing "J. R.R Tolkien" to "J. R. R Tolkien", the final
"R" lacks a period and won't be detected as an initial.

Now adds periods to standalone uppercase letters between spaces:
"J. R. R Tolkien" -> "J. R. R. Tolkien"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:26 +00:00
Your NameandClaude Opus 4.5 251579d32a Handle inconsistent initial formatting like "J. R.R Tolkien"
The metadata sometimes has messy formats with inconsistent spacing
around initials. Now handles:
- "J. R.R Tolkien" -> "J. R. R. Tolkien"
- "J.R.R. Tolkien" -> "J. R. R. Tolkien"
- "J.K. Rowling" -> "J. K. Rowling"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:19:48 +00:00
Your NameandClaude Opus 4.5 9d5e8abc11 Improve author image lookup for names with initials
Problems:
- "J.R.R. Tolkien" wasn't being parsed correctly - the condensed
  initials "J.R.R." weren't detected as initials
- Wikipedia only tried the first name variation

Fixes:
1. Expand condensed initials: "J.R.R." -> "J. R. R." for better splitting
2. Better initial detection that handles multiple initials
3. Generate compact variations: "JRR Tolkien", "JK Rowling"
4. Wikipedia now tries top 3 variations (best source for famous authors)

Example variations for "J.R.R. Tolkien":
- J.R.R. Tolkien (original)
- J. R. R. Tolkien (expanded)
- Tolkien (last name only)
- JRR Tolkien (compact initials)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:18:29 +00:00
Your NameandClaude Opus 4.5 225a1bd010 Fix author image aspect ratio distortion
Remove memCacheHeight from author images - setting both memCacheWidth
and memCacheHeight to equal values forces the image to be cached as
a square in memory, which distorts non-square source images BEFORE
BoxFit.cover can properly scale and crop.

By only setting memCacheWidth, height scales proportionally to
preserve the original aspect ratio. BoxFit.cover then handles
filling the circular container correctly.

Fixed in:
- Library grid author cards
- Library list author tiles
- Author detail screen header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:54:24 +00:00
Your NameandClaude Opus 4.5 ef5f6b6483 Fix author image sizing issues
1. Library grid: Wrap author image ClipOval in SizedBox for proper
   sizing constraints (prevents squished images on initial load)

2. Author detail screen: Use responsive sizing like artist detail:
   - coverSize = (screenWidth * 0.7).clamp(160, 280)
   - expandedHeight = coverSize + 100
   - Icon size scales with coverSize * 0.5
   - Added memCacheWidth/Height for better performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:42:08 +00:00
Your NameandClaude Opus 4.5 4a369fa5f5 Update audit report with Phase 4 status
Completed:
- SettingsService generic helpers (~15% reduction)
- Removed unused providers (1,615 lines dead code)

Deferred (risk outweighs benefit):
- MusicAssistantProvider split - evolved with Sendspin, ID mapping
- Library screen tab extraction - tight state coupling
- API domain split - shared WebSocket connection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:26:32 +00:00
Your NameandClaude Opus 4.5 52489fa97a Remove unused LibraryProvider and ConnectionProvider
These providers were early attempts at splitting MusicAssistantProvider
that were never completed or integrated. Like PlayerProvider (removed in
previous commit), the code has evolved significantly and these files
would need substantial updates to be useful.

Also fixes stale comment reference to library_provider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:19:20 +00:00
Your NameandClaude Opus 4.5 5c52ef2738 Remove unused PlayerProvider
The standalone PlayerProvider was created early but never integrated.
MusicAssistantProvider has since evolved with:
- Sendspin support (MA 2.7.0b20+)
- Cast-to-Sendspin ID mapping with DB persistence
- Position tracker integration
- Audio handler mode switching
- Complex player filtering

Keeping player code in MusicAssistantProvider as it's working
and would require ~1600 lines of sync work to update PlayerProvider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:13:28 +00:00
Your Name 447b8c8a93 Fix nullable return types for view mode getters 2026-01-14 09:41:36 +00:00
Your NameandClaude Opus 4.5 eb34663d3f Phase 4: Extract SettingsService generic helpers
Added generic helper methods to reduce boilerplate:
- _getString(key, {defaultValue}) / _setString(key, value, {removeIfEmpty})
- _getBool(key, {defaultValue}) / _setBool(key, value)
- _getInt(key, {defaultValue}) / _setInt(key, value)
- _remove(key)

Refactored ~40 getter/setter pairs to use helpers:
- Theme settings (themeMode, materialTheme, adaptiveTheme, customColor)
- Locale, API keys, player settings
- Home screen row visibility settings (12 toggles)
- View mode settings

Reduced file from 1,031 to 874 lines (~15% reduction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:36:40 +00:00
Your NameandClaude Opus 4.5 2d359db25c Update AUDIT_REPORT.md with comprehensive audit findings
Updated report with:
- Full codebase audit (104 Dart files, 18 screens)
- Critical/High/Medium severity categorization
- Refactoring roadmap (Phase 1-4)
- Quick wins list
- Files by priority

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:32:31 +00:00
Your NameandClaude Opus 4.5 0845d6d29a Phase 3: Replace Colors.red with StatusColors.favorite for favorites icons
Updated favorites heart icons to use semantic StatusColors.favorite:
- new_library_screen.dart: playlist and track favorites
- library_playlists_screen.dart: playlist favorites

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:29:10 +00:00
Your NameandClaude Opus 4.5 c80a86abe8 Phase 3: Replace hardcoded SizedBox gaps with Spacing tokens
Replaced const SizedBox(height: 8/16) with Spacing.vGap8/vGap16 in:
- album_details_screen.dart (7 replacements)
- artist_details_screen.dart (8 replacements)
- audiobook_detail_screen.dart (4 replacements)
- playlist_details_screen.dart (6 replacements)
- search_screen.dart (3 replacements)
- new_library_screen.dart (8 replacements)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:26:36 +00:00
Your NameandClaude Opus 4.5 8358274f73 Phase 3: Add design tokens and standardize values
Design tokens added:
- ButtonSizes: sm/md/lg/xl/xxl for touch targets, iconSm/iconMd/iconLg/iconXl
- StatusColors: success/error/warning/info with light/dark variants, favorite

Standardization:
- Library filter buttons now use ButtonSizes.md (36) and ButtonSizes.iconMd (18)
- Favorites button uses StatusColors.favorite instead of Colors.red
- All BorderRadius.circular(22) and circular(25) → Radii.xxl (24)

Files updated:
- album_details_screen, artist_details_screen, audiobook_detail_screen
- playlist_details_screen, search_screen, new_library_screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:25:27 +00:00
Your NameandClaude Opus 4.5 9200af705a Add 6px spacing between library filter buttons
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:06:17 +00:00
Your NameandClaude Opus 4.5 1eed678deb Fix Phase 2 build errors - undefined provider variables
- global_player_overlay.dart: Use isConnected/hasSelectedPlayer from
  context.select() instead of removed provider variable
- expandable_player.dart: Add themeProvider via context.read() for
  methods that need full ThemeProvider object

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:56:57 +00:00
Your NameandClaude Opus 4.5 4ab31c11df Adjust filter row button sizes to 36x36 with 18px icons
Reverts the oversized 44x44 buttons from Phase 1 audit fix to a more
balanced 36x36 size that fits better visually while still being
easily tappable.

- _filterRowHeight: 48 → 36
- buttonSize: 44 → 36
- iconSize: 20 → 18

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:53:42 +00:00
Your NameandClaude Opus 4.5 4c7ecfbf88 Optimize Provider selectors with context.select() (Phase 2 audit fixes)
- global_player_overlay: Use select() for isConnected/selectedPlayer check
- new_library_screen: Use select() for podcasts and radio tabs
- expandable_player: Use select() for adaptiveTheme boolean
- player_reveal_overlay: Remove unused ThemeProvider watch

Performance impact: Reduces unnecessary rebuilds when unrelated
provider state changes, improving scroll performance in library tabs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:34:04 +00:00
Your NameandClaude Opus 4.5 d376fd3725 Cache SharedPreferences and add migration skip flag (Phase 2 audit fixes)
- Add SettingsService.initialize() to cache SharedPreferences at startup
- Replace 130+ getInstance() calls with cached _getPrefs() helper
- Add migration completed flag to SecureStorageService
- Skip SecureStorage migration on subsequent app launches

Performance impact: Eliminates redundant SharedPreferences lookups
and saves ~100-300ms on startup after first run.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:32:17 +00:00
Your NameandClaude Opus 4.5 a7893e6ad9 Add navigation debounce and error boundaries (Phase 1 audit fixes)
- Add navigationDebounce timing constant (500ms)
- Convert card widgets to StatefulWidget for debounce state tracking
- Prevent double-tap navigation on AlbumCard, ArtistCard, PlaylistCard,
  PodcastCard, RadioStationCard
- Add FlutterError.onError handler for widget build errors
- Add PlatformDispatcher.onError for platform channel errors
- Wrap runApp in runZonedGuarded for async error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:30:45 +00:00
Your NameandClaude Opus 4.5 d3474a2665 Fix touch targets and bottom spacing (Phase 1 audit fixes)
- Increase filter row touch targets from 32x32 to 44x44 (Material Design minimum)
- Replace hardcoded bottom spacing (100, 140, 164) with BottomSpacing.withMiniPlayer
- Affected screens: album_details, artist_details, audiobook_detail,
  audiobook_author, playlist_details, podcast_detail, new_library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:27:36 +00:00
Your Name 5bfa91c566 Revert "Increase library action buttons to 40x40 with 20px icons"
This reverts commit 884b457139.
2026-01-14 07:05:46 +00:00
Your Name 884b457139 Increase library action buttons to 40x40 with 20px icons
- Sort, favorites, and view mode buttons now larger and easier to tap
- Increased filter row height from 36px to 44px to accommodate
2026-01-14 06:58:46 +00:00
Your Name 5e2599bdb8 Fix author avatar icon visibility using Stack approach
- Icon is always rendered underneath in a Stack
- Image covers icon when loaded successfully
- No widget tree switching = no layout glitches
- Placeholder/error show SizedBox.shrink() to reveal icon beneath
2026-01-14 06:49:14 +00:00
Your NameandClaude Opus 4.5 4dec8f43a2 Fix author icon rendering by using builder function
Use a builder function to create fresh Icon widgets for each use
instead of reusing a single widget instance. Also wrap placeholder
and error widgets in properly sized Containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:32:09 +00:00
Your NameandClaude Opus 4.5 ee7a08a859 Add fuzzy name matching for author image lookups
Name variations tried in order:
- Original name
- "Lastname, Firstname" -> "Firstname Lastname" conversion
- Remove titles (Dr., Mr., Mrs., Prof., Sir, Dame, etc.)
- Remove suffixes (Jr., Sr., III, PhD, etc.)
- First + Last name only (skip middle names/initials)
- Last name only (when first part is an initial)
- Handle double initials like "J.K. Rowling" -> try "Rowling"

Also refactored API calls into separate helper methods for cleaner code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:15:39 +00:00
Your NameandClaude Opus 4.5 4351a32cde Fix author avatar fallback to use icon instead of text
- Use Icons.person_rounded for fallback instead of text initials
- Properly center the icon in both list tile and card views
- Fix sizing to prevent visual jumps when images load
- Simplify widget structure for consistent rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:11:38 +00:00
Your NameandClaude Opus 4.5 587115dc2b Fix tracks favorites, font size, and bottom padding
- Fix favorites toggle not showing tracks by using _sortedFavoriteTracks
  instead of filtering _sortedAllTracks (which has stale favorite flags)
- Fix tracks list font size to use titleMedium like other tabs
- Fix all tabs to use BottomSpacing.withMiniPlayer to account for
  mini player height, preventing bottom items from being cut off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:59:19 +00:00
Your NameandClaude Opus 4.5 862c9d5ef3 Fix series folder detection for abbreviated paths
The browse API returns abbreviated paths like /s for series instead of
/series. Update detection to check for path.endsWith('/s') in addition
to checking for 'series' in the name or path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:44:26 +00:00
Your NameandClaude Opus 4.5 ed7613e6ad Fix audiobook bugs and UI inconsistencies
- Fix audiobook favorites toggle not reloading books when toggled
- Fix series loading by adding 'label' fallback for 'name' field
- Add consistent padding to View Mode button
- Add sort direction indicator with rotated icon for descending sorts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:29:52 +00:00
Your Name 74d2b1b1eb Increase filter chip padding to 12px 2026-01-13 21:57:05 +00:00
Your Name 951c8a68ea Fix highlight/content mismatch when swiping between media types
- Add flag to ignore onPageChanged during media type transitions
- Handle page jump directly in _changeMediaType instead of _resetCategoryIndex
- Clear flag after short delay to ensure all page change events have fired
2026-01-13 21:54:15 +00:00
Your Name c24dec51c5 Use TextPainter for accurate text measurement + fixed padding for consistent spacing 2026-01-13 21:48:28 +00:00
Your Name 11ed7e7d34 Fix swipe direction: go to last tab when swiping right-to-left between media types 2026-01-13 21:41:55 +00:00
Your Name 40f72f8db9 Fix inconsistent spacing in category chips - use same formula for flex and width 2026-01-13 21:38:28 +00:00
Your Name 5e2fb6ff32 Reduce filter row spacing and use consistent faded circle buttons 2026-01-13 21:29:26 +00:00
Your Name 47894e0bb9 Fix null safety for album artists sorting 2026-01-13 21:16:48 +00:00
Your NameandClaude Opus 4.5 c9f7321bec Add permanent Tracks tab, sort button, and extend favorites to all views
- Make Tracks tab always visible in Music (not just when favorites on)
- Add lazy loading for tracks (initial 200, +100 on scroll)
- Add sort button with popup menu next to favorites toggle
- Sort icon changes to reflect current sort order
- Extend favorites filtering to Radio and Podcasts tabs
- Add sort options for all categories:
  - Artists: A-Z, Z-A
  - Albums: A-Z, Z-A, Year (Newest/Oldest), By Artist
  - Tracks: A-Z, By Artist, By Album, Duration
  - Playlists: A-Z, Z-A, Track Count
  - Radio/Podcasts: A-Z, Z-A
- Persist sort preferences per category in settings
- Add localization strings for new empty states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:07:27 +00:00
Your NameandClaude Opus 4.5 277020616f Bump version to 2.8.7-beta+45
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:07:45 +00:00
Your NameandClaude Opus 4.5 cd43ad0868 Fix queue sync issues and add error handling (issue #30)
- Subscribe to queue_items_updated events from Music Assistant
- Add error handling with rollback for queue delete operations
- Add error handling with rollback for queue move operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:31:21 +00:00
Your NameandClaude Opus 4.5 98175719bf Add animated sliding highlight to search filter bar
- Add AnimatedPositioned highlight that slides smoothly between filters
- Match the library screen's animated pill-style highlight effect
- Use GlobalKeys to measure actual filter chip positions for accurate animation
- Fix scroll-back behavior by using measured positions instead of estimates
- The highlight animates with 300ms easeOutCubic curve matching library screen

Also fixes the scroll issue where swiping back to the beginning would leave
the last filter hidden. The scroll logic now uses actual measured positions
with proper padding instead of approximate 80px widths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:32:17 +00:00
Your NameandClaude Opus 4.5 e5274ca72e Fix favorite artists not showing on startup (issue #41)
Add listeners to SyncService and MusicAssistantProvider to detect when
artists become available after initial load. When the home screen builds
with empty favorites (before cache/sync completes), it now:

- Listens for SyncService status change from syncing to completed
- Listens for MusicAssistantProvider changes when artists load from cache
- Triggers a full row refresh when artists become available

This fixes the race condition where favorite rows would load before
the library cache was populated, showing empty results until manual refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:46:37 +00:00
Your Name 6a78405dc1 Revert "Add long-press to favorite on PlaylistCard"
This reverts commit 49e8bbbb30.
2026-01-11 10:10:44 +00:00
Your NameandClaude Opus 4.5 49e8bbbb30 Add long-press to favorite on PlaylistCard
Quick enhancement from Issue #43:

- Convert PlaylistCard to StatefulWidget
- Add onLongPress handler to toggle favorite
- Show animated heart overlay with elastic animation
- Display persistent favorite indicator (heart badge) when favorited
- Haptic feedback on long-press
- Snackbar confirmation with offline sync support
- Invalidate home cache after favorite change

Matches the favorite functionality in PlaylistDetailsScreen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:55:39 +00:00
Your NameandClaude Opus 4.5 beeebeb996 Fix language fallback to English and add French translation
Issue #44: Language now correctly falls back to English instead of German
for unsupported locales (e.g., French system language before this update).

Added localeListResolutionCallback to MaterialApp that:
- Respects user's explicit locale choice
- Falls back to matching system locale if available
- Defaults to English when no match found

Added complete French (fr) translation:
- All 200+ strings translated
- Proper French accents (é, è, ê, à, etc.)
- Added to settings language picker

Updated:
- lib/main.dart: localeListResolutionCallback for fallback logic
- lib/l10n/app_fr.arb: New French translation file
- lib/l10n/app_en.arb: Added "french" string
- lib/l10n/app_de.arb: Added "french" string (Französisch)
- lib/l10n/app_es.arb: Added "french" string (Francés)
- lib/providers/locale_provider.dart: Added French display name
- lib/screens/settings_screen.dart: Added French option

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:50:41 +00:00
Your NameandClaude Opus 4.5 49077b9858 Add hero animations to playlist displays across all screens
Applied optimized hero animation pattern from AlbumCard/ArtistAvatar:

Library Playlists Screen:
- Hero cover with ClipRRect(8) + CachedNetworkImage
- Hero title with Material wrapper
- heroTagSuffix: 'playlists'

New Library Screen (List):
- Hero cover + title with memCacheWidth/Height: 96
- heroTagSuffix: 'library_list'

New Library Screen (Grid):
- Hero cover + title with memCacheWidth/Height: 256
- RepaintBoundary wrapper added
- heroTagSuffix: 'library_grid'

Search Screen:
- Hero cover + title for search results
- heroTagSuffix: 'search'

Performance optimizations applied:
- fadeInDuration/fadeOutDuration: Duration.zero
- Matching memCacheWidth/Height between source and destination
- surfaceContainerHighest background for consistency
- placeholder: SizedBox() for smooth transitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:12:18 +00:00
Your NameandClaude Opus 4.5 9dbcf9971f Upgrade PlaylistDetailsScreen to match AlbumDetailsScreen quality
Major improvements to the playlist detail screen:

- Add hero animations for playlist cover and title
- Add adaptive color extraction from cover art
- Add responsive layout with proper sizing (LayoutBuilder)
- Add action button row: Play, Play On, Add to Queue, Favorite
- Add expandable track actions on long-press (radio, queue, favorite)
- Replace hardcoded colors with theme-aware colors
- Add track duration display in track list
- Add fullscreen art viewer on cover tap
- Add proper bottom padding for mini player
- Update all playlist navigation to use FadeSlidePageRoute

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:00:22 +00:00
Your Name c5bcc509e0 Merge feature/home-row-conformity-and-favorite-podcasts: Row conformity fixes and favorite podcasts row 2026-01-11 00:09:56 +00:00
Your NameandClaude Opus 4.5 2f933da413 Remove all debug coloring from rows and home screen
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:02:46 +00:00
Your NameandClaude Opus 4.5 2fdd3b4286 Add blue debug color to content area of all rows
Debug colors now show every pixel:
- Green = title padding (12px top, 8px bottom)
- Yellow = 2px margin between rows
- Blue = content area (horizontal list)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:53:42 +00:00
Your NameandClaude Opus 4.5 779810674b Add red debug bar at end of content with calculated values
Shows availableHeight, rowHeight, enabledRows, and margins to help
identify what's causing scroll overflow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:51:36 +00:00
Your NameandClaude Opus 4.5 0dc069b071 Fix 4th row peeking by only adjusting height for ≤3 rows
Only subtract margin space when there are 3 or fewer rows.
For 4+ rows, use original calculation so 4th row title stays hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:49:29 +00:00
Your NameandClaude Opus 4.5 ca7b5d8d35 Fix scroll overflow by accounting for margins in row height calculation
The row height calculation now subtracts margin space before dividing
by 3, ensuring that 3 rows + 2 margins fit exactly without scroll.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:39:19 +00:00
Your NameandClaude Opus 4.5 b15de9775f Increase margin between rows from 1px to 2px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:27:40 +00:00
Your NameandClaude Opus 4.5 5fcfe1d3b3 Add debug colors to visualize row layout
- Green: title padding area (12px top, 8px bottom)
- Yellow: 1px margin between rows

Temporary debug visualization to help understand spacing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:19:05 +00:00
Your NameandClaude Opus 4.5 6a8a4972dd Add 1px spacing between home screen rows
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:07:40 +00:00
Your Name 73a23594cf Revert "Increase row title bottom padding from 8px to 9px"
This reverts commit 02d7ea12cb.
2026-01-10 23:06:18 +00:00
Your NameandClaude Opus 4.5 02d7ea12cb Increase row title bottom padding from 8px to 9px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:59:25 +00:00
Your NameandClaude Opus 4.5 601dd8ac7a Add favorite podcasts row and fix home row conformity
- Add PodcastCard and PodcastRow widgets for favorite podcasts
- Fix RadioStationRow to conform to established row patterns:
  - Use titleLarge font instead of titleMedium
  - Use fromLTRB padding (16, 12, 16, 8) instead of symmetric
  - Add SizedBox height wrapper, itemExtent, StretchScrollBehavior
  - Add DebugLogger for performance tracking
- Fix RadioStationCard text clipping (height: 1.15, SizedBox wrapper)
- Fix PlaylistRow with same conformity updates as RadioStationRow
- Add getFavoritePodcasts() to provider
- Add settings support for favorite podcasts row visibility
- Add localization strings for favorite podcasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:46:02 +00:00
Your Name 6dbd0bbdfd Bump version to 2.8.6-beta+44 2026-01-09 21:38:25 +00:00
Your NameandClaude Opus 4.5 dedd1d2144 Fix podcast player: correct podcast name and text overlap
Two issues fixed:

1. Podcast name now displays correctly (not episode name twice)
   - Added _currentPodcastName context field to provider
   - Set podcast name when playing from podcast detail screen
   - Clear context on media type change, external source, or playback stop
   - Removed fallback to album/artist (which contained episode name)

2. Text overlap in mini player for long podcast titles
   - Changed maxLines from 2 to conditional (1 when collapsed, 2 when expanded)
   - Fixed trackInfoBlockHeight to account for podcasts hiding album line

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:24:59 +00:00
Your NameandClaude Opus 4.5 29eb7165c2 Fix highlight alignment at bar edges
First and last tabs are now flush with the container edges.
Middle tabs maintain 2px gap between adjacent highlights.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:30:07 +00:00
Your NameandClaude Opus 4.5 92333f8216 Filter bar uses same color as parent media type
- Moved color function to class level (_getMediaTypeColor)
- Category filter bar now uses the same color as the selected
  media type (e.g., purple for Music, orange for Audiobooks)
- Both bars share the same color logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:13:15 +00:00
Your NameandClaude Opus 4.5 1970bbf910 Add sliding animation to category filter bar
Same treatment as media type bar:
- Sliding highlight with 300ms easeOutCubic animation
- Full border radius pill shape
- 2px horizontal inset
- Proportional widths based on label length

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:05:15 +00:00
Your NameandClaude Opus 4.5 cbb5c68d5e Full height highlight with small horizontal inset only
- Highlight now fills full bar height (top: 0, bottom: 0)
- 2px horizontal inset for small gap between segments
- Border radius 8px to match container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:01:01 +00:00
Your NameandClaude Opus 4.5 a7df388a5f Add sliding animation for library type tabs
- Highlight now slides smoothly between tabs (300ms, easeOutCubic)
- Pill-shaped highlight with full border radius on all sides
- 3px inset from container edges for visual separation
- Color also animates when switching types
- Uses LayoutBuilder to calculate proportional positions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:58:08 +00:00
Your NameandClaude Opus 4.5 b351cbd774 Fix swipe for single-category types (Podcasts, Radio)
PageView with only 1 item doesn't generate overscroll notifications.
For single-category types, use GestureDetector to detect horizontal
swipes and switch media types accordingly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:19:53 +00:00
Your NameandClaude Opus 4.5 05f09f76c9 Fix swipe direction for media type switching
Swapped the overscroll logic:
- Positive overscroll (swipe left at end) = go to NEXT type
- Negative overscroll (swipe right at start) = go to PREVIOUS type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:09:18 +00:00
Your NameandClaude Opus 4.5 46ab36726a Add swipe between media types at category boundaries
When at the last category (e.g., Playlists in Music), swiping left
switches to the next media type (Audiobooks). Similarly, swiping
right at the first category goes to the previous media type.

Uses overscroll detection with 80px threshold to trigger the switch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:53:08 +00:00
Your NameandClaude Opus 4.5 4643288ff8 Library type bar: muted colors based on primaryContainer tone
- Colors derived from primaryContainer using HSL hue shift
- Same saturation/lightness as filter bar, just different hues
- Text uses onPrimaryContainer for proper theme contrast
- Fixed vertical centering with alignment: Alignment.center

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:41:33 +00:00
Your NameandClaude Opus 4.5 e7c03c65a9 Theme-aware colors for library type bar
Dark theme: deeper colors with white text
Light theme: lighter pastels with dark text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:33:02 +00:00
Your NameandClaude Opus 4.5 aff9cb1819 Match library type bar font size to filter bar (14px)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:27:31 +00:00
Your NameandClaude Opus 4.5 473f076b90 Fix library bar to fill full width with proportional flex
Use Expanded (not Flexible) so bar fills full width,
while flex values still give longer labels more space.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:24:41 +00:00
Your NameandClaude Opus 4.5 c009603302 Add pastel colors for each library type
- Music: soft purple
- Audiobooks: soft orange/amber
- Podcasts: soft teal
- Radio: soft pink

Colors appear when type is selected, dark text for contrast.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:20:15 +00:00
Your NameandClaude Opus 4.5 e89a4e26e5 Library type bar: proportional flex based on label length
- Bar fills full width with no gaps between segments
- Each segment gets flex = 10 (base) + label.length
- Longer labels like "Audiobooks" get more space than "Music"
- Works with any language - translations auto-adjust
- Icons always visible for all types
- Border radius matches filter bar (8px)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:16:43 +00:00
Your NameandClaude Opus 4.5 139ced9ae4 Redesign library type bar as unified segmented control
- All buttons now on one continuous bar with no gaps
- Icons always visible for each type (not just when selected)
- Smooth animation on selection change
- Better appearance on larger screens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:04:53 +00:00
Your NameandClaude Opus 4.5 9030a2bb0d Remove debug logging from nav bar fix
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:24:06 +00:00
Your NameandClaude Opus 4.5 440a02e79f Fix nav bar using adaptive colors on home screen with collapsed player
The nav bar now correctly uses default surface color (grey) on the home screen
when the player is collapsed. Adaptive colors are only applied when:
1. The player is expanding/expanded (progress > 0)
2. The user is on a detail screen (album, artist, playlist)

This prevents favorites and radio stations from incorrectly tinting the nav bar
when played directly from the home screen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:19:56 +00:00
Your Name fdd51af7db DEBUG: Red nav bar to verify fix is active 2026-01-09 12:11:41 +00:00
Your NameandClaude Opus 4.5 3bc8ef91c6 Fix nav bar using adaptive colors on home screen
- Only use adaptive colors for nav bar when:
  1. Player is expanding/expanded (progress > 0), OR
  2. On a detail screen (isOnDetailScreen flag)
- On home screen with collapsed player, always use default surface color
- Added isOnDetailScreen flag to ThemeProvider to distinguish between
  colors from detail screen navigation vs player color extraction
- Also applies to icon highlight color, not just background

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:56:46 +00:00
Your Name 4edcf858af Fix mini player: only lower primary row (9, 26, 46) 2026-01-09 10:10:07 +00:00
Your Name 2e274851d2 Adjust mini player text: shift down while preserving gaps 2026-01-09 10:07:20 +00:00
Your NameandClaude Opus 4.5 5d00705f91 Fix mini player text rows vertical centering
Adjust 3-line layout (track, artist, player) to be properly centered
within the 72px mini player height.

Before: Text block center at 32.5px (3.5px above true center)
After:  Text block center at 36px (true center)

Values changed:
- primaryTop: 5 → 9
- secondaryTop: 26 → 30
- tertiaryTop: 46 → 50

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:59:35 +00:00
Your NameandClaude Opus 4.5 71238981a3 Fix series cover hero animation showing wrong image
Remove flightShuttleBuilder that was showing only the first cover
during hero animation. The mosaic grid is simple enough (max 3x3)
to animate directly without performance issues.

Before: Single cover during flight -> mosaic snaps in at end
After:  Mosaic animates smoothly throughout the transition

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:33:21 +00:00
Your NameandClaude Opus 4.5 28d91956f1 Eliminate spinner flash by loading cache synchronously in initState
Move cached data loading from async method to initState so the first
build already has data. This prevents the brief spinner flash when
returning to the app after backgrounding.

Before: initState -> build (spinner) -> async cache load -> setState
After:  initState (sync cache) -> build (data) -> async fresh load

Applied to all row widgets:
- album_row.dart
- artist_row.dart
- playlist_row.dart
- radio_station_row.dart
- audiobook_row.dart
- series_row.dart
- track_row.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:49:12 +00:00
Your NameandClaude Opus 4.5 036d74a91e Fix TrackRow to use setState pattern for reconnection
Convert TrackRow from FutureBuilder to setState-based dual-loading
pattern (matching other row widgets). Add getCachedTracks callback
support for instant display and offline fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:05:02 +00:00
Your NameandClaude Opus 4.5 038fcccdd0 Fix home screen sections not rendering after reconnection (#41)
Convert AudiobookRow and SeriesRow from FutureBuilder to setState-based
dual-loading pattern (matching AlbumRow). Add caching support for
audiobooks and series to enable instant display and offline fallback.

Root cause: FutureBuilder pattern created Future once in initState.
When connection restored, widget State was preserved via KeepAlive,
_hasLoaded remained true, preventing data re-fetch.

Changes:
- AudiobookRow: setState pattern + getCachedAudiobooks callback
- SeriesRow: setState pattern + getCachedSeries callback
- CacheService: Add audiobook/series caching methods
- Provider: Add *WithCache() and getCached*() methods
- NewHomeScreen: Pass cache callbacks to audiobook/series rows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:01:19 +00:00
Your NameandClaude Opus 4.5 df38f8e828 perf: Phase 5 - Cache animations and pre-compute lerp values
Major performance fixes for animation stutter:

1. Cache FadeTransition opacity animation
   - Created _fadeIn50to100 once in initState
   - Previously created new Tween/CurveTween/Animation objects every frame
   - Used by 3 FadeTransition widgets (audio format, progress bar, volume)

2. Pre-compute Alignment and FontWeight
   - textAlignment: computed once per frame, reused by 3 text elements
   - titleFontWeight: computed once per frame for title text
   - Removed unnecessary FontWeight.lerp from artist text

These changes eliminate ~6-9 object allocations per animation frame
that were happening in hot paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:35:17 +00:00
Your Name 6d7e233b7d Merge branch 'feature/volume-control-redesign' into feature/curved-player-animations 2026-01-08 23:14:42 +00:00
Your NameandClaude Opus 4.5 8408db3abb perf: Optimize animations for butter-smooth 60-120fps
Phase 4 performance optimizations:

ExpandablePlayer:
- Cache MaterialRectCenterArcTween (recreate only on dimension change)
- Pre-cached static BoxShadow objects for mini player and art shadows
- Use Transform.translate for GPU-accelerated slide animations
- Pre-compute color opacities (textColor60, textColor70, textColor45)
- Use Color.lerp between pre-computed colors for artist text
- Throttle expansion progress notifications (notify on ≥1% change)
- Simplified shadow rendering with threshold-based visibility

PlayerRevealOverlay:
- Pre-compute player card data outside AnimatedBuilder
- Cache hint colors to avoid withOpacity() per frame
- Add _PlayerCardData helper class for pre-computed data

PlayerCard:
- Static const BoxShadow for card and volume overlay
- Already has RepaintBoundary (verified)

These changes reduce per-frame object allocations and move
slide animations to GPU compositing layer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:14:14 +00:00
Your NameandClaude Opus 4.5 5cbca93a6d fix: Smooth shuffle/repeat button appearance to prevent control jerk
Previously shuffle and repeat buttons used conditional rendering
(if t > 0.5) which caused them to suddenly appear in the Row,
jerking the other playback controls.

Now the buttons:
- Always render with animated width (0 to 44px)
- Smoothly grow into place as animation progresses
- Fade in with expandedElementsOpacity after t > 0.3

This eliminates the visual jerk when expanding the player.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:13:50 +00:00
Your NameandClaude Opus 4.5 f7caf67f56 perf: Cache commonly used color opacities to reduce allocations
- Pre-compute textColor50, primaryColor20, primaryColor70 at method start
- Replace 8+ withOpacity() calls with cached color references
- Reduces Color object allocations during animation frames

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:02:42 +00:00
Your NameandClaude Opus 4.5 377d2a54cd perf: Optimize expandable player for 60-120fps animations
Phase 1: Cache static values
- Batch MediaQuery lookups into single InheritedWidget access
- Batch Theme lookups similarly
- Cache SliderThemeData as static const (avoid recreating every frame)

Phase 3: GPU optimizations
- Replace animated Material elevation with fixed elevation 0
- Use Container decoration with animated shadow opacity instead
- Fixed shadow blur/offset avoids expensive shadow recalculations

These optimizations reduce object allocations during animation frames
and avoid GPU-expensive elevation changes, improving frame timing
consistency for 60-120fps performance targets.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:59:46 +00:00
Your NameandClaude Opus 4.5 3b1a33d405 Implement proper Hero-like animations for player expansion
Major animation improvements:

1. Album art: Uses MaterialRectCenterArcTween for true curved arc path
   - Creates natural Hero-like trajectory instead of straight diagonal

2. Text alignment: Replaced discrete textAlign switches with Align.lerp
   - Title, artist, and player name now smoothly transition from left to center
   - No more jump at t=0.5 threshold

3. Text styles: Replaced discrete style switches with smooth lerping
   - FontWeight.lerp for smooth weight transitions
   - _lerpDouble for letterSpacing and lineHeight
   - Opacity lerping for color transitions

4. Player name: Staggered opacity fade (0 to 0.4 range)
   - Position animates smoothly throughout
   - Wraps in Align for smooth centering

5. Controls: Proper Align.lerp for centering
   - Removed hacky Padding approach
   - Smooth transition from right-aligned to centered

6. Removed broken _lerpX/_lerpY helpers
   - These were double-curving an already-curved t value

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:26:19 +00:00
Your NameandClaude Opus 4.5 62f36a0d8b Add curved path animations for player expand transition
Implement Hero-like curved animations for mini player to expanded player:
- Add _lerpX (easeOutQuad) for faster horizontal centering
- Add _lerpY (easeInOutCubic) for gradual vertical movement
- Album art now follows curved arc path instead of straight diagonal
- Title and artist text use curved paths for smoother transitions
- Controls use Align widget for smooth centering transition
- Player name now animates position (was previously static)

This creates more natural, hero-like transitions where elements
curve into their final positions rather than moving linearly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:02:36 +00:00
Your NameandClaude Opus 4.5 5addb922ad Fix indicator position to match Slider's internal thumb positioning
Flutter's Slider uses overlayRadius (20px) as padding on each side,
so the thumb moves within [overlayRadius, width - overlayRadius].
The indicator position calculation now accounts for this to stay
perfectly aligned with the thumb at all positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:49:03 +00:00
Your NameandClaude Opus 4.5 e160d12075 Fix volume indicator to stay fixed with thumb at edges
Previously the indicator was clamped to stay 16px from edges, causing
it to animate separately from the thumb at 0% and 100%. Now it follows
the thumb exactly at all positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:41:43 +00:00
Your NameandClaude Opus 4.5 c3c9ff4d4b Move precision hint above slider to avoid nav bar obstruction
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:10:44 +00:00
Your NameandClaude Opus 4.5 8b722ffdda Fix precision hint: use custom overlay styled like snackbar
ScaffoldMessenger couldn't find Scaffold from within expanded player overlay.
Using custom hint positioned below slider with snackbar-like styling:
- Dark background (#323232)
- White text
- Positioned below the volume slider

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:02:48 +00:00
Your NameandClaude Opus 4.5 e7996bcf78 Fix volume button stepping and use standard SnackBar for hint
- Fix consecutive button taps using stale volume (now uses pending volume)
- Both +/- buttons correctly step by 1
- Change precision mode hint to standard SnackBar for consistency
- SnackBar appears at bottom like other app messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:54:00 +00:00
Your NameandClaude Opus 4.5 fb90502887 Show teardrop indicator on volume button taps
- Display teardrop indicator for 800ms when +/- buttons are tapped
- Both buttons already step by 1 (delta of -1 and +1)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:09:00 +00:00
Your NameandClaude Opus 4.5 d39727e585 Improve volume control: clamped indicator, adaptive color, precision hint
- Clamp teardrop indicator position to stay within slider bounds
- Use adaptive primary color instead of amber for precision mode
- Show "Precision mode" hint when show hints setting is enabled
- Auto-hide hint after 2 seconds
- Text color adapts based on background luminance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:59:47 +00:00
Your NameandClaude Opus 4.5 7ca9b557ee Add live volume updates, precision mode, and teardrop indicator
- Live volume: throttled updates during drag (150ms normal, 50ms precision)
- Precision mode: hold still 800ms for fine-grained control (10% range)
- Teardrop indicator: upside-down teardrop above thumb showing volume number
- Slider and track turn amber in precision mode
- Haptic feedback when entering precision mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:37:44 +00:00
Your NameandClaude Opus 4.5 a39a45bfa4 Redesign volume control: +/- buttons with floating indicator
- Replace mute button with volume decrease (-1%)
- Replace percentage text with volume increase (+1%)
- Add floating percentage indicator above thumb (visible only when dragging)
- Buttons adjust volume in steps of 1%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:27:45 +00:00
Your NameandClaude Opus 4.5 2669d05a00 Merge fix/queue-drag-reorder: Queue improvements and home screen fix
- Fix Android back gesture for queue panel (double haptic, reliability)
- Isolate queue animation to prevent full player rebuilds (perf)
- Increase spring stiffness for snappier queue animation
- Fix home screen row height calculation (no more scroll with 3 rows)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:23:40 +00:00
Your NameandClaude Opus 4.5 2636a09d85 Fix home screen row height calculation causing scroll
Row height now correctly divides available space by 3, removing
the erroneous +8 that caused unnecessary scroll with 3 rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:48:12 +00:00
Your NameandClaude Opus 4.5 ba5d0566d1 PERF: Isolate queue animation to prevent full player rebuilds
Critical optimizations:
1. Remove _queuePanelAnimation from main AnimatedBuilder merge
   - Queue animation no longer rebuilds entire player (~150 widgets)
   - Only queue panel section rebuilds during slide animation

2. Add cached _queueSlideAnimation
   - Avoids recreating Tween.animate() on every frame
   - Animation object created once in initState

3. Wrap queue panel in own AnimatedBuilder
   - Uses child parameter to avoid rebuilding QueuePanel itself
   - Only Offstage wrapper updates during animation

4. Wrap favorite/queue buttons in own AnimatedBuilder
   - Button fade animation isolated from main player rebuilds
   - Returns SizedBox.shrink() when fully hidden

5. Increase spring stiffness for snappier animation
   - Open: 400→550 stiffness, 42→50 damping
   - Close: 450→600 stiffness, 45→52 damping

Expected improvement: ~60% reduction in frame time during queue animation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:25:15 +00:00
Your NameandClaude Opus 4.5 cfdef31274 Fix rapid open-close timing: use target state not animation value
Root cause: During rapid open-close, the animation value might not
have reached 0.5 yet when back is pressed, causing isQueuePanelOpen
to return false. Also, closeQueuePanel() rejected calls during animation.

Fix:
- Add isQueuePanelTargetOpen getter (intent, not animation value)
- Use target state in didPopRoute for reliable detection
- Allow closeQueuePanel to work during animation by stopping it first
- This handles rapid gestures without needing 3-second gaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:02:26 +00:00
Your NameandClaude Opus 4.5 0124a955f0 Fix Android back: handle queue panel in didPopRoute
Root cause: didPopRoute in main.dart intercepts back gesture at app
level but only checked isPlayerExpanded, not isQueuePanelOpen. This
caused back to call collapsePlayer() which then called closeQueuePanel()
with default haptic enabled, resulting in double haptic (system + app).

Fix:
- Add isQueuePanelOpen check in didPopRoute BEFORE isPlayerExpanded
- Close queue panel with withHaptic: false (system provides haptic)
- Add withHaptic parameter to closeQueuePanel() and propagate through
  GlobalPlayerOverlay

This handles Android back at the app level, bypassing PopScope entirely
for reliable 100% interception.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:12:57 +00:00
Your NameandClaude Opus 4.5 9848399343 Fix PopScope not rebuilding when queue panel opens
Root cause: PopScope was outside AnimatedBuilder, so it never
rebuilt when _queuePanelTargetOpen changed. This caused canPop
to retain stale value (true) even when panel was open, allowing
Android back gesture to pass through instead of being intercepted.

Fix: Wrap _queuePanelTargetOpen changes in setState() to force
widget rebuild including PopScope with correct canPop value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:01:45 +00:00
Your NameandClaude Opus 4.5 66f93c933f Fix Android back gesture failures with target state tracking
- Add _queuePanelTargetOpen boolean to track intended panel state
  separate from animation value, preventing race conditions where
  animation crosses 0.5 threshold during back gesture
- Set target state immediately when open/close is triggered
- Use target state for PopScope canPop check instead of animation value
- Add withHaptic parameter to _closeQueuePanelWithSpring() to avoid
  double haptic (system + app) on Android back gesture

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:51:11 +00:00
Your NameandClaude Opus 4.5 db995c5643 Add physical edge gesture absorbers to block Dismissible
Previous approaches using state tracking in confirmDismiss didn't
reliably block edge gestures from reaching Dismissible.

New approach: Add invisible GestureDetector overlays on left and
right edges of the queue list Stack. These physically absorb all
horizontal drag gestures from the edge zones before they can reach
the Dismissible widgets underneath.

The absorbers use HitTestBehavior.translucent so they don't block
taps, only horizontal drags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:35:45 +00:00
Your NameandClaude Opus 4.5 a3af055688 Use static position tracking for edge zone detection
The Dismissible gesture system operates independently of the Listener.
Using static variables ensures the edge zone state persists correctly:

- Store last pointer down X position as static variable
- Add static getter lastTouchWasInEdgeZone for confirmDismiss
- Increased edge dead zone to 48px for better Android gesture coverage
- Edge touches now clear swipe state immediately

This ensures Dismissible's confirmDismiss can reliably check if the
gesture started from a screen edge, blocking accidental deletes
during Android back gesture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:15:40 +00:00
Your NameandClaude Opus 4.5 29ea825a12 Fix Android back gesture edge cases
1. Prevent Dismissible from triggering on edge swipes:
   - Keep _startedInEdgeZone flag until next touch (not reset on pointer up)
   - Check flag in confirmDismiss to reject edge-initiated swipes
   - This prevents Android back from right edge triggering swipe-to-delete

2. Fix PopScope sometimes failing to close:
   - Remove strict isAnimating guard that could block close
   - Stop any existing animation and start close immediately
   - Always respond to back gesture when queue panel is open

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:06:10 +00:00
Your NameandClaude Opus 4.5 212a1e0818 Fix edge zone detection to use screen coordinates
Bug: Was checking local X within queue panel widget, but queue panel
slides in from right side of screen. Local x=0 is middle of screen,
not the screen edge where Android back gesture starts.

Fix: Use global event.position.dx (screen coordinates) to detect
if touch started near screen edges, not local widget coordinates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:41:43 +00:00
Your NameandClaude Opus 4.5 751d317f2a Fix double haptic on Android back gesture
Root cause: QueuePanel's Listener was detecting Android back gesture
(edge swipe) as a swipe-to-close, triggering haptic. PopScope also
handled it, causing double haptic.

Fix: Add edge dead zone (40px) to QueuePanel's swipe detection.
Swipes starting within the edge zone are ignored, letting only
PopScope handle the Android back gesture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:35:56 +00:00
Your NameandClaude Opus 4.5 9b38f4fa3c Remove finger-following from queue swipe gesture
The direct value manipulation during swipe was causing jank from the
transition between finger tracking and spring animation. Simplified to:

- Swipe gesture just detects direction and velocity
- On release, decides to close or stay open based on velocity/displacement
- No intermediate finger tracking - cleaner animation trigger
- Panel either closes with spring or stays fully open

This eliminates the jank from mixed animation modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:24:48 +00:00
Your NameandClaude Opus 4.5 07b2210a6a Fix swipe close jerk by removing CurvedAnimation distortion
Root cause: CurvedAnimation was transforming spring physics output,
causing discontinuity between finger tracking and spring animation.

During swipe:
- Controller value set to 0.6 (finger position)
- CurvedAnimation transforms: fastOutSlowIn(0.6) ≈ 0.78
- Panel shows at wrong position (not following finger 1:1)
- When spring starts, there's a jump to correct position → jerk

Fix: Use raw AnimationController directly instead of CurvedAnimation.
Spring physics already provide natural easing - applying another
curve on top distorts the motion and breaks finger tracking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:16:20 +00:00
Your NameandClaude Opus 4.5 2e95cb6c95 Fix double close/haptic: remove competing gesture handler
Root cause: Two gesture handlers both triggered close on swipe right:
1. GestureDetector.onHorizontalDragEnd called _toggleQueuePanel()
2. QueuePanel's Listener called _closeQueuePanelWithSpring() via callback

Both fired on the same gesture, causing double close animation and
double haptic feedback.

Fix: Remove the redundant close trigger from GestureDetector since
QueuePanel's Listener already handles its own swipe-to-close.
Swipe left to OPEN queue is still handled by GestureDetector.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:49:05 +00:00
Your NameandClaude Opus 4.5 1e64a6bbce Fix queue panel double-close animation glitch
Root cause: The .then() callbacks that snapped controller value to
exactly 0/1 after spring animation caused a visible visual jump.

Changes:
- Remove all .then() snap callbacks from spring animations
- Use overdamped springs (damping > critical) to ensure clean settle
  without oscillation or need for manual snapping
- Open spring: stiffness=400, damping=42 (critical damping = 40)
- Close spring: stiffness=450, damping=45 for snappier close
- Snap-back spring uses same overdamped settings

The overdamped spring naturally settles at exactly 0/1 without
bouncing, eliminating the need for post-animation value correction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:41:01 +00:00
Your NameandClaude Opus 4.5 fea33a9720 Fix queue panel only opening once after spring animation
Spring simulations don't always land exactly at 0/1, causing the toggle
check to fail. Fixed by:
- Using threshold check (< 0.1) instead of exact equality
- Snapping controller value to exact 0/1 when spring settles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:29:05 +00:00
Your NameandClaude Opus 4.5 7b372a7984 Improve queue panel animations and swipe gesture
- Use spring physics for smoother open/close animations
- Add haptic feedback on queue open, close, and swipe start
- Fix swipe-to-close gesture requiring 2 attempts:
  - Reduced horizontal detection tolerance (1.5x → 1.2x)
  - Added direction locking to prevent accidental cancellation
  - Use weighted velocity averaging for smoother detection
  - Consider total displacement for slow swipes
- Lowered velocity threshold (300 → 200) for easier close
- Increased position threshold (0.5 → 0.65) for more forgiving close

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:12:17 +00:00
Your NameandClaude Opus 4.5 2dcc6d5e24 Add responsive haptic feedback for queue drag reordering
- Medium impact on drag start (confirms grab)
- Selection click on each reorder (subtle feedback when items swap)
- Light impact on drop (confirms release)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 06:01:39 +00:00
Your NameandClaude Opus 4.5 460f7208eb Fix left side alignment - compensate margin on both sides
Current item has 8px margin for highlight effect.
Now compensate on BOTH left and right contentPadding:
- 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

Album art and icons now align consistently across all items.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:59:27 +00:00
Your NameandClaude Opus 4.5 a4c08cadc8 Fix right edge gap with conditional contentPadding
- Current item has 8px margin for highlight effect
- Non-current items have no margin
- Use contentPadding right=0 for current, right=8 for others
- This compensates so both have equal 30px gap from icon to edge
- Reduced from original 38px (non-current) and 46px (current)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:50:54 +00:00
Your NameandClaude Opus 4.5 36e5ee7afd Fix queue item trailing alignment properly
- Keep 48x48 touch target (Material Design guideline)
- Wrap play arrow in matching 48x48 SizedBox for visual alignment
- Remove duration right padding (48px box provides natural spacing)
- Both current track and other tracks now have consistent icon positioning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:36:45 +00:00
Your NameandClaude Opus 4.5 a8bb34ff8c Reduce drag handle touch target width to close gap with duration
- Reverted contentPadding and alignment changes
- Reduced SizedBox width from 48 to 32 (kept height 48 for vertical touch area)
- This brings drag handle closer to duration while maintaining touch target

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:34:50 +00:00
Your NameandClaude Opus 4.5 2883899e75 Align drag handle icon to right edge of touch target
Changed Center to Align with centerRight alignment so the drag handle
icon aligns with the play arrow position on the current track.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:26:31 +00:00
Your NameandClaude Opus 4.5 3e91bbb10c Reduce gap between drag handles and right screen edge in queue panel
Set custom contentPadding on ListTile to reduce right padding from
default 16px to 8px, bringing drag handles closer to the edge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:15:24 +00:00
Your Name b63d35bbe9 Revert "Move duration and drag handles to right side of queue items"
This reverts commit 8e4014bd56.
2026-01-08 05:14:43 +00:00
Your NameandClaude Opus 4.5 8e4014bd56 Move duration and drag handles to right side of queue items
- Add _formatDuration helper method for displaying track duration
- Move drag handle from leading to trailing position
- Display duration text next to drag handle
- Show play arrow icon for current item instead of drag handle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:11:31 +00:00
Your NameandClaude Opus 4.5 dc6bdae5df Fix drag by reverting to Listener (bypasses gesture arena)
GestureDetector was losing to Dismissible in the gesture arena.
Listener uses raw pointer events and doesn't compete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:02:37 +00:00
Your NameandClaude Opus 4.5 7bb1121867 Fix drag handle with explicit 48x48 touch target
Use SizedBox instead of Padding to ensure touch area isn't constrained

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:57:42 +00:00
Your NameandClaude Opus 4.5 57e1a9904f Fix drag handle touch area - reduce excessive padding
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:55:45 +00:00
Your NameandClaude Opus 4.5 de0290ce66 Add rounded corners to current track highlight in queue
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:46:43 +00:00
Your NameandClaude Opus 4.5 1e64673ffe Theme nav bar icons during player expansion when adaptive is on
- Add primaryColor to PlayerExpansionState
- Pass adaptive primary color from expanded player to nav bar
- Blend nav bar icon color during expansion animation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:23:34 +00:00
Your NameandClaude Opus 4.5 660c4d8388 Fix Material You and adaptive color theming issues
- Fix nav bar icon color to use Material You when enabled (was falling back to lilac)
- Fix stale adaptive colors hanging on when returning to home
- Only use adaptive colors when they actually exist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:13:54 +00:00
Your NameandClaude Opus 4.5 00ccbf9e59 Improve drag handle touch target and remove refresh button
- Wrap drag handle in GestureDetector with larger padding (12h x 16v)
- Use HitTestBehavior.opaque for reliable touch detection
- Handle all pan events in GestureDetector for smoother dragging
- Remove redundant refresh button from header (queue syncs via WebSocket)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:46:04 +00:00
Your NameandClaude Opus 4.5 adc37c3320 Fix drag position when scrolled + add clear queue button
**Drag position fix:**
- Added GlobalKey to Stack for accurate position tracking
- Calculate overlay position relative to Stack, not screen
- Track touch offset within item for smooth finger following
- Works correctly when list is scrolled down

**Clear queue feature:**
- Added delete_sweep button in queue header
- Added queueCommandClear API method (player_queues/clear)
- Optimistic update clears list immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:36:03 +00:00
Your NameandClaude Opus 4.5 a3f0a47b96 Comprehensive queue panel gesture refactor
Based on detailed audit, fixed dual gesture system conflict:

**Root cause:** Two gesture systems (GestureDetector + Listener) were
fighting to control the same animation controller, causing jitter and
stuck states.

**Solution:** Use ONLY the Listener approach (bypasses gesture arena):
- Removed GestureDetector horizontal drag handling for queue panel
- Removed unused state variables and methods
- Fixed Listener to handle direction changes (snap back when vertical)
- Added proper instantaneous velocity calculation
- Simplified animation callbacks in expandable_player

**Other cleanup:**
- Removed dead code (_listKey, _itemListsEqual, _buildAnimatedItem)
- Fixed _pendingReorder timer stacking with cancellable Timer
- Proper state cleanup in _startDrag and _resetSwipeState

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:20:23 +00:00
Your NameandClaude Opus 4.5 cd5bef04c2 Fix swipe-to-close gesture on queue track items
- Add swipe callbacks to QueuePanel (onSwipeStart, onSwipeUpdate, onSwipeEnd)
- Use Listener for raw pointer events to bypass gesture arena
- Wire callbacks to expandable_player's queue panel animation controller
- Swipe-to-close now works on tracks, not just header

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:00:51 +00:00
Your NameandClaude Opus 4.5 bd3be91095 Add optimistic update for tap-to-skip in queue
- Add _optimisticCurrentIndex state to show tapped track as current immediately
- Update _buildQueueList to use optimistic index when set
- Clear optimistic state when server catches up or after timeout
- Provides instant visual feedback when user taps a track

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:54:08 +00:00
Your NameandClaude Opus 4.5 5c7970029c Add gesture-driven queue panel close (follow thumb)
- Add state variables for queue panel horizontal drag tracking
- Create _handleQueuePanelDragStart/Update/End/Cancel methods
- Wire up horizontal drag handlers when queue panel is open
- Remove redundant swipe-to-close from QueuePanel itself
- Queue panel now follows finger when swiping right to close

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:51:53 +00:00
Your Name 2721e83cbf feat: tap track in queue to skip to that track
- Add queueCommandPlayIndex API method
- Call it on tap (except for currently playing track)
2026-01-07 20:37:41 +00:00
Your Name 1b669aecec fix: don't sync on order changes - trust local order after reorder
Only sync queue items when player changes or item count changes.
This prevents stale server state from flashing before correct order loads.
2026-01-07 20:26:18 +00:00
Your Name 05f44a6cc0 fix: use pos_shift (relative) instead of new_index (absolute) for queue move API
MA API expects pos_shift: positive=down, negative=up, 0=next
2026-01-07 20:03:10 +00:00
Your Name 999e56d116 fix: increase pendingReorder delay to 2s for server propagation 2026-01-07 19:27:57 +00:00
Your Name ef68252ba5 fix: correct API parameter name for queue move (item_id_or_index -> queue_item_id) 2026-01-07 19:19:01 +00:00
Your Name 7d47fa18f5 debug: add logging to queue move API call 2026-01-07 19:07:45 +00:00
Your Name 9542cdb7d7 fix: preserve queue state when panel closes and block all gestures during drag
- Block onVerticalDragStart/Update/End when _isQueueDragging
- Use Offstage instead of conditional rendering to preserve QueuePanel state
- QueuePanel stays mounted when hidden, keeping _pendingReorder and _items
2026-01-07 18:57:07 +00:00
Your Name eb8225b440 fix: prevent queue panel collapse while dragging track
- Add onDraggingChanged callback to QueuePanel
- Block swipe-to-close in expandable_player while queue is dragging
- Add _pendingReorder to prevent state revert after API call
2026-01-07 18:47:18 +00:00
Your Name 7d558ea135 fix: use parent Listener to track drag pointer globally
- Listener on drag handle only starts drag
- Listener wrapping Stack captures move/up/cancel events
- This prevents gesture loss when finger moves off the small drag handle
2026-01-07 18:35:30 +00:00
Your NameandClaude Opus 4.5 b19acdadc4 fix: disable list scrolling while dragging to prevent gesture conflict
The ListView was stealing the drag gesture after a brief moment,
causing the dragged item to freeze in place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:06:35 +00:00
Your NameandClaude Opus 4.5 4577242e0f fix: use Stack-based overlay instead of OverlayEntry for drag
Replace OverlayEntry with Stack+Positioned within the widget tree.
This should fix the drag overlay not following the finger properly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:44:39 +00:00
Your NameandClaude Opus 4.5 58e8265d2a fix: Improve drag tracking to follow finger smoothly
- Track absolute finger position instead of deltas
- Calculate target index from total offset
- Prevent didUpdateWidget from reverting during drag
- Fix cancel to restore original position

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:24:30 +00:00
Your NameandClaude Opus 4.5 4a9923d431 refactor: Replace great_list_view with custom drag-reorder implementation
Remove great_list_view package (alpha, abandoned, drag bug) and implement
custom drag-to-reorder using Overlay for dragged item. Uses ListView.builder
with manual drag state management.

Avoids:
- ReorderableListView (grey screen bug)
- great_list_view (drag duplication bug)

Features preserved:
- Swipe left to delete (Dismissible)
- Drag handle for reorder
- Right swipe to close panel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:10:54 +00:00
Your NameandClaude Opus 4.5 d81a3ba3a5 docs: Comprehensive README update
Add documentation for all features since v2.0.0:
- Podcasts and Radio Stations
- Multi-room grouping
- Volume precision mode
- Queue improvements (drag/swipe)
- Search enhancements (fuzzy, history, quick actions)
- Letter scrollbar
- HA add-on setup instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:07:02 +00:00
Your Name 1bc35a9a16 Revert "docs: Add Home Assistant add-on setup instructions"
This reverts commit dd738120f1.
2026-01-07 12:49:31 +00:00
Your NameandClaude Opus 4.5 dd738120f1 docs: Add Home Assistant add-on setup instructions
Clarify that users need the Music Assistant URL (port 8095), not the
Home Assistant URL. Add instructions for finding Base URL and notes
about remote access setup.

Closes #31

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:48:08 +00:00
Your NameandClaude Opus 4.5 f26548cc17 docs: Update screenshots
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:43:15 +00:00
Your NameandClaude Opus 4.5 1bb6a49e22 feat: Faster volume updates in precision mode (50ms vs 150ms)
Precision mode now sends volume updates 3x faster for smoother control.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:40:44 +00:00
131 changed files with 33373 additions and 6163 deletions
+4 -17
View File
@@ -31,8 +31,8 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
flutter-version: '3.38.0'
channel: 'master'
cache: true
- name: Cache Gradle dependencies
@@ -63,10 +63,7 @@ jobs:
- name: Setup signing keystore
run: |
# Decode keystore from secret (persistent key for consistent signatures)
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
# Create key.properties file
cat > android/key.properties << EOF
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
@@ -75,12 +72,7 @@ jobs:
EOF
- name: Build APK
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
run: flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
@@ -91,10 +83,5 @@ jobs:
- name: Upload APK summary
run: |
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
+216 -95
View File
@@ -1,136 +1,257 @@
# Ensemble Flutter App - Code Audit Report
# Ensemble Codebase Audit Report
**Date:** January 4, 2026
**Branch:** `audit/code-quality-review`
**Files Audited:** 93 Dart files
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Category | Issues Found | Priority |
|----------|-------------|----------|
| Deprecated APIs | 20 instances | HIGH |
| Missing const constructors | 100+ instances | MEDIUM |
| Image cache parameters | 7 locations | MEDIUM |
| Code duplication | 8 view mode functions | LOW |
| Color constants | 3 files with duplicates | LOW |
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## 1. CRITICAL: Deprecated API Usage
## Critical Issues
### `colorScheme.background` (20 instances)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
**Files affected:**
- lib/main.dart:335, 336
- lib/screens/library_artists_screen.dart:21
- lib/screens/new_home_screen.dart:121
- lib/screens/artist_details_screen.dart:708, 720
- lib/screens/library_playlists_screen.dart:53
- lib/screens/library_tracks_screen.dart:19
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
- lib/screens/album_details_screen.dart:807, 819
- lib/screens/library_albums_screen.dart:19
- lib/screens/search_screen.dart:427, 738, 739
- lib/screens/settings_screen.dart:245
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## 2. HIGH: Missing const Constructors
## High Severity Issues
### EdgeInsets.zero (64 instances)
High-impact optimization - used extensively in button padding.
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
**Key files:**
- lib/screens/album_details_screen.dart (12 instances)
- lib/screens/artist_details_screen.dart (8 instances)
- lib/screens/audiobook_detail_screen.dart (3 instances)
- lib/screens/audiobook_series_screen.dart (2 instances)
- lib/screens/audiobook_author_screen.dart (2 instances)
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### BorderRadius.circular (40+ instances)
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
## Medium Severity Issues
### CachedNetworkImage without memCacheWidth/Height
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
**lib/screens/new_library_screen.dart:**
- Line 1395-1402: Author image (48x48) - missing cache params
- Line 1480-1486: Author card image - missing cache params
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
- Line 1740-1745: Audiobook grid cover - missing cache params
- Line 1959-1969: Playlist cover (56x56) - missing cache params
- Line 2143-2152: Playlist grid covers - missing cache params
- Line 2973-2981: Album cover (56x56) - missing cache params
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## 4. LOW: Code Duplication
## Quick Wins (High Impact, Low Effort)
### View Mode Cycling Functions (8 nearly identical functions)
**File:** lib/screens/new_library_screen.dart
Functions that follow identical switch/case pattern:
- `_cycleArtistsViewMode()` (line 184)
- `_cycleAlbumsViewMode()` (line 200)
- `_cyclePlaylistsViewMode()` (line 216)
- `_cycleAuthorsViewMode()` (line 232)
- `_cycleAudiobooksViewMode()` (line 248)
- `_cycleSeriesViewMode()` (line 286)
- `_cycleRadioViewMode()` (line 303)
- `_cyclePodcastsViewMode()` (line 319)
**Recommendation:** Create generic utility function.
### Duplicated Color Constants (3 files)
Series fallback colors defined identically in:
- lib/widgets/series_row.dart:246-253
- lib/screens/audiobook_series_screen.dart:466-473
- lib/screens/new_library_screen.dart:2112-2119
**Recommendation:** Extract to lib/constants/colors.dart
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## 5. POSITIVE FINDINGS (Already Optimized)
## Refactoring Roadmap
- **RepaintBoundary**: Properly implemented in all list item widgets
- **ListView.builder**: Used consistently with cacheExtent: 1000
- **ScrollController disposal**: All 8+ controllers properly disposed
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
- **Debounced operations**: Color extraction debounced to 300ms
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Recommended Action Plan
## Positive Findings
### Phase 1: Quick Wins (Low Risk)
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
The codebase already demonstrates many good practices:
### Phase 2: Image Optimization
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
### Phase 3: Code Cleanup (Optional)
1. Extract view mode cycling to utility function
2. Centralize color constants
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Complexity
## Files by Priority
| File | Lines | Status |
|------|-------|--------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+71 -25
View File
@@ -20,44 +20,66 @@ 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 left to Delete** - Remove tracks with a simple swipe gesture
- **Swipe right to play next** - Move 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
- **Providers Filter** - Toggle to show only specific provider items
- **Letter Scrollbar** - Fast navigation through long lists
- **Provider Icons** - Provider icons on library covers
### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
- **Smart Scoring** - Results ranked by relevance with colored type indicators
- **Search History** - Quickly access your recent searches
- **Quick Actions** - Long-press any result to add to queue or play next
### Audiobooks
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
- **Series Support** - View audiobooks organized by series with collage cover art
- **Author Browsing** - Browse audiobooks by author
- **Chapter Navigation** - Jump between chapters with timestamp display
- **Progress Tracking** - Track your listening progress across sessions
- **Continue Listening** - Pick up where you left off
- **Mark as Finished/Unplayed** - Manage your reading progress
- **Series Support** - View audiobooks organized by series with collage cover art
### Podcasts
- **Episode Browser** - View full episode list with artwork and descriptions
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
- **Offline Browsing** - Browse your cached library even when disconnected
- **Hero Animations** - Smooth transitions between screens
- **Welcome Screen** - Guided onboarding for first-time users
### Theming
- **Material You** - Dynamic theming based on your device's wallpaper
@@ -67,14 +89,18 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots
<div align="center">
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
</div>
## Download
@@ -84,9 +110,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
## Setup
1. Launch the app
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
2. Enter your Music Assistant server URL
3. Connect to your server
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
### Finding Your Server URL
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
To find the correct URL:
1. Open Music Assistant web UI
2. Go to **Settings** > **About**
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
### Home Assistant Add-on Users
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
@@ -103,7 +149,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)
+37
View File
@@ -0,0 +1,37 @@
# Release Notes Draft
## v2.9.2-beta (TBD)
### Bug Fixes
#### Audiobooks
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
- Sort handler now properly supports ascending and descending year sorts
#### Library Sorting
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
#### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
### Technical Changes
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly
+1 -1
View File
@@ -59,7 +59,7 @@ android {
defaultConfig {
applicationId "com.musicassistant.music_assistant"
minSdk 21
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
+1
View File
@@ -11,6 +11,7 @@
android:label="Ensemble"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:appCategory="audio"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+2 -1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_background_color">#1a1a1a</color>
</resources>
<color name="ic_launcher_background">#1a1a1a</color>
</resources>
+2 -2
View File
@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.1.0'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+2 -2
View File
@@ -22,8 +22,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

+336
View File
@@ -0,0 +1,336 @@
# Per-Provider Sync Implementation
## Date: January 17, 2026
## Overview
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
## Problem Statement
### The Core Issue
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
### Previous Approach (Failed)
The initial implementation tried to filter client-side using `provider_mappings`:
```dart
bool isItemAllowedByProviderFilter(MediaItem item) {
final mappings = item.providerMappings;
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
return false;
}
```
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
### Race Condition Issue
Additionally, when rapidly toggling providers, a race condition occurred:
1. User toggles Provider A off
2. Sync starts for remaining providers
3. User toggles Provider B off before sync completes
4. First sync completes with wrong filter state
5. UI shows incorrect results
## Solution: Per-Provider Sync with Source Tracking
### Concept
Instead of relying on `provider_mappings`, we:
1. **Sync each provider separately** using the MA API's `provider` parameter
2. **Tag each item** with which provider instance(s) returned it
3. **Store source tracking** in the local database
4. **Filter client-side** using the source tracking data (instant)
5. **Background sync** keeps data fresh without blocking UI
### Why This Works
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
## Implementation Details
### 1. Database Schema Update
**File:** `lib/database/database.dart`
Added `sourceProviders` column to `LibraryCache` table:
```dart
class LibraryCache extends Table {
// ... existing columns ...
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
}
```
**Schema version:** 5 → 6
**Migration:**
```dart
if (from < 6) {
await customStatement(
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
);
}
```
### 2. Database Service Updates
**File:** `lib/services/database_service.dart`
Added `sourceProvider` parameter to `cacheItem()`:
```dart
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
String? sourceProvider, // NEW: tracks which provider this item came from
})
```
Added new method to retrieve items with source tracking:
```dart
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
```
Returns tuples of `(itemData, sourceProviderIds)`.
### 3. SyncService Rewrite
**File:** `lib/services/sync_service.dart`
#### New Fields
```dart
// Source provider tracking for client-side filtering
Map<String, List<String>> _albumSourceProviders = {};
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
```
#### Per-Provider Sync Logic
```dart
Future<void> syncFromApi(MusicAssistantAPI api, {
bool force = false,
List<String>? providerInstanceIds,
}) async {
// If specific providers are requested, sync each separately
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
for (final providerId in providerInstanceIds) {
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
// ... etc
]);
// Track source provider for each item
for (final album in albums) {
albumMap[album.itemId] = album;
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
_albumSourceProviders[album.itemId]!.add(providerId);
}
}
}
}
}
```
#### Client-Side Filtering Methods
```dart
/// Filter albums by source provider (instant, no network)
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
if (sources == null || sources.isEmpty) return true; // No tracking = show
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
// Similar methods for artists, audiobooks, playlists
```
### 4. Library Screen Updates
**File:** `lib/screens/new_library_screen.dart`
#### Hybrid Provider Toggle Handler
```dart
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// Instant UI update via global provider toggle
maProvider.toggleProviderEnabled(providerId, enabled);
setState(() {}); // Rebuild with client-side filtering
// Background sync after 1.5s debounce
_providerFilterDebounce?.cancel();
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
if (mounted) {
await maProvider.forceLibrarySync();
if (mounted) setState(() {});
}
});
}
```
#### Updated Tab Builders
Artists tab now uses source tracking:
```dart
Widget _buildArtistsTab(BuildContext context, S l10n) {
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
selector: (_, provider) => (
provider.artists,
provider.isLoading,
provider.enabledProviderIds.toSet()
),
builder: (context, data, _) {
final (allArtists, isLoading, enabledProviders) = data;
// Client-side filtering using SyncService source tracking
final syncService = SyncService.instance;
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: allArtists;
// ... rest of build
},
);
}
```
Similar updates for albums, playlists, and audiobooks tabs.
### 5. Provider Instance Model
**File:** `lib/models/provider_instance.dart` (new file)
```dart
class ProviderInstance {
final String instanceId; // e.g., "spotify--abc123"
final String domain; // e.g., "spotify"
final String name; // e.g., "Spotify (John's Account)"
final bool available;
/// Known music provider domains
static const Set<String> musicProviderDomains = {
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
};
bool get isMusicProvider => musicProviderDomains.contains(domain);
}
```
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
│ - Updates enabledProviderIds │
│ - Triggers setState() for instant UI update │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. UI Rebuilds Immediately │
│ - Selector detects enabledProviderIds change │
│ - Calls SyncService.getArtistsFilteredByProviders() │
│ - Filters using source tracking (instant, no network) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Background Sync (after 1.5s debounce) │
│ - forceLibrarySync() called │
│ - Syncs each enabled provider separately │
│ - Updates source tracking in database │
│ - UI refreshes with fresh data │
└─────────────────────────────────────────────────────────────────┘
```
## Files Changed
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
| `lib/database/database.g.dart` | Auto-generated from schema changes |
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
| `lib/models/provider_instance.dart` | New model for provider instances |
## Testing Checklist
- [ ] Fresh install: First sync should populate source tracking
- [ ] Toggle single provider: UI updates instantly
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
- [ ] Two Spotify accounts: Toggling one only shows that account's items
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
- [ ] Legacy cache: Items without source tracking show regardless of filter
- [ ] Pull to refresh: Refreshes source tracking data
## Known Limitations
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
## Future Improvements
1. **Incremental sync:** Only sync changed items instead of full library
2. **Provider-specific cache age:** Different sync intervals per provider
3. **Offline source tracking:** Persist source tracking independently of full cache
## Branch
All changes are on the `feature/per-provider-sync` branch.
```bash
git checkout feature/per-provider-sync
```
## Commit
```
feat: Add per-provider sync with source tracking for instant filtering
- Add sourceProviders column to database schema (v6) for tracking which
provider instance each item came from
- Update SyncService to sync each enabled provider separately and tag
items with their source provider
- Add client-side filtering methods that use source tracking to properly
differentiate between multiple accounts of the same provider type
- Update library UI to use instant client-side filtering with background
sync for data freshness
```
+217
View File
@@ -0,0 +1,217 @@
# Provider Filter Implementation Notes
## Overview
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
## The Problem
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
## What Works vs What Failed
### Works: Different Provider Types
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
### Failed: Multiple Accounts of Same Provider
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
## Root Cause Analysis
### The provider_mappings Problem
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
```json
{
"provider_mappings": [
{"provider_instance": "spotify--userA_id", "item_id": "..."},
{"provider_instance": "spotify--userB_id", "item_id": "..."}
]
}
```
This happens because:
1. MA's internal database stores that the artist exists
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
3. MA doesn't track WHICH account originally added the item to the library
### MA Web UI Behavior
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
## API Investigation
### Server-Side Filtering Parameter
The MA server's `library_items` method accepts a `provider` parameter:
- Parameter name: `provider` (string or list of strings)
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
- Expected behavior: Filter to only return items from the specified provider's library
### What We Tried
1. **Client-side filtering based on provider_mappings**
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
2. **Server-side filtering with `provider` parameter**
- Result: Partially implemented but behavior was inconsistent
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
3. **Using `provider_instance_id_or_domain` parameter**
- Result: This parameter is for getting specific items by ID, not for filtering library_items
## Code Locations
### Files Modified (in stash)
The implementation attempt is preserved in git stash:
```bash
git stash list # Look for "provider-filter-and-dropdown-work"
git stash show -p stash@{0} # View the changes
```
Key files that were modified:
1. **lib/providers/music_assistant_provider.dart**
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
- `loadDisabledProviders()`: Load from local settings
- `filterByProvider()`: Client-side filtering (enhanced version)
- `isItemAllowedByProviderFilter()`: Check if item should be visible
2. **lib/services/music_assistant_api.dart**
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
- Added `MusicProvider` model parsing from `/providers` endpoint
- `musicProviders` getter: List of available music providers
3. **lib/services/sync_service.dart**
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
- Pass provider filter to API calls during sync
4. **lib/services/settings_service.dart**
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
- `setDisabledMusicProviders()`: Save disabled providers
- `toggleProviderEnabled()`: Toggle and persist
5. **lib/screens/settings_screen.dart**
- UI for toggling individual providers on/off
- Display provider name and instance ID
6. **lib/screens/new_library_screen.dart**
- Integration with provider filtering
7. **lib/models/music_provider.dart** (new file)
- Model for MA provider instances
- Fields: `instanceId`, `domain`, `name`, `available`
## Race Condition Issue
A race condition was identified where:
1. Initial app sync starts WITHOUT provider filter
2. User toggles provider filter
3. `clearCache()` is called
4. New sync with provider filter tries to start
5. "Sync already in progress" - new sync is skipped
6. Result: Old unfiltered data remains
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
## Potential Solutions to Explore
### 1. Investigate MA Server-Side Filtering
The MA server should support filtering by provider. Need to verify:
- Does the `provider` parameter work for filtering between same-type providers?
- Is there a different parameter or API endpoint?
- Check MA source code: https://github.com/music-assistant/server
### 2. Library Sync Per Provider
Instead of syncing all items and filtering, sync each provider's library separately:
```dart
for (final provider in enabledProviders) {
final items = await api.getAlbums(provider: provider.instanceId);
// Store with provider association
}
```
### 3. Track Item Origin
Store which provider originally added each item:
- Modify database schema to track `source_provider`
- When syncing, record which provider the item came from
- Filter based on source, not playability
### 4. Use MA's Internal Library State
MA might have internal state about which items are in which provider's library. Investigate:
- `/music/library/items` endpoint
- Provider-specific library endpoints
- MA's database schema for library associations
### 5. Check MA User Settings
The MA web UI's provider filter might be stored in user settings:
```dart
final userInfo = await api.getCurrentUserInfo();
// Check for provider_filter or similar field
```
## Testing Notes
### Log Messages to Watch
```
🔌 Selected provider for filter: spotify--xxxxx
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
🔍 API getArtists returned XXX items
🔌 filterByProvider: XXX → YYY (disabled=[...])
```
### Test Scenario
1. Configure two Spotify accounts in MA
2. Add different artists to each account's library
3. In Ensemble, select only one Spotify account
4. Verify library shows ONLY that account's items
5. Switch to other account, verify different items
### Expected vs Actual
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
- **Actual**: Artist appears regardless of which account is selected
## MA API Reference
### Get Providers
```
Command: providers
Returns: List of provider instances with type, domain, instanceId, name, available
```
### Library Items with Filter
```
Command: music/artists/library_items
Args: {
limit: int,
offset: int,
provider: string | list<string>, // Filter to specific provider(s)
album_artists_only: bool,
order_by: string
}
```
### Relevant MA Source Files
- `music_assistant/server/controllers/music.py` - Library item retrieval
- `music_assistant/server/models/provider.py` - Provider model
- `music_assistant/server/providers/` - Individual provider implementations
## Conclusion
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
@@ -0,0 +1,271 @@
# Audiobook Provider Filtering Fix
## Date: January 17, 2026 (Continuation)
## Overview
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
---
## Problem Statement
After implementing capability-based provider filtering for the music library, two issues remained:
### Issue 1: Audiobooks Not Filtering During Sync
The bug report showed:
```
📊 Source tracking: 252 albums, 87 artists have provider info
📚 Total audiobooks from browse: 186
```
- Albums and artists had provider source tracking
- Audiobooks had NO provider source tracking (not listed)
- All 186 audiobooks were returned regardless of provider selection
### Issue 2: UI Not Updating Instantly
When toggling providers in the library menu:
- Music tabs weren't updating immediately
- Audiobooks required manual pull-to-refresh
- Changes only appeared after ~600ms (debounce timer)
---
## Root Cause Analysis
### Cause 1: Browse API Ignoring Provider Filter
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
```dart
// BEFORE: providerInstanceIds was ignored when using browse API
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
```
### Cause 2: `notifyListeners()` Called Too Late
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
```dart
// BEFORE: notifyListeners only called after 600ms debounce
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
forceLibrarySync();
notifyListeners(); // Too late! UI should update immediately
});
```
### Cause 3: Audiobook Tabs Not Listening for Changes
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
```dart
// BEFORE: context.read doesn't listen for changes
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
final maProvider = context.read<MusicAssistantProvider>();
final enabledProviders = maProvider.enabledProviderIds.toSet();
// ... widget doesn't rebuild when enabledProviders changes
}
```
---
## Solutions
### Fix 1: Browse API Provider Filtering
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
Added logic to filter library paths by provider ID before browsing:
```dart
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
// If specific providers are requested, filter libraries to only matching providers
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
var librariesToBrowse = enabledLibraries;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
librariesToBrowse = enabledLibraries.where((libPath) {
// Extract provider ID from library path (everything before "://")
final providerEnd = libPath.indexOf('://');
if (providerEnd == -1) return false;
final providerId = libPath.substring(0, providerEnd);
return providerInstanceIds.contains(providerId);
}).toList();
// If no libraries match the requested providers, return empty
if (librariesToBrowse.isEmpty) {
return [];
}
}
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
}
```
### Fix 2: Immediate `notifyListeners()` Call
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
```dart
// Reload the enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
// This enables instant UI updates using cached data with source tracking
notifyListeners();
// Cancel any pending debounce timer
_providerFilterDebounceTimer?.cancel();
// Start debounced sync - waits 600ms for user to finish toggling
// This refreshes the cache with new data from enabled providers
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
_logger.log('🔄 Debounce complete, starting library sync...');
forceLibrarySync();
});
```
### Fix 3: Audiobook Tabs Use Selector
**File:** `lib/screens/new_library_screen.dart`
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
```dart
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
// Use Selector to rebuild when enabledProviderIds changes
return Selector<MusicAssistantProvider, Set<String>>(
selector: (_, provider) => provider.enabledProviderIds.toSet(),
builder: (context, enabledProviders, _) {
final syncService = SyncService.instance;
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// ... rest of widget
},
);
}
```
### Fix 4: Enhanced Sync Logging
**File:** `lib/services/sync_service.dart`
Added audiobook counts to logging:
```dart
// Per-provider log (line 237)
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
// Source tracking summary (line 323)
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
```
---
## Data Flow After Fixes
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
│ │
│ 1. toggleProviderEnabled() called │
│ 2. _enabledProviderIds updated │
│ 3. notifyListeners() called IMMEDIATELY │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Selectors Detect Change │
│ │
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
│ Audiobook tabs: Selector<..., Set<String>> │
│ │
│ → All tabs rebuild immediately │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client-Side Filtering (Instant) │
│ │
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
│ │
│ → Uses EXISTING cache with source tracking │
│ → Instant UI update, no network required │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Background Sync (After 600ms Debounce) │
│ │
│ - Refreshes cache with new data from enabled providers │
│ - Updates source tracking │
│ - Only needed when ENABLING providers (to fetch new data) │
└─────────────────────────────────────────────────────────────────┘
```
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
---
## Expected Behavior After Fixes
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
---
## Testing Checklist
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
- [ ] Toggle between different Spotify accounts, verify correct filtering
- [ ] Sync with ABS library filtering enabled
- [ ] Verify audiobooks appear in source tracking log
- [ ] Toggle ABS provider off in library menu
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
- [ ] Toggle ABS provider back on
- [ ] Verify audiobooks reappear after sync completes
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
---
## Related Documentation
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
---
## Known Limitations
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
@@ -0,0 +1,217 @@
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
## Overview
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
## Issues Addressed
### 1. Podcast Provider Counts Showing Incorrectly
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
**Solution**:
- Added `inLibrary` field to `ProviderMapping` model
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
- Updated podcast filtering in library screen to check `inLibrary` field
**Files Modified**:
- `lib/models/media_item.dart` - Added `inLibrary` field
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
- `lib/screens/new_library_screen.dart` - Updated filtering logic
### 2. iTunes Podcasts Not Appearing in Library
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
**Investigation**:
- Added `itunes_podcasts` to provider capabilities and music provider domains
- Added synthetic provider instance creation for search-only providers
- Added debug logging to understand podcast loading
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
**Files Modified**:
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
### 3. Episode Loading Failing for Non-Library Podcasts
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
### 4. Search Filtering Rework
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
**Solution**:
- Removed `filterSearchResults()` calls from search methods
- Added client-side `_filterByEnabledProviders()` helper
- Applied filtering only when `_libraryOnly` toggle is ON
**Files Modified**:
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
- `lib/screens/search_screen.dart` - Added client-side filtering
### 5. Library Buttons Not Updating Immediately
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart`
- `lib/screens/album_details_screen.dart`
- `lib/screens/artist_details_screen.dart`
## Code Changes
### ProviderMapping.inLibrary Field
```dart
// lib/models/media_item.dart
class ProviderMapping {
final String itemId;
final String providerDomain;
final String providerInstance;
final bool available;
final Map<String, dynamic>? audioFormat;
final bool inLibrary; // NEW: indicates ownership
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
return ProviderMapping(
// ... other fields ...
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
}
```
### Podcast Provider Counting
```dart
// lib/providers/music_assistant_provider.dart
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in _podcasts) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
```
### Optimistic Update Pattern
```dart
// Example from podcast_detail_screen.dart
Future<void> _toggleLibrary() async {
final newState = !_isInLibrary;
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
);
}
// Fire and forget - API call happens in background
maProvider.addToLibrary(...).catchError((e) {
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
```
### Client-Side Search Filtering
```dart
// lib/screens/search_screen.dart
Map<String, List<MediaItem>> _filterByEnabledProviders(
Map<String, List<MediaItem>> results,
Set<String> enabledProviders,
) {
if (enabledProviders.isEmpty) return results;
bool isItemAllowed(MediaItem item) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true;
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowed).toList(),
};
}
// Applied when setting results:
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
```
## Provider Capabilities Update
```dart
// lib/models/provider_instance.dart
static const Map<String, Set<String>> providerCapabilities = {
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'}, // NEW
'tunein': {'radio', 'podcasts'},
// ... other providers
};
```
## Summary of All Modified Files
| File | Changes |
|------|---------|
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
## Key Learnings
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
@@ -0,0 +1,263 @@
# Provider Filtering Improvements
## Date: January 17, 2026
## Overview
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
---
## Changes Made
### 1. Provider Capability Mapping
**File:** `lib/models/provider_instance.dart`
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
```dart
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music and audiobooks)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook-only providers
'audiobookshelf': {'audiobooks'},
// Radio providers
'tunein': {'radio'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
```
**Added helper methods:**
```dart
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
bool supportsContentType(String category) => supportedContentTypes.contains(category);
```
**Benefits:**
- Radio providers never show in Artists tab (by design, not by count)
- Works immediately, even with empty libraries
- Disabled providers still show in menu (if they support the content type)
- Clean separation of concerns
---
### 2. Updated Provider Filtering in MusicAssistantProvider
**File:** `lib/providers/music_assistant_provider.dart`
Updated `getRelevantProvidersForCategory()` to filter by capability:
```dart
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
// ... get counts ...
final result = <(ProviderInstance, int)>[];
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
result.add((provider, count));
}
}
// ...
}
```
---
### 3. Simplified Library Menu Provider Filter
**File:** `lib/screens/new_library_screen.dart`
Simplified the options menu overlay to show all providers that support the current category:
**Before (clunky):**
```dart
if (widget.relevantProviders.where((p) {
final hasItems = p.$2 > 0;
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
!widget.enabledProviderIds.contains(p.$1.instanceId);
return hasItems || isDisabledByUser;
}).length > 1) ...[
```
**After (clean):**
```dart
// relevantProviders is pre-filtered by capability
if (widget.relevantProviders.length > 1) ...[
// Show all providers that support this category
...widget.relevantProviders.map((providerData) {
```
---
### 4. Removed Provider Toggles from Settings Screen
**File:** `lib/screens/settings_screen.dart`
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
- Removed `_providerEnabled` state variable
- Removed provider loading code from `_loadSettings()`
- Removed the entire Music Providers UI section (~100 lines)
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
---
### 5. Fixed Audiobooks Library Provider Filtering
**File:** `lib/screens/new_library_screen.dart`
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
**Fixed `_buildBooksAuthorsTab()`:**
```dart
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
// Client-side filtering using SyncService source tracking
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
// Group filtered audiobooks by author (dynamic, not cached)
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
```
**Fixed `_buildAllBooksTab()`:**
- Same provider filtering pattern
- Dynamic sorting after filtering
- Updated PageStorageKey to include provider count for proper rebuild
---
## Bug Fixes
### Provider Filter Menu Disappearing
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
### Audiobooks Not Filtering by Provider
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Provider Capability Map │
│ (ProviderInstance.providerCapabilities) │
│ │
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
│ audiobookshelf → {audiobooks} │
│ tunein → {radio} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ getRelevantProvidersForCategory(category) │
│ │
│ - Filters _availableMusicProviders by capability │
│ - Returns only providers that support the category │
│ - Includes item counts for display │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Library Menu (Options) │
│ │
│ - Shows providers section if > 1 providers support category │
│ - All relevant providers shown (can toggle on/off) │
│ - Instant UI update via setState() │
│ - Background sync after 1.5s debounce │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Tab Build Methods │
│ │
│ - Get enabledProviderIds from MusicAssistantProvider │
│ - Filter data using SyncService source tracking │
│ - Display filtered results │
└─────────────────────────────────────────────────────────────────┘
```
---
## Known Limitations
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
---
## Testing Checklist
- [x] Music tabs filter by provider correctly
- [x] Provider menu shows only relevant providers per category
- [x] Radio providers don't appear in Artists/Albums tabs
- [x] Spotify appears in Audiobooks tab (has audiobooks)
- [x] Audiobooks Authors tab filters by provider
- [x] Audiobooks All Books tab filters by provider
- [ ] Audiobooks Series tab filters by provider (not implemented)
- [x] Disabled providers can be re-enabled from menu
- [x] Settings screen no longer has provider toggles
---
## Related Documentation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
+221
View File
@@ -0,0 +1,221 @@
# Feature: Replace "Play On" Popups with Player Selector
## Overview
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
## Current State
### PlayerPickerSheet Popup
Used in 7+ places:
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
- `playlist_details_screen.dart` - Play Playlist, Play Radio
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
- `podcast_detail_screen.dart` - Play Episode
- `audiobook_detail_screen.dart` - Play Audiobook
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
### Player Selector (Reveal Overlay)
- Location: `lib/widgets/player/player_reveal_overlay.dart`
- Shows vertical list of all players with artwork, current track, sync status
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
- Triggered by pull-down gesture or device button tap
## Proposed Changes
### 1. Modify PlayerRevealOverlay
**File:** `lib/widgets/player/player_reveal_overlay.dart`
Add new optional parameters:
```dart
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom;
final double miniPlayerHeight;
final bool showOnboardingHints;
// NEW: Callback when player is selected for a pending action
final void Function(Player player)? onPlayerSelected;
// NEW: Context hint to display instead of default hints
// e.g., "Select player to play album"
final String? contextHint;
// NEW: Icon for context hint (default: speaker icon)
final IconData? contextHintIcon;
```
### 2. Modify Hint Display
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
```dart
// Current:
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
// New:
: widget.contextHint != null
? buildHintRow(
widget.contextHintIcon ?? Icons.play_circle_outline,
widget.contextHint!,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
```
### 3. Modify Player Card onTap
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
```dart
// Current:
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
dismiss();
},
// New:
onTap: () {
HapticFeedback.mediumImpact();
if (widget.onPlayerSelected != null) {
// Execute the pending action with selected player
widget.onPlayerSelected!(data.player);
} else {
// Default behavior: just select the player
maProvider.selectPlayer(data.player);
}
dismiss();
},
```
### 4. Add Helper Method to GlobalPlayerOverlay
**File:** `lib/widgets/global_player_overlay.dart`
Add a static method to show player selector with pending action:
```dart
/// Show player selector for a "Play on" action
///
/// [contextHint] - Message shown instead of default hints
/// [onPlayerSelected] - Called when user taps a player
///
/// Example:
/// ```dart
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
/// contextHint: "Select player to play album",
/// onPlayerSelected: (player) {
/// provider.playAlbum(albumId, playerId: player.playerId);
/// },
/// );
/// ```
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(Player player) onPlayerSelected,
IconData? hintIcon,
}) {
// Implementation: trigger the player reveal overlay with these parameters
// May need to store pending action in state and pass to overlay
}
```
### 5. Update Calling Sites
Replace `showPlayerPickerSheet()` calls with new method:
**Example - album_details_screen.dart:**
```dart
// Current (around line 1305-1316):
showPlayerPickerSheet(
context: context,
title: 'Play Album',
onPlayerSelected: (player) async {
GlobalPlayerOverlay.hidePlayer();
await maProvider.playAlbum(albumId, playerId: player.playerId);
GlobalPlayerOverlay.showPlayer();
},
);
// New:
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
onPlayerSelected: (player) async {
await maProvider.playAlbum(albumId, playerId: player.playerId);
},
);
```
### 6. Add Localizations
**File:** `lib/l10n/app_en.arb` (and other language files)
```json
"selectPlayerToPlay": "Select player to play",
"selectPlayerToPlayAlbum": "Select player to play album",
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"selectPlayerToPlayRadio": "Select player to start radio",
"selectPlayerToPlayEpisode": "Select player to play episode",
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
```
## Files to Modify
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
2. `lib/widgets/global_player_overlay.dart` - Add helper method
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
8. `lib/l10n/app_en.arb` - Add new strings
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
## Exception: Queue Transfer
**Keep the popup for `queue_screen.dart`** because:
- Needs to filter out current player (can't transfer to self)
- Needs to filter out certain player types
- Different UX intent (transfer vs play)
## Implementation Order
1. Modify `PlayerRevealOverlay` to accept new parameters
2. Add helper method to `GlobalPlayerOverlay`
3. Test with one screen (album_details_screen.dart)
4. Add localizations
5. Migrate remaining screens
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
## Testing Checklist
- [ ] Album details: Play Album button
- [ ] Album details: Play From Here (track context menu)
- [ ] Album details: Play Radio (track context menu)
- [ ] Playlist details: Play Playlist button
- [ ] Playlist details: Play Radio (track context menu)
- [ ] Search: Play album from results
- [ ] Search: Play playlist from results
- [ ] Search: Play radio from results
- [ ] Podcast detail: Play episode
- [ ] Audiobook detail: Play audiobook
- [ ] Queue transfer still works with popup
- [ ] Long-press to sync still works in player selector
- [ ] Dismiss by tapping backdrop works
- [ ] Dismiss by swiping down works
Binary file not shown.
Binary file not shown.
+3
View File
@@ -29,6 +29,9 @@ class HeroTags {
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
+3
View File
@@ -10,4 +10,7 @@ class NetworkConstants {
/// Default HTTP port
static const int defaultHttpPort = 80;
/// Maximum number of pending WebSocket requests to prevent memory leaks
static const int maxPendingRequests = 100;
}
+3
View File
@@ -31,6 +31,9 @@ class Timings {
/// Search debounce delay
static const Duration searchDebounce = Duration(milliseconds: 500);
/// Navigation debounce to prevent double-tap navigation
static const Duration navigationDebounce = Duration(milliseconds: 500);
/// Delay after track change before updating state
static const Duration trackChangeDelay = Duration(milliseconds: 500);
}
+115 -8
View File
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
/// Whether this item was deleted on the server
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {cacheKey};
}
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 5;
int get schemaVersion => 6;
@override
MigrationStrategy get migration {
@@ -226,6 +230,10 @@ class AppDatabase extends _$AppDatabase {
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
}
// Migration from v5 to v6: Add source_providers column for client-side filtering
if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
}
},
);
}
@@ -368,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
// ============================================
/// Cache a library item
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
Future<void> cacheItem({
required String itemType,
required String itemId,
required String data,
String? sourceProvider,
}) async {
final cacheKey = '${itemType}_$itemId';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
// If sourceProvider is specified, we need to merge with existing providers
if (sourceProvider != null) {
final existing = await getCachedItem(itemType, itemId);
List<String> providers = [];
if (existing != null) {
try {
final existingProviders = existing.sourceProviders;
if (existingProviders.isNotEmpty && existingProviders != '[]') {
providers = List<String>.from(
(await Future.value(existingProviders))
.replaceAll('[', '')
.replaceAll(']', '')
.replaceAll('"', '')
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) => s.trim()),
);
}
} catch (_) {}
}
if (!providers.contains(sourceProvider)) {
providers.add(sourceProvider);
}
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
));
} else {
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
}
}
/// Get cached items by type
@@ -650,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
// ============================================
// Batch Cache Operations
// ============================================
/// Batch size for chunked inserts to prevent SQLite limits
static const int _batchChunkSize = 500;
/// Batch cache multiple items efficiently using drift batch API
/// This is much faster than individual inserts for large datasets.
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
///
/// [items] - List of items to cache, each containing:
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
/// - itemId: the item's ID
/// - data: JSON string of the item
/// - sourceProviders: list of provider instance IDs
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
if (items.isEmpty) return;
// Process in chunks to avoid SQLite variable limits
for (var i = 0; i < items.length; i += _batchChunkSize) {
final chunk = items.skip(i).take(_batchChunkSize).toList();
await batch((b) {
for (final item in chunk) {
final cacheKey = '${item.itemType}_${item.itemId}';
final sourceProvidersJson = item.sourceProviders.isEmpty
? '[]'
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
b.insert(
libraryCache,
LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: item.itemType,
itemId: item.itemId,
data: item.data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
),
mode: InsertMode.insertOrReplace,
);
}
});
}
}
}
/// Data class for batch cache operations
class BatchCacheItem {
final String itemType;
final String itemId;
final String data;
final List<String> sourceProviders;
const BatchCacheItem({
required this.itemType,
required this.itemId,
required this.data,
this.sourceProviders = const [],
});
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -203,6 +203,7 @@
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
+313
View File
@@ -261,6 +261,11 @@
"description": "Home screen section title"
},
"favoritePodcasts": "Favorite Podcasts",
"@favoritePodcasts": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -1055,6 +1060,11 @@
"description": "Spanish language"
},
"french": "French",
"@french": {
"description": "French language"
},
"noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
@@ -1074,6 +1084,7 @@
"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",
@@ -1129,6 +1140,10 @@
"audiobookLibraries": "Audiobook Libraries",
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
"unknownLibrary": "Unknown Library",
"musicProviders": "Music Providers",
"musicProvidersDescription": "Choose which accounts to show in your library",
"libraryRefreshing": "Refreshing library...",
"cannotDisableLastProvider": "Cannot disable the last provider",
"noSeriesFound": "No Series Found",
"ensembleBugReport": "Ensemble Bug Report",
"byOwner": "By {owner}",
@@ -1277,6 +1292,16 @@
"description": "Description for smart sort players setting"
},
"disableAutoSwitch": "Disable Auto-Switch",
"@disableAutoSwitch": {
"description": "Setting to disable automatic player switching"
},
"disableAutoSwitchDescription": "Never automatically switch to a different player",
"@disableAutoSwitchDescription": {
"description": "Description for disable auto-switch setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
@@ -1332,6 +1357,21 @@
"description": "Description for show hints toggle"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
},
"showProviderIcons": "Show Provider Icons",
"@showProviderIcons": {
"description": "Toggle label for showing provider icons on album art"
},
"showProviderIconsDescription": "Display provider logos on album art",
"@showProviderIconsDescription": {
"description": "Description for show provider icons toggle"
},
"pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces"
@@ -1390,5 +1430,278 @@
"type": "String"
}
}
},
"noTracks": "No tracks",
"@noTracks": {
"description": "Empty state title when no tracks exist in library"
},
"addTracksHint": "Add some music to your library to see tracks here",
"@addTracksHint": {
"description": "Hint for adding tracks to library"
},
"noFavoriteRadioStations": "No favorite radio stations",
"@noFavoriteRadioStations": {
"description": "Empty state when no favorite radio stations"
},
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
"@longPressRadioHint": {
"description": "Hint for favoriting radio stations"
},
"noFavoritePodcasts": "No favorite podcasts",
"@noFavoritePodcasts": {
"description": "Empty state when no favorite podcasts"
},
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
"@longPressPodcastHint": {
"description": "Hint for favoriting podcasts"
},
"failedToPlayRadioStation": "Failed to play radio station: {error}",
"@failedToPlayRadioStation": {
"description": "Error when playing radio station fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"itemAlreadyInLibrary": "Item is already in library",
"@itemAlreadyInLibrary": {
"description": "Message when trying to add item already in library"
},
"failedToAddToLibrary": "Failed to add to library",
"@failedToAddToLibrary": {
"description": "Error when adding to library fails"
},
"cannotFindLibraryId": "Cannot find library ID for removal",
"@cannotFindLibraryId": {
"description": "Error when library ID not found for removal"
},
"failedToRemoveFromLibrary": "Failed to remove from library",
"@failedToRemoveFromLibrary": {
"description": "Error when removing from library fails"
},
"failedToPlayAlbum": "Failed to play album: {error}",
"@failedToPlayAlbum": {
"description": "Error when playing album fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"playingOnPlayer": "Playing {name} on {playerName}",
"@playingOnPlayer": {
"description": "Status when playing item on specific player",
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"failedToPlayPlaylist": "Failed to play playlist: {error}",
"@failedToPlayPlaylist": {
"description": "Error when playing playlist fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
"@failedToPlayAudiobook": {
"description": "Error when playing audiobook fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"noOtherPlayersAvailable": "No other players available",
"@noOtherPlayersAvailable": {
"description": "Message when no other players available for transfer"
},
"transferQueue": "Transfer queue",
"@transferQueue": {
"description": "Tooltip for transfer queue button"
},
"clearQueue": "Clear queue",
"@clearQueue": {
"description": "Tooltip for clear queue button"
},
"syncingPlayer": "Syncing {playerName}...",
"@syncingPlayer": {
"description": "Status when syncing player",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"switchPlayer": "Switch Player",
"@switchPlayer": {
"description": "Label for switch player action"
},
"logsCopiedToClipboard": "Logs copied to clipboard",
"@logsCopiedToClipboard": {
"description": "Confirmation when logs copied"
},
"auto": "Auto",
"@auto": {
"description": "Auto option for language selection"
},
"transferQueueTo": "Transfer Queue To",
"@transferQueueTo": {
"description": "Title for transfer queue dialog"
},
"queueTransferredTo": "Queue transferred to {playerName}",
"@queueTransferredTo": {
"description": "Success message when queue transferred",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"failedToTransferQueue": "Failed to transfer queue: {error}",
"@failedToTransferQueue": {
"description": "Error when queue transfer fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToLoadQueue": "Failed to load queue: {error}",
"@failedToLoadQueue": {
"description": "Error when queue loading fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"transferTo": "Transfer to...",
"@transferTo": {
"description": "Label for transfer destination selection"
},
"transferQueueToPlayer": "Transfer queue to another player",
"@transferQueueToPlayer": {
"description": "Tooltip for transfer queue button"
},
"selectPlayerToPlay": "Select a player",
"@selectPlayerToPlay": {
"description": "Generic hint for selecting a player in player selector"
},
"selectPlayerToPlayAlbum": "Select player to play album",
"@selectPlayerToPlayAlbum": {
"description": "Hint for selecting player to play album"
},
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"@selectPlayerToPlayPlaylist": {
"description": "Hint for selecting player to play playlist"
},
"selectPlayerForRadio": "Select player for radio",
"@selectPlayerForRadio": {
"description": "Hint for selecting player to start radio"
},
"selectPlayerForEpisode": "Select player for episode",
"@selectPlayerForEpisode": {
"description": "Hint for selecting player to play episode"
},
"selectPlayerForAudiobook": "Select player for audiobook",
"@selectPlayerForAudiobook": {
"description": "Hint for selecting player to play audiobook"
},
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
},
"localConnection": "Server URL",
"@localConnection": {
"description": "Direct connection mode toggle label"
},
"remoteConnection": "Remote Access ID",
"@remoteConnection": {
"description": "HA Remote Access connection mode toggle label"
},
"remoteAccessId": "Remote Access ID",
"@remoteAccessId": {
"description": "Remote access ID field label"
},
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
"@remoteAccessIdHint": {
"description": "Hint for remote access ID field"
},
"remoteAccessNote": "For Home Assistant add-on users only",
"@remoteAccessNote": {
"description": "Note about remote access availability"
},
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
"@pleaseEnterRemoteId": {
"description": "Validation message for remote access ID"
},
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
"@remoteConnectionFailed": {
"description": "Error message when remote connection fails"
},
"scanQrCode": "Scan QR Code",
"@scanQrCode": {
"description": "Title for QR code scanner screen"
},
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
"@pointCameraAtQr": {
"description": "Instructions for scanning QR code"
},
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
"@cameraPermissionDenied": {
"description": "Error message when camera permission is denied"
}
}
+1
View File
@@ -360,6 +360,7 @@
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"french": "Francés",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
+1400
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+87 -4
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
@@ -84,7 +89,29 @@ Future<void> main() async {
),
);
runApp(const MusicAssistantApp());
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
// Wrap app in zone to catch async errors
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
},
);
}
class MusicAssistantApp extends StatefulWidget {
@@ -179,7 +206,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// Priority order: device list > expanded player > normal navigation
// Priority order: device list > queue panel > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
@@ -187,9 +214,18 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
return true; // We handled it, don't let Navigator process it
}
// Queue panel second - close it before collapsing player
// Use target state (not animation value) to handle rapid open-close timing
// Use withHaptic: false because Android back gesture provides system haptic
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
// No haptic - Android back gesture provides its own
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
@@ -266,12 +302,59 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
// Fallback to English when locale is not supported
localeListResolutionCallback: (locales, supportedLocales) {
// If user has set a specific locale, try to use it
if (localeProvider.locale != null) {
for (final supported in supportedLocales) {
if (supported.languageCode == localeProvider.locale!.languageCode) {
return supported;
}
}
}
// Try to match system locales
if (locales != null) {
for (final locale in locales) {
for (final supported in supportedLocales) {
if (supported.languageCode == locale.languageCode) {
return supported;
}
}
}
}
// Fallback to English (not German)
return const Locale('en');
},
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
// Also include hidden WebView for remote WebRTC bridge
final musicProvider = context.read<MusicAssistantProvider>();
return Stack(
children: [
GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
// WebView for WebRTC - must be VISIBLE to avoid Android throttling
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
borderRadius: BorderRadius.circular(2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: musicProvider.remoteBridgeWebView,
),
),
),
],
);
},
home: const AppStartup(),
),
+38 -4
View File
@@ -16,6 +16,10 @@ class ProviderMapping {
final String providerInstance;
final bool available;
final Map<String, dynamic>? audioFormat;
/// Whether this item is in the library for this specific provider instance.
/// True = this provider "owns" the item (user added it from this account)
/// False = item exists but wasn't added from this provider
final bool inLibrary;
ProviderMapping({
required this.itemId,
@@ -23,15 +27,24 @@ class ProviderMapping {
required this.providerInstance,
required this.available,
this.audioFormat,
this.inLibrary = true,
});
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
// Parse in_library field - MA uses 1/0 or true/false
// Default to true if not present (items from library_items API are in library)
final inLibraryValue = json['in_library'];
final inLibrary = inLibraryValue == null
? true // Default: items fetched from library are in library
: inLibraryValue == true || inLibraryValue == 1;
return ProviderMapping(
itemId: json['item_id'] as String? ?? '',
providerDomain: json['provider_domain'] as String? ?? '',
providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?,
inLibrary: inLibrary,
);
}
@@ -42,6 +55,7 @@ class ProviderMapping {
'provider_instance': providerInstance,
'available': available,
if (audioFormat != null) 'audio_format': audioFormat,
'in_library': inLibrary,
};
}
}
@@ -118,6 +132,17 @@ class MediaItem {
if (duration != null) 'duration': duration!.inSeconds,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaItem &&
runtimeType == other.runtimeType &&
itemId == other.itemId &&
provider == other.provider;
@override
int get hashCode => Object.hash(itemId, provider);
}
class Artist extends MediaItem {
@@ -421,9 +446,20 @@ class Audiobook extends MediaItem {
factory Audiobook.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json);
// Parse year
// Parse year - check top level first, then metadata.release_date (ABS format)
int? year;
final yearValue = json['year'];
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic yearValue = json['year'];
if (yearValue == null && metadata != null) {
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
final releaseDate = metadata['release_date'];
if (releaseDate is String && releaseDate.isNotEmpty) {
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
yearValue = releaseDate.split('-').first;
} else if (releaseDate is int) {
yearValue = releaseDate;
}
}
if (yearValue is int) {
year = yearValue;
} else if (yearValue is String) {
@@ -453,8 +489,6 @@ class Audiobook extends MediaItem {
// Parse chapters - check top level first, then metadata
List<Chapter>? chapters;
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic chaptersData = json['chapters'];
if (chaptersData == null && metadata != null) {
chaptersData = metadata['chapters'];
+128
View File
@@ -0,0 +1,128 @@
/// Represents a music provider instance from Music Assistant.
///
/// Each provider (like Spotify, Tidal) can have multiple instances
/// (e.g., two different Spotify accounts). This class represents
/// a single provider instance with its unique ID.
class ProviderInstance {
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
final String instanceId;
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this instance (e.g., "Spotify (John's Account)")
final String name;
/// Whether this provider is currently available/connected
final bool available;
ProviderInstance({
required this.instanceId,
required this.domain,
required this.name,
required this.available,
});
/// Provider capabilities - maps domain to supported content types
/// This determines which tabs/categories show which providers
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services (support artists, albums, tracks, playlists)
// Spotify also has audiobooks and podcasts
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music, audiobooks, and podcasts)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook and podcast providers
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'},
// Radio providers (tunein also has podcasts)
'tunein': {'radio', 'podcasts'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
/// Known music provider domains that provide library content
static const Set<String> musicProviderDomains = {
// Streaming services
'spotify',
'tidal',
'qobuz',
'deezer',
'ytmusic',
'soundcloud',
'apple_music',
'amazon_music',
// Self-hosted / local
'plex',
'subsonic',
'opensubsonic',
'jellyfin',
'emby',
'navidrome',
'audiobookshelf',
'filesystem',
'itunes_podcasts',
// Other
'tunein',
'radiobrowser',
'snapcast',
'fully_kiosk',
};
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
bool get isMusicProvider => musicProviderDomains.contains(domain);
/// Get supported content types for this provider
Set<String> get supportedContentTypes =>
providerCapabilities[domain] ?? <String>{};
/// Check if this provider supports a specific content type/category
bool supportsContentType(String category) =>
supportedContentTypes.contains(category);
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
return ProviderInstance(
instanceId: json['instance_id'] as String? ?? '',
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
available: json['available'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'instance_id': instanceId,
'domain': domain,
'name': name,
'available': available,
};
}
@override
String toString() => 'ProviderInstance($instanceId: $name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProviderInstance &&
runtimeType == other.runtimeType &&
instanceId == other.instanceId;
@override
int get hashCode => instanceId.hashCode;
}
+65
View File
@@ -0,0 +1,65 @@
/// Represents a provider manifest from Music Assistant.
///
/// Contains metadata about a provider type including its icons.
/// This is separate from ProviderInstance which represents a configured
/// instance of a provider.
class ProviderManifest {
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this provider type
final String name;
/// Description of the provider
final String? description;
/// SVG icon as XML string
final String? iconSvg;
/// SVG icon for dark theme as XML string
final String? iconSvgDark;
/// Monochrome SVG icon as XML string
final String? iconSvgMonochrome;
/// Material Design icon name (fallback)
final String? icon;
ProviderManifest({
required this.domain,
required this.name,
this.description,
this.iconSvg,
this.iconSvgDark,
this.iconSvgMonochrome,
this.icon,
});
/// Whether this manifest has any SVG icon available
bool get hasSvgIcon =>
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
/// Get the best available SVG icon for the given brightness
/// Prefers dark icon for dark themes, falls back to regular or monochrome
String? getSvgIcon({bool isDark = false}) {
if (isDark && iconSvgDark != null) {
return iconSvgDark;
}
return iconSvg ?? iconSvgMonochrome;
}
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
return ProviderManifest(
domain: json['domain'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
iconSvg: json['icon_svg'] as String?,
iconSvgDark: json['icon_svg_dark'] as String?,
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
icon: json['icon'] as String?,
);
}
@override
String toString() => 'ProviderManifest($domain: $name)';
}
+157 -170
View File
@@ -2,168 +2,80 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server
/// Handles WebSocket connection, authentication, and reconnection
/// Callback types for connection events
typedef OnConnected = Future<void> Function();
typedef OnAuthenticated = Future<void> Function();
typedef OnDisconnected = void Function();
/// Provider for connection state management.
///
/// Extracted from MusicAssistantProvider to handle:
/// - WebSocket connection lifecycle
/// - Authentication flow (MA token, credentials)
/// - Reconnection logic
/// - Connection state broadcasting
///
/// Note: This is a partial extraction. Full integration requires updating
/// all consumers to use this provider directly.
class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
final AuthManager _authManager = AuthManager();
MAConnectionState _connectionState = MAConnectionState.disconnected;
MusicAssistantAPI? _api;
String? _serverUrl;
String? _error;
MAConnectionState _connectionState = MAConnectionState.disconnected;
// Callbacks for post-connection initialization
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
StreamSubscription? _connectionStateSubscription;
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
// Callbacks for connection events - set by the facade provider
OnConnected? onConnected;
OnAuthenticated? onAuthenticated;
OnDisconnected? onDisconnected;
// User settings captured during authentication
List<String> _providerFilter = [];
List<String> _playerFilter = [];
// ============================================================================
// GETTERS
// ============================================================================
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService;
String? get serverUrl => _serverUrl;
String? get error => _error;
MAConnectionState get connectionState => _connectionState;
ConnectionProvider() {
_initialize();
}
bool get isConnected =>
_connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
/// Restore auth credentials from persistent storage
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
} else {
_logger.log('🔐 No saved auth credentials found');
}
}
// ============================================================================
// CONNECTION
// ============================================================================
/// Handle Music Assistant native authentication after WebSocket connection
Future<bool> handleMaAuthentication() async {
if (_api == null) return false;
try {
// First, try stored MA token
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
// No valid token - try stored credentials
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
/// Fetch user profile from MA and set owner name + create/activate local profile
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) {
_logger.log('⚠️ Could not fetch user profile');
return;
}
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
final profileName = (displayName != null && displayName.isNotEmpty)
? displayName
: username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
// Create/activate profile in local database
if (DatabaseService.instance.isInitialized && username != null) {
await ProfileService.instance.onMaAuthenticated(
username: username,
displayName: displayName,
);
_logger.log('✅ Local profile activated: $username');
}
} else {
_logger.log('⚠️ No valid name in profile');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
}
}
Future<void> connectToServer(String serverUrl) async {
/// Connect to the Music Assistant server
Future<void> connect(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection
await _api?.disconnect();
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes
_api!.connectionState.listen(
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
@@ -173,9 +85,8 @@ class ConnectionProvider with ChangeNotifier {
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication();
final authenticated = await _handleAuthentication();
if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
@@ -183,15 +94,13 @@ class ConnectionProvider with ChangeNotifier {
return;
}
// No auth required - notify connected
onConnected?.call();
// No auth required, trigger callback
await onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
await onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached data for instant resume');
onDisconnected?.call();
}
},
@@ -214,44 +123,122 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<bool> _handleAuthentication() async {
if (_api == null) return false;
try {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null &&
password != null &&
username.isNotEmpty &&
password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchUserSettings();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
final profileName =
(displayName != null && displayName.isNotEmpty) ? displayName : username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log(
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
}
} else {
_providerFilter = [];
}
// Capture player filter
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log(
'🔒 Player filter active: ${_playerFilter.length} players allowed');
}
} else {
_playerFilter = [];
}
} catch (e) {
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
/// Disconnect from the server
Future<void> disconnect() async {
_connectionStateSubscription?.cancel();
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
/// Check connection and reconnect if needed
Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, connection verified');
if (_serverUrl != null && !isConnected) {
_logger.log('🔄 Attempting reconnection...');
await connect(_serverUrl!);
}
}
@override
void dispose() {
_connectionStateSubscription?.cancel();
_api?.dispose();
super.dispose();
}
-392
View File
@@ -1,392 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
class LibraryProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService;
MusicAssistantAPI? _api;
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
bool _isLoading = false;
String? _error;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Getters
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
bool get isLoading => _isLoading;
String? get error => _error;
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
LibraryProvider(this._cacheService);
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api) {
_api = api;
}
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
}
// ============================================================================
// HOME SCREEN DATA
// ============================================================================
/// Get recently played albums with caching
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!;
}
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh recent albums...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
return _cacheService.getCachedRecentAlbums() ?? [];
}
}
/// Get discover artists (random) with caching
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
}
}
/// Get discover albums (random) with caching
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
}
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_cacheService.invalidateHomeCache();
}
// ============================================================================
// LIBRARY DATA
// ============================================================================
Future<void> loadLibrary() async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
_artists = results[0] as List<Artist>;
_albums = results[1] as List<Album>;
_tracks = results[2] as List<Track>;
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
// ============================================================================
// DETAIL SCREEN DATA
// ============================================================================
/// Get album tracks with caching
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
/// Get playlist tracks with caching
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
/// Get artist albums with caching
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
final libraryAlbums = await _api!.getAlbums();
final artistAlbums = libraryAlbums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH
// ============================================================================
/// Search with caching
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
final cacheKey = query.toLowerCase().trim();
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query"');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
try {
_logger.log('🔄 Searching for "$query"...');
final results = await _api!.search(query);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
void clearState() {
_artists = [];
_albums = [];
_tracks = [];
_isLoading = false;
_error = null;
clearSearchState();
notifyListeners();
}
}
+4
View File
@@ -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:
File diff suppressed because it is too large Load Diff
-964
View File
@@ -1,964 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/local_player_service.dart';
import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
/// Provider for managing player state, selection, and controls
class PlayerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
late final LocalPlayerService _localPlayer;
final CacheService _cacheService;
// Dependencies injected from parent provider
MusicAssistantAPI? _api;
AuthManager? _authManager;
String? _serverUrl;
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Track? _currentTrack;
Timer? _playerStateTimer;
Timer? _adjacentPlayerCacheTimer;
// Local Playback
bool _isLocalPlayerPowered = true;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
Timer? _localPlayerStateReportTimer;
// Pending metadata from player_updated events
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
// Registration guard
Completer<void>? _registrationInProgress;
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
PlayerProvider(this._cacheService, AuthManager authManager) {
_authManager = authManager;
_localPlayer = LocalPlayerService(authManager);
}
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api, String serverUrl) {
_api = api;
_serverUrl = serverUrl;
}
/// Set up event listeners after connection
Future<void> setupEventListeners() async {
if (_api == null) return;
// Listen to built-in player events
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) {
_logger.log('Builtin player event stream error: $error');
},
);
// Listen to player_updated events
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) {
_logger.log('Player updated event stream error: $error');
},
);
}
/// Initialize local playback
Future<void> initializeLocalPlayback() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up skip button callbacks
audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
nextTrackSelectedPlayer();
};
audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
previousTrackSelectedPlayer();
};
if (_api != null) {
await registerLocalPlayer();
}
}
/// Try to adopt an existing ghost player
Future<bool> tryAdoptGhostPlayer() async {
if (_api == null) return false;
try {
final isFresh = await DeviceIdService.isFreshInstallation();
if (!isFresh) {
_logger.log('👻 Not a fresh install, skipping ghost adoption');
return false;
}
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) {
_logger.log('👻 No owner name set, cannot adopt ghost player');
return false;
}
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
if (adoptableId == null) {
_logger.log('👻 No matching ghost player found - will generate new ID');
return false;
}
_logger.log('👻 Found adoptable ghost: $adoptableId');
await DeviceIdService.adoptPlayerId(adoptableId);
_logger.log('✅ Successfully adopted ghost player');
return true;
} catch (e) {
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
return false;
}
}
/// Register local player with MA server
Future<void> registerLocalPlayer() async {
if (_api == null) return;
if (_registrationInProgress != null) {
_logger.log('⏳ Registration already in progress, waiting...');
return _registrationInProgress!.future;
}
_registrationInProgress = Completer<void>();
try {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('🆔 Using player ID: $playerId');
await SettingsService.setBuiltinPlayerId(playerId);
final name = await SettingsService.getLocalPlayerName();
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
if (existingPlayer != null && existingPlayer.available) {
_logger.log('✅ Player already registered and available: $playerId');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
} else if (existingPlayer != null && !existingPlayer.available) {
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
} else {
_logger.log('🆔 Player not found in MA, registering as new');
}
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
await _api!.registerBuiltinPlayer(playerId, name);
_logger.log('✅ Player registration complete');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
} catch (e) {
_logger.log('❌ CRITICAL: Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
_registrationInProgress = null;
rethrow;
}
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
try {
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error reporting local player state (will retry): $e');
}
});
}
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
// Don't try to report state if not authenticated - avoids spamming errors
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round();
final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState(
playerId,
powered: _isLocalPlayerPowered,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayer.volume == 0.0,
);
}
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
try {
final eventPlayerId = event['player_id'] as String?;
final myPlayerId = await SettingsService.getBuiltinPlayerId();
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
return;
}
final command = (event['type'] as String?) ?? (event['command'] as String?);
switch (command) {
case 'play_media':
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
if (urlPath != null && _serverUrl != null) {
String fullUrl;
if (urlPath.startsWith('http')) {
fullUrl = urlPath;
} else {
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
fullUrl = '$baseUrl$path';
}
TrackMetadata metadata;
if (_pendingTrackMetadata != null) {
metadata = _pendingTrackMetadata!;
} else {
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
final albumName = event['album_name'] as String? ?? event['album'] as String?;
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
final durationSecs = event['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
metadata = TrackMetadata(
title: trackName,
artist: artistName,
album: albumName,
artworkUrl: artworkUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
}
_localPlayer.setCurrentTrackMetadata(metadata);
_currentNotificationMetadata = metadata;
await _localPlayer.playUrl(fullUrl);
}
break;
case 'stop':
await _localPlayer.stop();
break;
case 'pause':
await _localPlayer.pause();
break;
case 'play':
await _localPlayer.play();
break;
case 'seek':
final position = event['position'] as int?;
if (position != null) {
await _localPlayer.seek(Duration(seconds: position));
}
break;
case 'volume_set':
final volume = event['volume_level'] as int?;
if (volume != null) {
await _localPlayer.setVolume(volume / 100.0);
}
break;
case 'power_on':
case 'power_off':
case 'power':
bool? newPowerState;
if (command == 'power_on') {
newPowerState = true;
} else if (command == 'power_off') {
newPowerState = false;
} else {
newPowerState = event['powered'] as bool?;
}
if (newPowerState != null) {
_isLocalPlayerPowered = newPowerState;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
}
break;
}
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error handling local player event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
if (playerId == null) return;
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_updatePlayerState();
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?;
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
final imageUrl = currentMedia['image_url'] as String?;
Map<String, dynamic>? metadata;
if (imageUrl != null && _serverUrl != null) {
var finalImageUrl = imageUrl;
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
finalImageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
// Use original URL
}
metadata = {
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
};
}
final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track',
uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_currentTrack = trackFromEvent;
}
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
if (imageUrl != null) {
final images = metadata?['images'] as List?;
if (images != null && images.isNotEmpty) {
final imagePath = images[0]['path'] as String?;
if (imagePath != null) {
_precacheImage(imagePath);
}
}
}
notifyListeners();
}
}
// Handle notification metadata for local player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
if (currentMedia == null) return;
final title = currentMedia['title'] as String? ?? 'Unknown Track';
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
if (imageUrl != null && _serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
imageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
if (imageUrl != null && imageUrl.startsWith('http://')) {
imageUrl = imageUrl.replaceFirst('http://', 'https://');
}
}
}
final newMetadata = TrackMetadata(
title: title,
artist: artist,
album: album,
artworkUrl: imageUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
final mediaType = currentMedia['media_type'] as String?;
if (mediaType == 'flow_stream') return;
_pendingTrackMetadata = newMetadata;
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
(_currentNotificationMetadata!.title != title ||
_currentNotificationMetadata!.artist != artist);
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
_currentNotificationMetadata = newMetadata;
}
} catch (e) {
_logger.log('Error handling player_updated event: $e');
}
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
if (_api == null) return;
try {
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
return;
}
final allPlayers = await _api!.getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
filteredCount++;
return false;
}
if (player.playerId.startsWith('ensemble_')) {
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
filteredCount++;
return false;
}
}
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched();
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
if (_selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Priority 1: Last selected player
if (lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
}
// Priority 2: Built-in (local) player
if (playerToSelect == null && builtinPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId && p.available,
orElse: () => null,
);
}
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
orElse: () => null,
);
}
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
}
}
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
void selectPlayer(Player player, {bool skipNotify = false}) {
_selectedPlayer = player;
SettingsService.setLastSelectedPlayerId(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
if (!skipNotify) {
notifyListeners();
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
if (players.isEmpty) return;
if (preloadAll) {
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
_logger.log('🖼️ Preloading complete');
return;
}
if (_selectedPlayer == null || players.length <= 1) return;
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
if (currentIndex == -1) return;
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
final playersToPreload = <Player>{};
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
}
Future<void> _preloadPlayerTrack(Player player) async {
if (_api == null) return;
try {
if (!player.available || !player.powered) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
final queue = await _api!.getQueue(player.playerId);
if (queue != null && queue.currentItem != null) {
final track = queue.currentItem!.track;
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = track.metadata?['images'] != null;
if (!existingHasImage || newHasImage) {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
}
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
if (artworkUrl512 != null) {
await _precacheImage(artworkUrl512);
}
} else {
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
if (existingTrack?.metadata?['images'] == null) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
}
}
} catch (e) {
_logger.log('Error preloading player track for ${player.name}: $e');
}
}
Future<void> _precacheImage(String url) async {
try {
final imageProvider = NetworkImage(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
_logger.log('Image precache failed for $url: $e');
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
}
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await _updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
_updatePlayerState();
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
bool stateChanged = false;
final allPlayers = await _api!.getPlayers();
final updatedPlayer = allPlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
orElse: () => _selectedPlayer!,
);
_selectedPlayer = updatedPlayer;
stateChanged = true;
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
if (!shouldShowTrack) {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
if (stateChanged) notifyListeners();
return;
}
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
if (_currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name) {
_currentTrack = queue.currentItem!.track;
stateChanged = true;
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
_localPlayer.updateNotification(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artworkUrl: artworkUrl,
duration: track.duration,
);
}
}
} else {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
}
// ============================================================================
// PLAYER CONTROLS
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
if (_selectedPlayer!.isPlaying) {
await _api!.pausePlayer(_selectedPlayer!.playerId);
} else {
await _api!.resumePlayer(_selectedPlayer!.playerId);
}
await refreshPlayers();
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await loadAndSelectPlayers(forceRefresh: true);
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
} catch (e) {
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
}
Future<void> togglePower(String playerId) async {
if (_api == null) return;
try {
final localPlayerId = await SettingsService.getBuiltinPlayerId();
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
if (isLocalPlayer) {
_isLocalPlayerPowered = !_isLocalPlayerPowered;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
await _reportLocalPlayerState();
await refreshPlayers();
} else {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
await _api!.setPower(playerId, !player.powered);
await refreshPlayers();
}
} catch (e) {
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
if (_api == null) return;
try {
await _api!.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
if (_api == null) return;
try {
await _api!.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
if (_api == null) return;
try {
await _api!.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
if (_api == null) return;
try {
await _api!.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
void clearState() {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_availablePlayers = [];
_selectedPlayer = null;
_currentTrack = null;
}
@override
void dispose() {
clearState();
super.dispose();
}
}
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/debug_logger.dart';
/// Callback type for when sleep timer expires and should pause playback
typedef OnSleepTimerExpired = void Function();
/// Provider for sleep timer functionality.
///
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
/// This provider manages:
/// - Timed sleep (pause after X minutes)
/// - End of track sleep (pause when current track ends)
class SleepTimerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer;
/// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired;
// ============================================================================
// GETTERS
// ============================================================================
/// Whether a sleep timer is currently active
bool get isActive => _sleepTimerMinutes != null;
/// Current timer setting: null = off, -1 = end of track, positive = minutes
int? get minutes => _sleepTimerMinutes;
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
Duration? get remaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
// ============================================================================
// TIMER CONTROL
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setTimer(int? minutes) {
// Cancel any existing timer
_cancelInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancel() {
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrack() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
}
@override
void dispose() {
_cancelInternal();
super.dispose();
}
}
+101 -79
View File
@@ -10,8 +10,9 @@ 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 '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
@@ -222,7 +223,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -253,12 +253,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -279,13 +302,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -293,13 +310,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
_logger.log('Error toggling album library: $e');
@@ -529,12 +558,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -609,12 +638,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -672,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _navigateToArtist() {
// Navigate to the first artist if available
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
final artist = widget.album.artists!.first;
if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
final artist = _displayAlbum.artists!.first;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
Navigator.push(
@@ -849,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
child: SizedBox(
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: Stack(
fit: StackFit.expand,
children: [
Container(
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,
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
@@ -902,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Material(
@@ -913,7 +956,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.album.artistsString,
_displayAlbum.artistsString,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
@@ -922,7 +965,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -944,7 +987,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
Row(
children: [
@@ -1015,7 +1058,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
),
),
child: Icon(
@@ -1193,7 +1236,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1216,7 +1259,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
);
},
@@ -1228,13 +1271,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(
@@ -1243,48 +1281,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
startIndex: startIndex,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex];
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
String _formatDuration(Duration duration) {
+152 -209
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
@@ -13,6 +14,7 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -307,7 +309,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -338,12 +339,36 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
return false;
});
} else {
// Remove from library
int? libraryItemId;
@@ -364,13 +389,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -378,13 +397,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove artist from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
return false;
});
}
} catch (e) {
_logger.log('Error toggling artist library: $e');
@@ -401,171 +433,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
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)!.startRadioOn(widget.artist.name),
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);
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
}
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
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, 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);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToAddToQueue,
onPlayerSelected: (player) async {
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
}
Future<void> _loadArtistImage() async {
@@ -786,7 +720,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -807,7 +741,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
// Action Buttons Row
Row(
@@ -861,7 +795,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -897,7 +831,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
],
),
const SizedBox(height: 16),
Spacing.vGap16,
],
),
),
@@ -947,7 +881,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
@@ -968,7 +902,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
@@ -1004,7 +938,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
@@ -1024,7 +958,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
@@ -1032,7 +966,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
_buildAlbumSliver(_providerAlbums),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
],
],
@@ -1071,40 +1005,49 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
children: [
AspectRatio(
aspectRatio: 1.0, // Square album art
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
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,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
children: [
Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
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,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
// Provider icon overlay
if (album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: album.providerMappings!.first.providerDomain,
),
],
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Material(
+46 -31
View File
@@ -5,15 +5,14 @@ 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 '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -150,10 +148,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -172,37 +176,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: ClipOval(
child: _authorImageUrl != null
? CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
MdiIcons.accountOutline,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
// Image covers icon when loaded
if (_authorImageUrl != null)
SizedBox(
width: coverSize,
height: coverSize,
child: ClipOval(
child: CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
)
: Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
@@ -287,8 +295,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
_buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
@@ -395,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
minHeight: 3,
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
+57 -49
View File
@@ -5,13 +5,14 @@ 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 '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
@@ -211,13 +212,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
@@ -226,9 +222,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
widget.audiobook,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
Future<void> _markAsFinished() async {
@@ -504,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
child: SizedBox(
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.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
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.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
@@ -566,7 +574,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
// Author
Text(
@@ -588,7 +596,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
],
// Duration & Progress
const SizedBox(height: 16),
Spacing.vGap16,
Row(
children: [
if (book.duration != null) ...[
@@ -712,7 +720,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -736,7 +744,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
InkWell(
onTap: () {
setState(() {
@@ -767,7 +775,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
),
),
@@ -777,7 +785,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
+51 -35
View File
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../widgets/provider_icon.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
// Progress indicator
if (book.progress > 0)
Positioned(
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
child: SizedBox(
width: 56,
height: 56,
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
size: 16,
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
),
),
),
+6 -2
View File
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
// Copy only the filtered entries that are currently displayed
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: filteredLogs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.logsCopied),
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
report,
subject: S.of(context)!.ensembleBugReport,
);
} catch (e) {
_logger.error('Share failed', error: e);
} finally {
if (mounted) {
setState(() => _isGeneratingReport = false);
+52 -26
View File
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -104,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for this context to avoid hero tag conflicts
const heroSuffix = '_playlists';
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
playlist.trackCount != null
@@ -144,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'playlists',
initialImageUrl: imageUrl,
),
),
);
+371 -5
View File
@@ -5,10 +5,12 @@ import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
import 'home_screen.dart';
import 'qr_scanner_screen.dart';
enum ConnectionMode { local, remote }
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@@ -24,6 +26,7 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
final FocusNode _usernameFocusNode = FocusNode();
bool _isConnecting = false;
@@ -36,6 +39,9 @@ class _LoginScreenState extends State<LoginScreen> {
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
// Remote connection state
ConnectionMode _connectionMode = ConnectionMode.local;
@override
void initState() {
super.initState();
@@ -74,7 +80,10 @@ class _LoginScreenState extends State<LoginScreen> {
_authServerUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_remoteIdController.dispose();
_usernameFocusNode.dispose();
// Note: Don't stop _remoteBridge here - it needs to stay alive for the session
// It will be stopped when the app closes or user disconnects
super.dispose();
}
@@ -443,6 +452,9 @@ class _LoginScreenState extends State<LoginScreen> {
}
if (provider.isConnected) {
// Clear remote access ID since this is a direct/local connection
await SettingsService.clearRemoteAccessId();
// For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
@@ -477,6 +489,104 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
/// Connect via remote bridge (WebRTC tunnel)
Future<void> _connectRemote() async {
final remoteId = _remoteIdController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (remoteId.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterRemoteId;
});
return;
}
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterCredentials;
});
return;
}
setState(() {
_isConnecting = true;
_error = null;
});
_addDebugLog('Starting remote connection to: $remoteId');
try {
// Start the remote bridge via provider (WebView is already in widget tree)
final provider = context.read<MusicAssistantProvider>();
final port = await provider.startRemoteBridge(remoteId, username, password);
if (port == null) {
setState(() {
_error = S.of(context)!.remoteConnectionFailed;
_isConnecting = false;
});
return;
}
_addDebugLog('Remote bridge started on port $port');
// Save credentials BEFORE connecting - needed for MA authentication via bridge
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Connect to the local bridge endpoint
final serverUrl = 'ws://localhost:$port';
_addDebugLog('Connecting to bridge at $serverUrl');
// Pass remoteId as cloud URL for Sendspin to connect directly
await provider.connectToServer(serverUrl, isRemoteMode: true, remoteCloudUrl: remoteId);
// Wait for connection
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 500));
if (provider.isConnected) break;
}
if (provider.isConnected) {
_addDebugLog('Connected successfully via remote bridge');
// Save remote access ID and credentials for future auto-reconnect
await SettingsService.setRemoteAccessId(remoteId);
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// For first-time users, wait for player selection
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) {
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break;
}
}
// Navigate to home screen
if (mounted) {
FocusScope.of(context).unfocus();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
} else {
setState(() {
_error = S.of(context)!.connectionFailed;
_isConnecting = false;
});
}
} catch (e) {
_addDebugLog('Remote connection error: $e');
setState(() {
_error = 'Remote connection failed: ${e.toString()}';
_isConnecting = false;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -509,9 +619,260 @@ class _LoginScreenState extends State<LoginScreen> {
},
),
const SizedBox(height: 48),
const SizedBox(height: 32),
// Server URL
// Connection Mode Toggle
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: _isConnecting ? null : () {
setState(() {
_connectionMode = ConnectionMode.local;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: _connectionMode == ConnectionMode.local
? colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 20,
color: _connectionMode == ConnectionMode.local
? colorScheme.onPrimary
: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 8),
Text(
S.of(context)!.localConnection,
style: TextStyle(
color: _connectionMode == ConnectionMode.local
? colorScheme.onPrimary
: colorScheme.onSurface.withOpacity(0.6),
fontWeight: _connectionMode == ConnectionMode.local
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
onTap: _isConnecting ? null : () {
setState(() {
_connectionMode = ConnectionMode.remote;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: _connectionMode == ConnectionMode.remote
? colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_rounded,
size: 20,
color: _connectionMode == ConnectionMode.remote
? colorScheme.onPrimary
: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 8),
Text(
S.of(context)!.remoteConnection,
style: TextStyle(
color: _connectionMode == ConnectionMode.remote
? colorScheme.onPrimary
: colorScheme.onSurface.withOpacity(0.6),
fontWeight: _connectionMode == ConnectionMode.remote
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
),
),
],
),
),
const SizedBox(height: 32),
// REMOTE MODE: Remote Access ID + Credentials
if (_connectionMode == ConnectionMode.remote) ...[
Text(
S.of(context)!.remoteAccessId,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.remoteAccessIdHint,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: TextField(
controller: _remoteIdController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'ABCD1234-EFGH5-6IJKL-MNOP7890',
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.vpn_key_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
icon: Icon(
Icons.qr_code_scanner_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
onPressed: _isConnecting ? null : () async {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) => const QrScannerScreen(),
),
);
if (result != null && result.isNotEmpty) {
_remoteIdController.text = result;
}
},
),
),
],
),
),
const SizedBox(height: 24),
// Username for remote
Text(
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _usernameController,
focusNode: _connectionMode == ConnectionMode.remote ? _usernameFocusNode : null,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Password for remote
Text(
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.lock_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
obscureText: true,
enabled: !_isConnecting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connectRemote(),
),
const SizedBox(height: 16),
],
// LOCAL MODE: Server URL, Port, etc.
if (_connectionMode == ConnectionMode.local) ...[
Text(
S.of(context)!.serverAddress,
style: textTheme.titleMedium?.copyWith(
@@ -856,6 +1217,7 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
],
], // End of local mode conditional
const SizedBox(height: 16),
@@ -902,7 +1264,9 @@ class _LoginScreenState extends State<LoginScreen> {
ElevatedButton(
onPressed: (_isConnecting || _isDetectingAuth)
? null
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
: _connectionMode == ConnectionMode.remote
? _connectRemote
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -944,7 +1308,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: Text(
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
_connectionMode == ConnectionMode.remote
? S.of(context)!.connect
: (_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
+147 -6
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
@@ -15,6 +16,7 @@ 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';
@@ -39,6 +41,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -49,19 +52,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override
bool get wantKeepAlive => true;
// Track if we had empty favorites on first load (to know when to refresh)
bool _hadEmptyFavoritesOnLoad = false;
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
}
@override
void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// Check if favorites are empty on initial load (so we know to refresh after sync)
void _checkInitialFavoriteState() {
// Schedule after first frame to ensure provider is accessible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
_lastArtistCount = provider.artists.length;
// Check if artists list is empty - favorites will also be empty
if (_lastArtistCount == 0) {
_hadEmptyFavoritesOnLoad = true;
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
// Also listen to provider for when cache loads (before sync completes)
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
}
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
void _onSyncChanged() {
final newStatus = SyncService.instance.status;
// When sync completes and we had empty favorites, refresh to show new data
if (_lastSyncStatus == SyncStatus.syncing &&
newStatus == SyncStatus.completed &&
_hadEmptyFavoritesOnLoad) {
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
_hadEmptyFavoritesOnLoad = false;
// Clean up provider listener if still attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
@@ -79,6 +152,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
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();
@@ -93,6 +167,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -152,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0,
centerTitle: false,
actions: [
// Copy logs button for debugging
IconButton(
icon: const Icon(Icons.bug_report_outlined, size: 20),
onPressed: () {
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${DebugLogger().entries.length} log entries copied'),
duration: const Duration(seconds: 2),
),
);
},
tooltip: 'Copy logs',
),
// Sync indicator - shows when library is syncing in background
ListenableBuilder(
listenable: SyncService.instance,
@@ -246,15 +336,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Count how many rows are currently enabled
int _countEnabledRows() {
int count = 0;
for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++;
}
return count;
}
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
case 'favorite-albums': return _showFavoriteAlbums;
case 'favorite-artists': return _showFavoriteArtists;
case 'favorite-tracks': return _showFavoriteTracks;
case 'favorite-playlists': return _showFavoritePlaylists;
case 'favorite-radio-stations': return _showFavoriteRadioStations;
case 'favorite-podcasts': return _showFavoritePodcasts;
default: return false;
}
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = (availableHeight + 8) / 3;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -296,6 +421,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
// Add spacing between rows (not before first row)
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
}
}
@@ -338,7 +467,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
@@ -346,7 +476,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
@@ -354,7 +485,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return SeriesRow(
key: const ValueKey('discover-series'),
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
loadSeries: () => provider.getDiscoverSeriesWithCache(),
getCachedSeries: () => provider.getCachedDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
@@ -399,6 +531,15 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+129 -62
View File
@@ -5,7 +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 '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -66,7 +66,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -97,12 +96,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add podcast to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -123,13 +145,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -137,13 +153,25 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove podcast from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
@@ -190,10 +218,38 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
List<MediaItem> episodes = [];
// Try loading with the podcast's own ID and provider first
try {
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
}
}
}
}
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
@@ -225,6 +281,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (selectedPlayer != null) {
try {
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -250,20 +308,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
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) {
@@ -426,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
child: SizedBox(
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,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
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,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
@@ -856,8 +923,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
+257
View File
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../l10n/app_localizations.dart';
/// A screen for scanning QR codes to get the Remote Access ID.
class QrScannerScreen extends StatefulWidget {
const QrScannerScreen({super.key});
@override
State<QrScannerScreen> createState() => _QrScannerScreenState();
}
class _QrScannerScreenState extends State<QrScannerScreen> {
late final MobileScannerController _controller;
bool _hasScanned = false;
String _statusMessage = '';
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
formats: [BarcodeFormat.qrCode], // Only scan QR codes
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_hasScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
final String? code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
final remoteId = _extractRemoteId(code);
if (remoteId != null && remoteId.isNotEmpty) {
_hasScanned = true;
Navigator.of(context).pop(remoteId);
return;
} else {
// Show what was scanned for debugging
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Scanned: $code'),
duration: const Duration(seconds: 5),
),
);
}
}
}
}
/// Extract the remote ID from various QR code formats
String? _extractRemoteId(String code) {
// Format 1: Raw remote ID (alphanumeric)
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
return code;
}
// Format 2: ma://remote/<id>
if (code.startsWith('ma://remote/')) {
return code.replaceFirst('ma://remote/', '');
}
// Format 3: URL from app.music-assistant.io
// Examples:
// - https://app.music-assistant.io/?cloudInstanceId=<id>
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
if (code.contains('music-assistant.io')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check query parameters in main URL
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
for (final param in queryParams) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
final fragment = uri.fragment;
if (fragment.contains('?')) {
final fragmentQuery = fragment.split('?').last;
final fragmentParams = Uri.splitQueryString(fragmentQuery);
for (final param in queryParams) {
final value = fragmentParams[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
}
// Check if ID is in path segments
for (final segment in uri.pathSegments) {
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
return segment;
}
}
}
}
// Format 4: Other URL formats - try to extract from query params or path
if (code.contains('://')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check common query param names
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Last path segment if it looks like an ID
if (uri.pathSegments.isNotEmpty) {
final last = uri.pathSegments.last;
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
return last;
}
}
}
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(S.of(context)!.scanQrCode),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
// Toggle torch
IconButton(
icon: ValueListenableBuilder(
valueListenable: _controller,
builder: (context, state, child) {
return Icon(
state.torchState == TorchState.on
? Icons.flash_on
: Icons.flash_off,
);
},
),
onPressed: () => _controller.toggleTorch(),
),
],
),
body: Stack(
children: [
// Camera preview
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt_outlined,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Camera error: ${error.errorCode.name}',
style: TextStyle(color: colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
textAlign: TextAlign.center,
),
],
),
);
},
),
// Scan overlay with instruction
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Scan frame
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.8),
width: 2,
),
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 32),
// Instruction text
Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context)!.pointCameraAtQr,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
if (_statusMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 12,
),
),
],
],
),
),
],
),
),
],
),
);
}
}
+68
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
}
}
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Get available players, excluding the current source player
final allPlayers = maProvider.availablePlayers;
final targetPlayers = allPlayers
.where((p) => p.playerId != player.playerId && p.available)
.toList();
if (targetPlayers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.noOtherPlayersAvailable),
duration: const Duration(seconds: 2),
),
);
return;
}
// Show player picker sheet
await showPlayerPickerSheet(
context: context,
title: S.of(context)!.transferQueueTo,
players: targetPlayers,
onPlayerSelected: (targetPlayer) async {
try {
await maProvider.api?.transferQueue(
sourceQueueId: player.playerId,
targetQueueId: targetPlayer.playerId,
autoPlay: true,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error transferring queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToTransferQueue(e.toString())),
backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 3),
),
);
}
}
},
);
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadQueue,
+353 -231
View File
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
@@ -22,6 +21,7 @@ import 'playlist_details_screen.dart';
import 'audiobook_detail_screen.dart';
import 'podcast_detail_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -137,6 +137,13 @@ class SearchScreenState extends State<SearchScreen> {
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
final Map<String, double> _filterWidths = {};
final Map<String, double> _filterPositions = {};
double _highlightLeft = 0;
double _highlightWidth = 80; // Default width until measured
@override
void initState() {
super.initState();
@@ -161,6 +168,27 @@ class SearchScreenState extends State<SearchScreen> {
return filters;
}
/// Filter search results by enabled providers (only when in-library mode is on)
/// Uses the client-side provider toggles from the library screen
Map<String, List<MediaItem>> _filterByEnabledProviders(
Map<String, List<MediaItem>> results,
Set<String> enabledProviders,
) {
if (enabledProviders.isEmpty) return results; // No filter = show all
bool isItemAllowed(MediaItem item) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true; // No mappings = show
// Item is allowed if it has inLibrary=true for any enabled provider
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowed).toList(),
};
}
/// Animate to a specific filter
void _animateToFilter(String filter) {
final filters = _getAvailableFilters();
@@ -185,6 +213,39 @@ class SearchScreenState extends State<SearchScreen> {
}
}
/// Measure filter chip positions and update highlight
void _measureFilterPositions() {
final filters = _getAvailableFilters();
double left = 0;
for (final filter in filters) {
final key = _filterKeys[filter];
if (key?.currentContext != null) {
final box = key!.currentContext!.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
_filterWidths[filter] = box.size.width;
_filterPositions[filter] = left;
left += box.size.width;
}
}
}
}
/// Update highlight position for the active filter
void _updateHighlightPosition(String activeFilter) {
_measureFilterPositions();
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
/// Scroll filter bar to keep active filter visible
/// Only scrolls when the filter would be obscured (off-screen)
/// Uses post-frame callback to avoid interfering with PageView animation
@@ -200,30 +261,35 @@ class SearchScreenState extends State<SearchScreen> {
// If all filters fit on screen, don't scroll at all
if (maxScroll <= 0) return;
// Approximate width per filter chip (padding + text)
const chipWidth = 80.0;
const horizontalPadding = 16.0;
final filters = _getAvailableFilters();
if (filterIndex < 0 || filterIndex >= filters.length) return;
final filter = filters[filterIndex];
// Update highlight position
_updateHighlightPosition(filter);
// Use measured positions if available, fallback to estimate
final chipLeft = _filterPositions[filter] ?? (filterIndex * 80.0);
final chipWidth = _filterWidths[filter] ?? 80.0;
final chipRight = chipLeft + chipWidth;
final currentScroll = _filterScrollController.offset;
final viewportWidth = _filterScrollController.position.viewportDimension;
// Calculate the left and right edges of the active filter chip
final chipLeft = (filterIndex * chipWidth);
final chipRight = chipLeft + chipWidth;
// Calculate what's currently visible in the viewport
final visibleLeft = currentScroll;
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
final visibleRight = currentScroll + viewportWidth;
// Only scroll if the chip is actually obscured
double? targetOffset;
if (chipRight > visibleRight) {
// Chip is cut off on the right - scroll right just enough to show it
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
// Chip is cut off on the right - scroll right to show it with padding
targetOffset = chipRight - viewportWidth + 8;
} else if (chipLeft < visibleLeft) {
// Chip is cut off on the left - scroll left just enough to show it
targetOffset = chipLeft;
// Chip is cut off on the left - scroll left to show it with padding
targetOffset = chipLeft - 8;
}
// Only animate if we need to scroll
@@ -382,12 +448,21 @@ class SearchScreenState extends State<SearchScreen> {
}
if (mounted) {
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
setState(() {
_searchResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results
@@ -441,81 +516,97 @@ class SearchScreenState extends State<SearchScreen> {
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Search field
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
child: Row(
children: [
// Search field container
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
prefixIcon: Icon(
Icons.search_rounded,
color: colorScheme.onSurface.withOpacity(0.5),
),
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
),
),
),
// Library-only toggle
Tooltip(
message: S.of(context)!.libraryOnly,
child: IconButton(
icon: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
color: _libraryOnly ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
),
onPressed: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
),
),
],
),
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Tooltip(
message: S.of(context)!.libraryOnly,
child: SizedBox(
width: 44,
height: 44,
child: Material(
color: _libraryOnly
? colorScheme.primary
: colorScheme.surfaceVariant.withOpacity(0.6),
shape: const CircleBorder(),
child: InkWell(
onTap: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
customBorder: const CircleBorder(),
child: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
size: 22,
color: _libraryOnly
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
),
),
],
),
),
),
@@ -557,7 +648,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 80,
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.searchForContent,
style: TextStyle(
@@ -610,7 +701,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 64,
color: colorScheme.error.withOpacity(0.7),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
_searchError!,
style: TextStyle(
@@ -619,7 +710,7 @@ class SearchScreenState extends State<SearchScreen> {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Spacing.vGap16,
FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text),
child: Text(S.of(context)!.retry),
@@ -740,7 +831,7 @@ class SearchScreenState extends State<SearchScreen> {
end: Alignment.bottomCenter,
colors: [
colorScheme.background,
colorScheme.surface.withOpacity(0),
colorScheme.background.withOpacity(0),
],
),
),
@@ -934,7 +1025,7 @@ class SearchScreenState extends State<SearchScreen> {
return items;
}
/// Build joined segmented filter selector (like library media type selector)
/// Build joined segmented filter selector with animated sliding highlight
Widget _buildFilterSelector(ColorScheme colorScheme) {
final filters = _getAvailableFilters();
final l10n = S.of(context)!;
@@ -953,42 +1044,93 @@ class SearchScreenState extends State<SearchScreen> {
}
}
// No ClipRRect here - parent container handles clipping with rounded corners
// Ensure GlobalKeys exist for all filters
for (final filter in filters) {
_filterKeys.putIfAbsent(filter, () => GlobalKey());
}
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
return ValueListenableBuilder<String>(
valueListenable: _activeFilterNotifier,
builder: (context, activeFilter, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = activeFilter == filter;
return Material(
// Use theme-aware colors for light/dark mode support
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant.withOpacity(0.6),
child: InkWell(
onTap: () {
// Only update ValueNotifier - no setState needed
_activeFilterNotifier.value = filter;
_animateToFilter(filter);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(
getLabel(filter),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
// Measure positions after build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_measureFilterPositions();
// Update highlight on first build or when filters change
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null &&
(_highlightLeft != position || _highlightWidth != width)) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
});
final selectedIndex = filters.indexOf(activeFilter);
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == filters.length - 1;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(10),
),
child: IntrinsicHeight(
child: Stack(
children: [
// Animated sliding highlight
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _highlightLeft + (isFirstTab ? 0 : 2),
width: _highlightWidth - (isFirstTab ? 0 : 2) - (isLastTab ? 0 : 2),
top: 0,
bottom: 0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
),
),
),
);
}).toList(),
// Filter buttons row (transparent, on top of highlight)
Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = activeFilter == filter;
return GestureDetector(
key: _filterKeys[filter],
onTap: () {
_activeFilterNotifier.value = filter;
_animateToFilter(filter);
},
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(
getLabel(filter),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
);
}).toList(),
),
],
),
),
);
},
);
@@ -1126,7 +1268,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1342,7 +1484,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1536,7 +1678,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1566,37 +1708,58 @@ class SearchScreenState extends State<SearchScreen> {
final isExpanded = _expandedPlaylistId == playlistId;
final isInLib = _isInLibrary(playlist);
// Unique suffix for search context
const heroSuffix = '_search';
return RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: ValueKey(playlistId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
leading: Hero(
tag: HeroTags.playlistCover + playlistId + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Hero(
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: showType
? Row(
@@ -1631,13 +1794,14 @@ class SearchScreenState extends State<SearchScreen> {
if (isExpanded) {
setState(() => _expandedPlaylistId = null);
} else {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
);
@@ -1879,7 +2043,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -2250,7 +2414,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
);
}
}
@@ -2308,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioOnMenu(Track track) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -2335,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Add track to queue on current player (1 tap)
@@ -2447,7 +2604,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -2498,7 +2655,7 @@ class SearchScreenState extends State<SearchScreen> {
// Item is library-only, can't add
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Item is already in library')),
SnackBar(content: Text(S.of(context)!.itemAlreadyInLibrary)),
);
}
return;
@@ -2534,7 +2691,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add to library')),
SnackBar(content: Text(S.of(context)!.failedToAddToLibrary)),
);
}
}
@@ -2561,7 +2718,7 @@ class SearchScreenState extends State<SearchScreen> {
if (libraryItemId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot find library ID for removal')),
SnackBar(content: Text(S.of(context)!.cannotFindLibraryId)),
);
return;
}
@@ -2593,7 +2750,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to remove from library')),
SnackBar(content: Text(S.of(context)!.failedToRemoveFromLibrary)),
);
}
}
@@ -2737,7 +2894,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -2768,7 +2925,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
);
}
}
@@ -2778,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAlbumPlayOnMenu(Album album) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -2792,7 +2944,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${album.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(album.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -2800,14 +2952,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
);
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Add album to queue
@@ -2916,7 +3066,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling album favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -2947,7 +3097,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
);
}
}
@@ -2957,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPlaylistPlayOnMenu(Playlist playlist) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -2971,7 +3116,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${playlist.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(playlist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -2979,14 +3124,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
);
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Play audiobook on current player
@@ -3006,7 +3149,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${audiobook.name}'),
content: Text(S.of(context)!.playing(audiobook.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3014,7 +3157,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
);
}
}
@@ -3024,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -3038,7 +3176,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${audiobook.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(audiobook.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3046,14 +3184,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
);
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Toggle audiobook favorite status
@@ -3131,7 +3267,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling audiobook favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -3141,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioPlayOnMenu(MediaItem radio) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -3155,7 +3286,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${radio.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(radio.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3163,14 +3294,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
);
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Play podcast (navigate to detail screen where episodes can be played)
@@ -3198,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPodcastPlayOnMenu(MediaItem podcast) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlay,
onPlayerSelected: (player) async {
// Select the player, then navigate to podcast detail
maProvider.selectPlayer(player);
@@ -3224,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
);
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
String _formatDuration(Duration duration) {
+68 -109
View File
@@ -31,23 +31,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
// Display settings
bool _showProviderIcons = true;
@override
void initState() {
@@ -75,6 +74,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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();
@@ -84,26 +84,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load home row order
final rowOrder = await SettingsService.getHomeRowOrder();
// Load audiobook library settings
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
final enabled = await SettingsService.getEnabledAbsLibraries();
final libraryEnabled = <String, bool>{};
for (final lib in discovered) {
final path = lib['path'] ?? '';
// null means all enabled
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
if (mounted) {
setState(() {
@@ -115,17 +106,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
_showProviderIcons = showProviderIcons;
});
}
}
@@ -163,6 +154,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
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': ''};
}
@@ -193,6 +186,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
case 'favorite-podcasts':
return _showFavoritePodcasts;
default:
return false;
}
@@ -246,6 +241,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
case 'favorite-podcasts':
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
}
});
}
@@ -638,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
subtitle: Text(S.of(context)!.auto),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -647,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('English'),
title: Text(S.of(context)!.english),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -656,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
title: Text(S.of(context)!.german),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -665,7 +664,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('Español'),
title: Text(S.of(context)!.spanish),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -673,6 +672,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: Text(S.of(context)!.french),
value: 'fr',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('fr'));
Navigator.pop(dialogContext);
},
),
],
),
);
@@ -747,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
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(
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -812,17 +845,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 32),
const SizedBox(height: 24),
// Library section
// Display section
Text(
S.of(context)!.library,
S.of(context)!.display,
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(
@@ -831,24 +865,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
S.of(context)!.showProviderIcons,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
S.of(context)!.showProviderIconsDescription,
style: textTheme.bodySmall?.copyWith(
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<MusicAssistantProvider>().forceLibrarySync();
}
value: _showProviderIcons,
onChanged: (value) {
setState(() => _showProviderIcons = value);
SettingsService.setShowProviderIcons(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
@@ -1033,76 +1062,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[
Text(
S.of(context)!.audiobookLibraries,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.chooseAudiobookLibraries,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: _discoveredLibraries.asMap().entries.map((entry) {
final index = entry.key;
final library = entry.value;
final path = library['path'] ?? '';
final name = library['name'] ?? S.of(context)!.unknownLibrary;
final isEnabled = _libraryEnabled[path] ?? true;
return Column(
children: [
SwitchListTile(
title: Text(
name,
style: TextStyle(color: colorScheme.onSurface),
),
value: isEnabled,
onChanged: (value) async {
setState(() {
_libraryEnabled[path] = value;
});
await SettingsService.toggleAbsLibrary(path, value);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.pullToRefresh),
duration: const Duration(seconds: 2),
),
);
}
},
activeColor: colorScheme.primary,
),
if (index < _discoveredLibraries.length - 1)
Divider(
height: 1,
indent: 16,
endIndent: 16,
color: colorScheme.onSurface.withOpacity(0.1),
),
],
);
}).toList(),
),
),
const SizedBox(height: 32),
],
SizedBox(
width: double.infinity,
height: 50,
@@ -157,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Note: We don't actually stop playback - this button is repurposed for player switching
}
/// Fully stop the foreground service and release resources
/// Called after idle timeout to save battery
Future<void> stopService() async {
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
_isRemoteMode = false;
_currentMediaItem = null;
await _player.stop();
// Call the base stop() to properly stop the foreground service
await super.stop();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
+81 -7
View File
@@ -7,6 +7,7 @@ import 'basic_auth_strategy.dart';
import 'authelia_strategy.dart';
import 'ma_auth_strategy.dart';
import '../debug_logger.dart';
import '../../constants/network.dart';
/// Central authentication manager
/// Manages auth strategy selection, auto-detection, and credential lifecycle
@@ -65,16 +66,18 @@ class AuthManager {
return result;
}
/// Normalize a server URL with appropriate protocol
/// Normalize a server URL with appropriate protocol and port
/// For local IP addresses without an explicit port, defaults to MA port 8095
String _normalizeUrl(String url) {
var baseUrl = url;
var baseUrl = url.trim();
// Check if URL already has a port specified (before adding protocol)
// Port can be specified as host:port or after protocol as http://host:port
bool hasExplicitPort = _hasExplicitPort(baseUrl);
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
if (_isLocalAddress(baseUrl)) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
@@ -84,9 +87,80 @@ class AuthManager {
baseUrl = 'https://$baseUrl';
}
}
// For local addresses without explicit port, add default MA port
// This handles the common case of MA as HAOS add-on where port 8095 is needed
if (!hasExplicitPort) {
final uri = Uri.parse(baseUrl);
if (_isLocalAddress(uri.host)) {
baseUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: NetworkConstants.defaultWsPort,
path: uri.path.isEmpty ? null : uri.path,
).toString();
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
}
}
return baseUrl;
}
/// Check if a URL string has an explicit port specified
bool _hasExplicitPort(String url) {
// Remove protocol if present
var hostPart = url;
if (hostPart.startsWith('http://')) {
hostPart = hostPart.substring(7);
} else if (hostPart.startsWith('https://')) {
hostPart = hostPart.substring(8);
}
// Remove path if present
final pathIndex = hostPart.indexOf('/');
if (pathIndex != -1) {
hostPart = hostPart.substring(0, pathIndex);
}
// Check for port (host:port pattern)
// Need to handle IPv6 addresses like [::1]:8095
if (hostPart.startsWith('[')) {
// IPv6 address
final closeBracket = hostPart.indexOf(']');
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
return hostPart[closeBracket + 1] == ':';
}
return false;
} else {
// IPv4 or hostname - check for colon followed by digits
final colonIndex = hostPart.lastIndexOf(':');
if (colonIndex != -1) {
final portPart = hostPart.substring(colonIndex + 1);
return int.tryParse(portPart) != null;
}
return false;
}
}
/// Check if an address is a local/private address (without protocol)
bool _isLocalAddress(String address) {
// Remove port if present for checking
var host = address;
final colonIndex = host.lastIndexOf(':');
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
host = host.substring(0, colonIndex);
}
return host.startsWith('192.168.') ||
host.startsWith('192.') ||
host.startsWith('10.') ||
host.startsWith('172.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
+91
View File
@@ -20,9 +20,15 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
// Detail screen caching
final Map<String, List<Track>> _albumTracksCache = {};
@@ -112,11 +118,82 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedInProgressAudiobooks != null &&
_inProgressAudiobooksLastFetched != null &&
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached in-progress audiobooks
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
}
/// Check if discover audiobooks cache is valid
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverAudiobooks != null &&
_discoverAudiobooksLastFetched != null &&
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover audiobooks
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
}
/// Check if discover series cache is valid
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverSeries != null &&
_discoverSeriesLastFetched != null &&
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover series
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
}
/// Invalidate audiobook caches
void invalidateAudiobookCaches() {
_cachedInProgressAudiobooks = null;
_inProgressAudiobooksLastFetched = null;
_cachedDiscoverAudiobooks = null;
_discoverAudiobooksLastFetched = null;
_cachedDiscoverSeries = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Audiobook caches invalidated');
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Home screen cache invalidated');
}
@@ -136,6 +213,20 @@ class CacheService {
_logger.log('🗑️ Home artist caches invalidated');
}
/// Clear discover caches (call when provider filter changes)
/// This ensures discover rows are refetched with the new provider filter
void clearDiscoverCaches() {
_cachedDiscoverArtists = null;
_discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_searchCache.clear();
_searchCacheTime.clear();
_logger.log('🗑️ Discover caches cleared for provider filter change');
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
+33
View File
@@ -107,15 +107,18 @@ class DatabaseService {
// ============================================
/// Cache an item with JSON serialization
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
String? sourceProvider,
}) {
return db.cacheItem(
itemType: itemType,
itemId: itemId,
data: jsonEncode(data),
sourceProvider: sourceProvider,
);
}
@@ -132,6 +135,29 @@ class DatabaseService {
}).where((item) => item.isNotEmpty).toList();
}
/// Get cached items with source provider info for client-side filtering
/// Returns list of (data, sourceProviders) tuples
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType) async {
final items = await db.getCachedItems(itemType);
return items.map((item) {
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
List<String> providers = [];
try {
if (item.sourceProviders.isNotEmpty && item.sourceProviders != '[]') {
providers = List<String>.from(
jsonDecode(item.sourceProviders) as List,
);
}
} catch (_) {}
return (data, providers);
} catch (e) {
_logger.log('Error decoding cached item: $e');
return (<String, dynamic>{}, <String>[]);
}
}).where((item) => item.$1.isNotEmpty).toList();
}
/// Get a single cached item
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
final item = await db.getCachedItem(itemType, itemId);
@@ -167,6 +193,13 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]);
}
/// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500.
Future<void> batchCacheItems(List<BatchCacheItem> items) {
return db.batchCacheItems(items);
}
// ============================================
// Player Cache Convenience Methods
// ============================================
+38
View File
@@ -7,6 +7,9 @@ enum ErrorType {
network,
playback,
library,
remote, // Remote connection (WebRTC) errors
dataChannel, // WebRTC data channel errors
signaling, // WebRTC signaling server errors
unknown,
}
@@ -107,6 +110,41 @@ class ErrorHandler {
);
}
// WebRTC/Remote connection errors
if (errorStr.contains('webrtc') ||
errorStr.contains('remote peer') ||
errorStr.contains('remote server disconnected')) {
return ErrorInfo(
type: ErrorType.remote,
userMessage: 'Remote connection lost. Attempting to reconnect...',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Data channel errors
if (errorStr.contains('data channel') ||
errorStr.contains('datachannel')) {
return ErrorInfo(
type: ErrorType.dataChannel,
userMessage: 'Remote data connection failed. Please try reconnecting.',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Signaling errors
if (errorStr.contains('signaling') ||
errorStr.contains('ice') ||
errorStr.contains('peer connection failed')) {
return ErrorInfo(
type: ErrorType.signaling,
userMessage: 'Failed to establish remote connection. Please try again.',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Unknown errors
return ErrorInfo(
type: ErrorType.unknown,
+451 -94
View File
@@ -2,14 +2,19 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'debug_logger.dart';
import 'settings_service.dart';
import '../utils/lru_cache.dart';
class MetadataService {
static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {};
// Cache for artist images
static final Map<String, String?> _artistImageCache = {};
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
// Using NullableLruCache for caches that store null values (failed lookups)
/// Cache for artist/album descriptions (100 entries)
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
/// Cache for artist images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
/// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in)
@@ -34,8 +39,8 @@ class MetadataService {
// Check cache
final cacheKey = 'artist:$artistName';
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey];
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API
@@ -43,7 +48,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc;
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
@@ -53,7 +58,7 @@ class MetadataService {
if (audioDbKey != null && audioDbKey.isNotEmpty) {
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
if (audioDbDesc != null) {
_cache[cacheKey] = audioDbDesc;
_descriptionCache[cacheKey] = audioDbDesc;
return audioDbDesc;
}
}
@@ -83,8 +88,8 @@ class MetadataService {
// Check cache
final cacheKey = 'album:$artistName:$albumName';
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey];
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API (TheAudioDB doesn't have good album info)
@@ -92,7 +97,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc;
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
@@ -251,11 +256,11 @@ class MetadataService {
return null;
}
// Cache for album images
static final Map<String, String?> _albumImageCache = {};
/// Cache for album images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
// Cache for author images (audiobooks)
static final Map<String, String?> _authorImageCache = {};
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
/// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
@@ -326,120 +331,301 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Returns the image URL if found, null otherwise
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
/// Generate name variations for fuzzy matching
/// Returns a list of name variations to try, in priority order
static List<String> _generateNameVariations(String authorName) {
final variations = <String>[];
final original = authorName.trim();
// Always try original first
variations.add(original);
// Handle "Lastname, Firstname" format
if (original.contains(',')) {
final parts = original.split(',').map((p) => p.trim()).toList();
if (parts.length == 2) {
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
}
}
// Try Audnexus (specifically for audiobook authors, uses Audible data)
// Normalize and expand initials for better matching
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
String normalized = original;
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
// Match single letter followed by period and optional space, then another letter
normalized = normalized.replaceAllMapped(
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
(m) => '${m.group(1)}. ${m.group(3)}',
);
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
// This catches the case where we have "R.R" as a unit
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])\.([A-Z])'),
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
);
// Add periods to standalone single uppercase letters (initials without periods)
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])(\s)'),
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
);
if (normalized != original && !variations.contains(normalized)) {
variations.add(normalized);
}
// Remove common titles
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
String withoutTitle = original;
for (final title in titles) {
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
withoutTitle = withoutTitle.substring(title.length).trim();
if (withoutTitle != original) {
variations.add(withoutTitle);
}
break;
}
}
// Remove suffixes
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
String withoutSuffix = withoutTitle;
for (final suffix in suffixes) {
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
variations.add(withoutSuffix);
}
break;
}
}
// Split into parts for further variations (use normalized name for better splitting)
final nameToSplit = normalized != original ? normalized : withoutSuffix;
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.length >= 2) {
// Try first + last only (skip middle names/initials)
final firstLast = '${parts.first} ${parts.last}';
if (!variations.contains(firstLast)) {
variations.add(firstLast);
}
// Check if name starts with initials (single letter or letter with period)
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
// If first part looks like an initial, try just the last name
if (isInitial(parts.first)) {
if (!variations.contains(parts.last)) {
variations.add(parts.last);
}
}
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
// Find where initials end and actual name begins
int initialsEnd = 0;
for (int i = 0; i < parts.length - 1; i++) {
if (isInitial(parts[i])) {
initialsEnd = i + 1;
} else {
break;
}
}
if (initialsEnd > 0 && initialsEnd < parts.length) {
// Try the non-initial part of the name
final restOfName = parts.sublist(initialsEnd).join(' ');
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
variations.add(restOfName);
}
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
final initialsNoPeriods = parts.sublist(0, initialsEnd)
.map((s) => s.replaceAll('.', ''))
.join('');
final compactInitials = '$initialsNoPeriods ${parts.last}';
if (!variations.contains(compactInitials)) {
variations.add(compactInitials);
}
}
}
// Remove duplicates while preserving order
return variations.toSet().toList();
}
/// Try to fetch author image from Audnexus
/// Audnexus requires a two-step process:
/// 1. Search by name to get the ASIN
/// 2. Fetch full author details by ASIN to get the image
static Future<String?> _tryAudnexus(String name) async {
try {
final audnexusUri = Uri.https(
'api.audnex.us',
'/authors',
{'name': authorName},
);
// Step 1: Search for author to get ASIN
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
if (searchData is List && searchData.isNotEmpty) {
// Find the best match - prefer exact name matches
String? bestAsin;
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
if (audnexusResponse.statusCode == 200) {
final data = json.decode(audnexusResponse.body);
// Audnexus returns an array of authors
if (data is List && data.isNotEmpty) {
final author = data[0];
final imageUrl = author['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
for (final result in searchData) {
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
final asin = result['asin'] as String?;
if (asin != null) {
if (resultName == nameLower) {
bestAsin = asin;
break; // Exact match found
}
bestAsin ??= asin; // Keep first result as fallback
}
}
if (bestAsin != null) {
// Step 2: Fetch full author details by ASIN
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
if (detailsResponse.statusCode == 200) {
final detailsData = json.decode(detailsResponse.body);
final imageUrl = detailsData['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Open Library API
/// Try to fetch author image from Open Library
static Future<String?> _tryOpenLibrary(String name) async {
try {
final uri = Uri.https(
'openlibrary.org',
'/search/authors.json',
{
'q': authorName,
'limit': '1',
},
);
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'q': name,
'limit': '1',
});
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final author = authors[0];
final authorKey = author['key'] as String?;
final authorKey = authors[0]['key'] as String?;
if (authorKey != null) {
// Open Library author images are available at:
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify the image actually exists (Open Library returns 404 for missing images)
// Verify image exists - Open Library returns 302 redirects for valid images
try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) {
_authorImageCache[cacheKey] = imageUrl;
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl;
}
} catch (_) {
// Image doesn't exist, continue to cache null
} catch (_) {}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikidata (structured Wikipedia data)
static Future<String?> _tryWikidata(String name) async {
try {
// Search for entity by name
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbsearchentities',
'search': name,
'language': 'en',
'type': 'item',
'limit': '3',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['search'] as List?;
// Try each result to find one with an image
for (final result in results ?? []) {
final entityId = result['id'] as String?;
if (entityId == null) continue;
// Get the image property (P18)
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbgetclaims',
'entity': entityId,
'property': 'P18', // P18 is the "image" property
'format': 'json',
});
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
if (claimsResponse.statusCode == 200) {
final claimsData = json.decode(claimsResponse.body);
final claims = claimsData['claims'] as Map<String, dynamic>?;
final imageProperty = claims?['P18'] as List?;
if (imageProperty != null && imageProperty.isNotEmpty) {
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
if (imageName != null && imageName.isNotEmpty) {
// Convert filename to Wikimedia Commons URL
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
}
}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error: $e', context: 'Metadata');
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Wikipedia API
/// Try to fetch author image from Wikipedia
static Future<String?> _tryWikipedia(String name) async {
try {
// Search for author on Wikipedia
final searchUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'list': 'search',
'srsearch': '$authorName writer',
'srlimit': '1',
'format': 'json',
},
);
// Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'list': 'search',
'srsearch': '$name writer author',
'srlimit': '1',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?;
final results = searchData['query']?['search'] as List?;
if (results != null && results.isNotEmpty) {
final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
},
);
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
});
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
@@ -450,7 +636,6 @@ class MetadataService {
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
@@ -459,7 +644,100 @@ class MetadataService {
}
}
} catch (e) {
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
@@ -467,11 +745,90 @@ class MetadataService {
return null;
}
/// Clears the metadata cache
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
static final Map<String, (String?, String?)?> _lyricsCache = {};
static const int _lyricsCacheMaxSize = 500;
/// Fetches lyrics from LRCLIB (free, no API key required)
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
/// LRCLIB provides both synced (LRC format) and plain lyrics
static Future<(String?, String?)?> getTrackLyrics({
required String trackName,
required String artistName,
String? albumName,
int? durationSeconds,
}) async {
// Build cache key
final cacheKey = 'lyrics:$artistName:$trackName';
if (_lyricsCache.containsKey(cacheKey)) {
return _lyricsCache[cacheKey];
}
// Try LRCLIB API
try {
final params = <String, String>{
'track_name': trackName,
'artist_name': artistName,
};
if (albumName != null && albumName.isNotEmpty) {
params['album_name'] = albumName;
}
if (durationSeconds != null && durationSeconds > 0) {
params['duration'] = durationSeconds.toString();
}
final uri = Uri.https('lrclib.net', '/api/get', params);
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
final response = await http.get(
uri,
headers: {
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final plainLyrics = data['plainLyrics'] as String?;
final syncedLyrics = data['syncedLyrics'] as String?;
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
final result = (plainLyrics, syncedLyrics);
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = result;
return result;
}
} else if (response.statusCode == 404) {
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
} else {
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
}
} catch (e) {
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = null;
return null;
}
/// Evict oldest entries from lyrics cache if over max size
static void _evictLyricsCacheIfNeeded() {
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
_lyricsCache.remove(_lyricsCache.keys.first);
}
}
/// Clears all metadata caches
static void clearCache() {
_cache.clear();
_descriptionCache.clear();
_artistImageCache.clear();
_albumImageCache.clear();
_authorImageCache.clear();
_lyricsCache.clear();
}
}
+548 -77
View File
@@ -8,6 +8,8 @@ import '../constants/network.dart';
import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
@@ -23,6 +25,20 @@ enum MAConnectionState {
error,
}
/// Wrapper for pending requests with timestamp for age-based cleanup
class _PendingRequest {
final Completer<Map<String, dynamic>> completer;
final DateTime createdAt;
_PendingRequest({required this.completer, DateTime? createdAt})
: createdAt = createdAt ?? DateTime.now();
bool get isStale {
const staleThreshold = Duration(minutes: 2);
return DateTime.now().difference(createdAt) > staleThreshold;
}
}
class MusicAssistantAPI {
final String serverUrl;
final AuthManager authManager;
@@ -36,7 +52,7 @@ class MusicAssistantAPI {
MAConnectionState _currentState = MAConnectionState.disconnected;
MAConnectionState get currentConnectionState => _currentState;
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
final Map<String, _PendingRequest> _pendingRequests = {};
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
Completer<void>? _connectionCompleter;
@@ -59,6 +75,16 @@ class MusicAssistantAPI {
int? get schemaVersion => _schemaVersion;
Map<String, dynamic>? get serverInfo => _serverInfo;
// Provider manifests (contains icons)
final Map<String, ProviderManifest> _providerManifests = {};
Map<String, ProviderManifest> get providerManifests => _providerManifests;
// Remote mode flag - when true, images go through WebRTC bridge which doesn't support HTTP
// In this mode, we use external CDN URLs directly instead of imageproxy
bool _isRemoteMode = false;
bool get isRemoteMode => _isRemoteMode;
set isRemoteMode(bool value) => _isRemoteMode = value;
MusicAssistantAPI(this.serverUrl, this.authManager);
// Guard to prevent multiple simultaneous connection attempts
@@ -79,13 +105,15 @@ class MusicAssistantAPI {
return;
}
// If connection is in progress, wait for it instead of starting another
// Atomic check-and-set: create new completer first, then check if one exists
// This ensures no async operations happen between check and set
final newCompleter = Completer<void>();
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<void>();
// Set immediately before any async operations to close the race window
_connectionInProgress = newCompleter;
try {
_updateConnectionState(MAConnectionState.connecting);
@@ -277,15 +305,15 @@ class MusicAssistantAPI {
// Handle response to a request
if (messageId != null && _pendingRequests.containsKey(messageId)) {
final completer = _pendingRequests.remove(messageId);
final pending = _pendingRequests.remove(messageId);
if (data.containsKey('error_code')) {
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
completer!.completeError(
pending!.completer.completeError(
Exception('${data['error_code']}: ${data['details']}'),
);
} else {
completer!.complete(data);
pending!.completer.complete(data);
}
return;
}
@@ -344,9 +372,20 @@ class MusicAssistantAPI {
}
}
// Clean up stale requests before checking limits
_cleanupStaleRequests();
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
'Too many pending requests (${_pendingRequests.length}). '
'Please wait for existing requests to complete.',
);
}
final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
_pendingRequests[messageId] = _PendingRequest(completer: completer);
final message = {
'message_id': messageId,
@@ -369,6 +408,27 @@ class MusicAssistantAPI {
}
}
/// Clean up stale pending requests that have been waiting too long
void _cleanupStaleRequests() {
final staleIds = <String>[];
for (final entry in _pendingRequests.entries) {
if (entry.value.isStale && !entry.value.completer.isCompleted) {
staleIds.add(entry.key);
}
}
if (staleIds.isNotEmpty) {
_logger.log('Cleanup: Removing ${staleIds.length} stale pending requests');
for (final id in staleIds) {
final pending = _pendingRequests.remove(id);
if (pending != null && !pending.completer.isCompleted) {
pending.completer.completeError(
TimeoutException('Request expired after 2 minutes'),
);
}
}
}
}
// Library browsing methods
Future<List<Artist>> getArtists({
int? limit,
@@ -376,23 +436,31 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
bool albumArtistsOnly = true,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final args = {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
};
_logger.log('🔍 API getArtists args: $args');
final response = await _sendCommand(
'music/artists/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
},
args: args,
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
return [];
}
_logger.log('🔍 API getArtists returned ${items.length} items');
return items
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
.toList();
@@ -402,7 +470,11 @@ class MusicAssistantAPI {
}
}
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
Future<List<Artist>> getRandomArtists({
int limit = 10,
bool albumArtistsOnly = true,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
'music/artists/library_items',
@@ -410,6 +482,8 @@ class MusicAssistantAPI {
'limit': limit,
'order_by': 'random',
'album_artists_only': albumArtistsOnly,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -431,6 +505,8 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
String? artistId,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final args = <String, dynamic>{
@@ -439,7 +515,11 @@ class MusicAssistantAPI {
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist_id': artistId,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
};
_logger.log('🔍 API getAlbums args: $args');
final response = await _sendCommand(
'music/albums/library_items',
@@ -448,6 +528,7 @@ class MusicAssistantAPI {
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
_logger.log('🔍 API getAlbums returned ${items.length} items');
return items
.map((item) => Album.fromJson(item as Map<String, dynamic>))
@@ -465,6 +546,8 @@ class MusicAssistantAPI {
bool? favoriteOnly,
String? artistId,
String? albumId,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
@@ -476,6 +559,9 @@ class MusicAssistantAPI {
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist': artistId,
if (albumId != null) 'album': albumId,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -496,6 +582,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -504,6 +591,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -555,6 +643,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -563,6 +652,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -719,48 +809,125 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
String? authorId,
List<String>? providerInstanceIds,
}) async {
try {
// Check if library filtering is enabled
final allAudiobooks = <Audiobook>[];
final seenIds = <String>{};
// Check if library filtering is enabled (for ABS providers)
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
// If specific libraries are enabled, use browse API for filtering
// Extract ABS provider IDs from library paths
final absProviderIds = <String>{};
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
// Otherwise use the faster MA API (returns all audiobooks)
final args = <String, dynamic>{
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (authorId != null) 'author_id': authorId,
};
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
);
// Check for error in response
if (response.containsKey('error_code')) {
return [];
}
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
final audiobooks = <Audiobook>[];
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book);
} catch (e) {
// Skip items that fail to parse
for (final libPath in enabledLibraries) {
final providerEnd = libPath.indexOf('://');
if (providerEnd != -1) {
absProviderIds.add(libPath.substring(0, providerEnd));
}
}
}
return audiobooks;
_logger.log('📚 getAudiobooks: absProviderIds=$absProviderIds, providerInstanceIds=$providerInstanceIds');
// PART 1: Fetch from ABS providers using browse API (for library filtering support)
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
// If specific providers are requested, filter libraries to only matching ABS providers
var librariesToBrowse = enabledLibraries;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
librariesToBrowse = enabledLibraries.where((libPath) {
final providerEnd = libPath.indexOf('://');
if (providerEnd == -1) return false;
final providerId = libPath.substring(0, providerEnd);
return providerInstanceIds.contains(providerId);
}).toList();
}
if (librariesToBrowse.isNotEmpty) {
final browseBooks = await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
for (final book in browseBooks) {
if (!seenIds.contains(book.itemId)) {
seenIds.add(book.itemId);
allAudiobooks.add(book);
}
}
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
// If browse returned nothing, the saved library paths may be stale
// (e.g., after provider was removed and re-added with new instance ID)
// Clear the stale settings so we fall back to library_items API
if (browseBooks.isEmpty) {
_logger.log('📚 Browse returned 0 books - clearing stale library settings');
await SettingsService.clearEnabledAbsLibraries();
await SettingsService.setDiscoveredAbsLibraries([]);
}
}
}
// PART 2: Fetch from non-ABS providers using library_items API
// This gets audiobooks from Spotify, Plex, Jellyfin, etc.
List<String>? nonAbsProviders;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
// Filter to only non-ABS providers from the requested list
nonAbsProviders = providerInstanceIds.where((id) => !absProviderIds.contains(id)).toList();
} else if (absProviderIds.isNotEmpty) {
// No specific providers requested, but we have ABS - need to also fetch from non-ABS
// Pass null to get all, then we'll deduplicate
nonAbsProviders = null; // Will fetch all
}
// Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR
// 2. We have specific non-ABS providers to query, OR
// 3. ABS browse returned 0 items (stale paths were cleared above)
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
// as library_items API would return all audiobooks bypassing the library filter
final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty ||
allAudiobooks.isEmpty || // Fall back if browse got nothing
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) {
final args = <String, dynamic>{
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (authorId != null) 'author_id': authorId,
if (nonAbsProviders != null && nonAbsProviders.isNotEmpty)
'provider': nonAbsProviders,
};
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
);
if (!response.containsKey('error_code')) {
final items = response['result'] as List<dynamic>?;
if (items != null) {
int addedCount = 0;
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
// Deduplicate by itemId
if (!seenIds.contains(book.itemId)) {
seenIds.add(book.itemId);
allAudiobooks.add(book);
addedCount++;
}
} catch (e) {
// Skip items that fail to parse
}
}
_logger.log('📚 Got $addedCount audiobooks from library_items API');
}
}
}
_logger.log('📚 Total audiobooks: ${allAudiobooks.length}');
return allAudiobooks;
} catch (e, stack) {
_logger.log('Error getting audiobooks: $e');
_logger.log('Stack: $stack');
@@ -988,6 +1155,51 @@ class MusicAssistantAPI {
}
}
/// Get lyrics for a track
/// Returns the lyrics text if available, null otherwise
/// Uses the dedicated metadata/get_track_lyrics endpoint
Future<String?> getTrackLyrics(Map<String, dynamic> track) async {
try {
_logger.log('🎤 Getting lyrics for track: ${track['name']}');
// Use the dedicated lyrics endpoint
final response = await _sendCommand(
'metadata/get_track_lyrics',
args: {
'track': track,
},
);
if (response.containsKey('error_code')) {
_logger.log('🎤 Error getting lyrics: ${response['error_code']}');
return null;
}
// Result is a tuple: [plain_lyrics, synced_lyrics]
// We prefer synced lyrics if available, otherwise plain
final result = response['result'];
if (result is List && result.length >= 2) {
final plainLyrics = result[0] as String?;
final syncedLyrics = result[1] as String?;
if (syncedLyrics != null && syncedLyrics.isNotEmpty) {
_logger.log('🎤 Found synced lyrics: ${syncedLyrics.length} chars');
return syncedLyrics;
}
if (plainLyrics != null && plainLyrics.isNotEmpty) {
_logger.log('🎤 Found plain lyrics: ${plainLyrics.length} chars');
return plainLyrics;
}
}
_logger.log('🎤 No lyrics found in result: $result');
return null;
} catch (e) {
_logger.log('🎤 Error getting lyrics: $e');
return null;
}
}
/// Browse Music Assistant content by path
/// Returns a list of browse items (folders or media items)
Future<List<dynamic>> browse(String? path) async {
@@ -1082,7 +1294,7 @@ class MusicAssistantAPI {
for (final item in providerRoot) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
if (path != 'root' && name != '..') {
discoveredLibraries.add({'path': path, 'name': name});
}
@@ -1101,7 +1313,7 @@ class MusicAssistantAPI {
for (final libraryItem in providerRoot) {
final libraryMap = libraryItem as Map<String, dynamic>;
final libraryPath = libraryMap['path'] as String? ?? '';
final libraryName = libraryMap['name'] as String? ?? '';
final libraryName = (libraryMap['name'] as String?) ?? (libraryMap['label'] as String?) ?? '';
// Skip "root" (parent navigation) item
if (libraryPath == 'root' || libraryName == '..') continue;
@@ -1121,7 +1333,7 @@ class MusicAssistantAPI {
// Log library contents to find series
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
final path = itemMap['path'] as String? ?? '';
_logger.log('📚 Library item: name=$name, path=$path');
}
@@ -1129,10 +1341,11 @@ class MusicAssistantAPI {
// Look for "Series" folder in the library
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = (itemMap['name'] as String? ?? '').toLowerCase();
final name = ((itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '').toLowerCase();
final path = itemMap['path'] as String? ?? '';
if (name.contains('series') || path.contains('series')) {
// Check for "series" in name/path, or abbreviated path ending with "/s"
if (name.contains('series') || path.contains('series') || path.endsWith('/s')) {
_logger.log('📚 Found series folder: $path');
// Browse the series folder
@@ -1141,13 +1354,15 @@ class MusicAssistantAPI {
for (final seriesItem in seriesItems) {
final seriesMap = seriesItem as Map<String, dynamic>;
final seriesName = (seriesMap['name'] as String?) ?? (seriesMap['label'] as String?) ?? '';
final seriesPath = seriesMap['path'] as String? ?? '';
// Skip parent navigation
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
// Skip actual audiobook items (we only want series folders)
final mediaType = seriesMap['media_type'] as String?;
if (mediaType == 'audiobook' || mediaType == 'track') continue;
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
_logger.log('📚 Series: name=$seriesName, path=$seriesPath, media_type=$mediaType');
allSeries.add(AudiobookSeries.fromJson(seriesMap));
}
break; // Found series in this library, move to next library
@@ -1335,13 +1550,18 @@ class MusicAssistantAPI {
}
/// Get random albums
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
Future<List<Album>> getRandomAlbums({
int limit = 10,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
'music/albums/library_items',
args: {
'limit': limit,
'order_by': 'random',
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -1457,6 +1677,8 @@ class MusicAssistantAPI {
int? offset,
String? search,
bool? favoriteOnly,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
@@ -1466,6 +1688,9 @@ class MusicAssistantAPI {
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -1487,7 +1712,7 @@ class MusicAssistantAPI {
final response = await _sendCommand(
'music/playlist',
args: {
'provider': provider,
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
@@ -2469,15 +2694,16 @@ class MusicAssistantAPI {
}
}
/// Move an item to a new position in the queue
Future<void> queueCommandMoveItem(String queueId, String itemId, int newIndex) async {
/// Move an item in the queue by a relative position shift
/// pos_shift > 0: move down, pos_shift < 0: move up, pos_shift = 0: move to next
Future<void> queueCommandMoveItem(String queueId, String itemId, int posShift) async {
try {
await _sendCommand(
'player_queues/move_item',
args: {
'queue_id': queueId,
'item_id_or_index': itemId,
'new_index': newIndex,
'queue_item_id': itemId,
'pos_shift': posShift,
},
);
} catch (e) {
@@ -2486,6 +2712,75 @@ class MusicAssistantAPI {
}
}
/// Play a specific item in the queue by its queue_item_id
Future<void> queueCommandPlayIndex(String queueId, String queueItemId) async {
try {
await _sendCommand(
'player_queues/play_index',
args: {
'queue_id': queueId,
'index': queueItemId,
},
);
} catch (e) {
_logger.log('Error playing queue item: $e');
rethrow;
}
}
/// Clear all items from the queue
Future<void> queueCommandClear(String queueId) async {
try {
await _sendCommand(
'player_queues/clear',
args: {
'queue_id': queueId,
},
);
} catch (e) {
_logger.log('Error clearing queue: $e');
rethrow;
}
}
/// Transfer the current queue to another player
/// This moves the entire playback queue from source player to target player,
/// including shuffle and repeat settings.
///
/// [sourceQueueId] - The queue ID of the source player (optional, uses first playing player if null)
/// [targetQueueId] - The queue ID of the target player (required)
/// [autoPlay] - Whether to automatically start playback on the target player (default: true)
Future<void> transferQueue({
String? sourceQueueId,
required String targetQueueId,
bool autoPlay = true,
}) async {
try {
_logger.log('🔄 Transferring queue to player: $targetQueueId (source: ${sourceQueueId ?? "auto"}, autoPlay: $autoPlay)');
final args = <String, dynamic>{
'target_queue_id': targetQueueId,
'auto_play': autoPlay,
};
// Only include source_queue_id if explicitly provided
// When null, MA will use the first playing player
if (sourceQueueId != null) {
args['source_queue_id'] = sourceQueueId;
}
await _sendCommand(
'player_queues/transfer',
args: args,
);
_logger.log('✅ Queue transferred successfully');
} catch (e) {
_logger.log('❌ Error transferring queue: $e');
rethrow;
}
}
// ============================================================================
// BUILT-IN PLAYER MANAGEMENT
// ============================================================================
@@ -2529,6 +2824,23 @@ class MusicAssistantAPI {
return _eventStreams['media_item_deleted']!.stream;
}
/// Stream of queue_updated events (for queue metadata changes)
Stream<Map<String, dynamic>> get queueUpdatedEvents {
if (!_eventStreams.containsKey('queue_updated')) {
_eventStreams['queue_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_updated']!.stream;
}
/// Stream of queue_items_updated events (for queue item add/remove/reorder)
/// This is critical for keeping queue UI in sync, especially in radio mode
Stream<Map<String, dynamic>> get queueItemsUpdatedEvents {
if (!_eventStreams.containsKey('queue_items_updated')) {
_eventStreams['queue_items_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_items_updated']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
@@ -2948,6 +3260,12 @@ class MusicAssistantAPI {
return null;
}
// In remote mode (WebRTC bridge), HTTP requests don't work through the tunnel
// So we need to use external CDN URLs directly when available
if (_isRemoteMode) {
return _getImageUrlForRemoteMode(images, size);
}
// Try to find a non-remotely accessible image first (prefer local/opensubsonic)
Map<String, dynamic>? selectedImage;
for (var img in images) {
@@ -3010,6 +3328,107 @@ class MusicAssistantAPI {
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
}
/// Get image URL for remote mode - uses external CDN URLs directly
/// since HTTP requests don't work through the WebRTC bridge
String? _getImageUrlForRemoteMode(List<dynamic> images, int size) {
// Known external CDN domains that are publicly accessible
const externalDomains = [
'i.scdn.co', // Spotify
'mosaic.scdn.co', // Spotify playlists
'image-cdn-ak.spotifycdn.com', // Spotify
'cdns-images.dzcdn.net', // Deezer
'resources.tidal.com', // Tidal
'e-cdns-images.dzcdn.net', // Deezer
'is1-ssl.mzstatic.com', // Apple Music
'is2-ssl.mzstatic.com', // Apple Music
'is3-ssl.mzstatic.com', // Apple Music
'is4-ssl.mzstatic.com', // Apple Music
'is5-ssl.mzstatic.com', // Apple Music
'lastfm.freetls.fastly.net', // Last.fm
'coverartarchive.org', // MusicBrainz
'archive.org', // Internet Archive
'fanart.tv', // Fanart.tv
];
// First pass: look for images with paths that are external URLs
for (var img in images) {
final imgMap = img as Map<String, dynamic>;
final path = imgMap['path'] as String?;
if (path == null) continue;
// Check if path is already an external URL
if (path.startsWith('https://')) {
try {
final uri = Uri.parse(path);
// If URL contains imageproxy with a size parameter, replace with requested size
if (uri.path.contains('imageproxy') && uri.queryParameters.containsKey('size')) {
final newParams = Map<String, String>.from(uri.queryParameters);
newParams['size'] = size.toString();
final newUri = uri.replace(queryParameters: newParams);
_logger.log('🖼️ Remote mode: adjusted imageproxy size to $size from ${uri.host}');
return newUri.toString();
}
// Check if it's from a known CDN
for (final domain in externalDomains) {
if (uri.host.contains(domain)) {
_logger.log('🖼️ Remote mode: using external CDN URL from ${uri.host}');
// For Spotify, we can request different sizes
if (uri.host.contains('scdn.co') || uri.host.contains('spotifycdn.com')) {
// Spotify image URLs can have size in path, but we'll use as-is
return path;
}
return path;
}
}
// Check if it's an Audiobookshelf URL (cover API endpoint)
// Audiobookshelf supports ?width=X parameter for resizing
if (uri.path.contains('/api/') && uri.path.contains('/cover')) {
final newParams = Map<String, String>.from(uri.queryParameters);
newParams['width'] = size.toString();
final newUri = uri.replace(queryParameters: newParams);
_logger.log('🖼️ Remote mode: adjusted Audiobookshelf cover size to $size from ${uri.host}');
return newUri.toString();
}
// Even if not a known CDN, if it's HTTPS it might work
_logger.log('🖼️ Remote mode: trying external URL from ${uri.host}');
return path;
} catch (e) {
// Not a valid URL, continue
}
}
}
// Second pass: try any image with a provider that typically has external URLs
for (var img in images) {
final imgMap = img as Map<String, dynamic>;
final provider = imgMap['provider'] as String?;
final path = imgMap['path'] as String?;
if (path == null) continue;
// Providers that typically have external URLs in their paths
if (provider != null) {
if (provider.contains('spotify') ||
provider.contains('tidal') ||
provider.contains('deezer') ||
provider.contains('apple_music') ||
provider.contains('musicbrainz')) {
if (path.startsWith('https://')) {
_logger.log('🖼️ Remote mode: using $provider image URL');
return path;
}
}
}
}
// No external URLs found - images won't load in remote mode for local files
_logger.log('🖼️ Remote mode: no external image URL available');
return null;
}
// ==================== iTunes Artwork Lookup ====================
/// Search iTunes for a podcast and return high-res artwork URL
@@ -3171,22 +3590,26 @@ class MusicAssistantAPI {
try {
_logger.log('📥 Fetching initial state...');
// Fetch provider manifests (available providers)
// Fetch provider manifests (available providers with icons)
try {
await _sendCommand('providers/manifests');
_logger.log('✓ Provider manifests loaded');
final manifestResponse = await _sendCommand('providers/manifests');
final manifests = manifestResponse['result'];
if (manifests is List) {
_providerManifests.clear();
for (final manifestData in manifests) {
if (manifestData is Map<String, dynamic>) {
final manifest = ProviderManifest.fromJson(manifestData);
if (manifest.domain.isNotEmpty) {
_providerManifests[manifest.domain] = manifest;
}
}
}
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
}
} catch (e) {
_logger.log('⚠️ Could not load provider manifests: $e');
}
// Fetch provider instances (active providers)
try {
await _sendCommand('providers');
_logger.log('✓ Provider instances loaded');
} catch (e) {
_logger.log('⚠️ Could not load provider instances: $e');
}
_logger.log('✅ Initial state fetch complete');
} catch (e) {
_logger.log('❌ Error fetching state: $e');
@@ -3194,6 +3617,51 @@ class MusicAssistantAPI {
}
}
/// Get all music provider instances configured in Music Assistant
/// Returns only music providers (spotify, tidal, filesystem, etc.) not player/metadata providers
Future<List<ProviderInstance>> getMusicProviders() async {
try {
final response = await _sendCommand('providers');
final result = response['result'];
if (result == null) {
_logger.log('⚠️ providers command returned no result');
return [];
}
final List<dynamic> providers = result is List ? result : [];
final musicProviders = <ProviderInstance>[];
for (final providerData in providers) {
if (providerData is Map<String, dynamic>) {
final instance = ProviderInstance.fromJson(providerData);
// Only include music providers (not player providers, metadata providers, etc.)
if (instance.isMusicProvider) {
musicProviders.add(instance);
}
}
}
_logger.log('🎵 Found ${musicProviders.length} music providers: ${musicProviders.map((p) => p.name).join(", ")}');
return musicProviders;
} catch (e) {
_logger.log('❌ Error getting music providers: $e');
return [];
}
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _providerManifests[domain];
}
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
final manifest = _providerManifests[domain];
return manifest?.getSvgIcon(isDark: isDark);
}
/// Get current authenticated user's profile
/// Returns user info including display_name, username, groups, etc.
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
@@ -3329,6 +3797,9 @@ class MusicAssistantAPI {
return;
}
// Cancel all pending requests before reconnecting to prevent memory leaks
_cancelPendingRequests('Connection lost, reconnecting');
await Future.delayed(Timings.reconnectDelay);
// Check again after delay
@@ -3357,8 +3828,8 @@ class MusicAssistantAPI {
/// 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));
if (!entry.value.completer.isCompleted) {
entry.value.completer.completeError(Exception(reason));
}
}
_pendingRequests.clear();
+36 -4
View File
@@ -100,6 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -155,17 +156,34 @@ class PositionTracker {
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
// 4. We're not playing (paused state should reflect server position)
// 5. Anchor is getting stale (> 20 seconds old) - prevents position freezing at _maxAnchorAge cap
//
// Key insight: When timestamp is stale, the raw position from server is likely outdated.
// Our interpolated position is probably more accurate, so don't let stale data override it.
// HOWEVER, if our anchor itself is getting old, we need to refresh it to prevent freezing.
final anchorAge = DateTime.now().difference(_anchorTime);
final anchorIsGettingStale = anchorAge.inSeconds > 20;
// When anchor is stale, always refresh it - the suspicious reset check should
// only prevent us from trusting the SERVER's position, not from refreshing
// our own interpolated anchor. The newAnchorPos logic below handles using
// interpolated position for stale anchors with suspicious server data.
final shouldUpdateAnchor = playerChanged
|| playStateChanged
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|| !isPlaying;
|| !isPlaying
|| anchorIsGettingStale;
if (shouldUpdateAnchor) {
_anchorPosition = anchorPos;
// When refreshing due to stale anchor (not player/state change), use interpolated position
// to avoid backward jumps from outdated server data
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -209,6 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -218,9 +237,20 @@ class PositionTracker {
}
void _startInterpolationTimer() {
if (_interpolationTimer != null) return;
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) {
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
});
@@ -229,6 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -240,6 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+675
View File
@@ -0,0 +1,675 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Error types for RemoteBridge
enum RemoteBridgeErrorType {
webrtcConnection,
signaling,
authentication,
dataChannel,
timeout,
unknown,
}
/// Error information from RemoteBridge
class RemoteBridgeError {
final RemoteBridgeErrorType type;
final String message;
final DateTime timestamp;
RemoteBridgeError({
required this.type,
required this.message,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
}
/// Connection state for RemoteBridge
enum RemoteBridgeState {
disconnected,
connecting,
authenticating,
connected,
reconnecting,
failed,
}
/// Connection quality metrics
class ConnectionMetrics {
final int requestsSent;
final int responsesReceived;
final int reconnectionCount;
final Duration? lastLatency;
final DateTime? lastActivity;
ConnectionMetrics({
required this.requestsSent,
required this.responsesReceived,
required this.reconnectionCount,
this.lastLatency,
this.lastActivity,
});
double get successRate {
if (requestsSent == 0) return 1.0;
return responsesReceived / requestsSent;
}
}
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
/// for remote connections. All traffic is tunneled through a local WebSocket server.
///
/// Architecture:
/// ```
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
/// ```
class RemoteBridge {
HttpServer? _server;
WebViewWebRTCConnection? _webrtcConnection;
int? _port;
// API client (MusicAssistantAPI)
WebSocket? _apiClientSocket;
StreamSubscription? _apiClientSubscription;
StreamSubscription? _apiWebrtcSubscription;
// Sendspin client (local player audio)
WebSocket? _sendspinClientSocket;
StreamSubscription? _sendspinClientSubscription;
StreamSubscription? _sendspinTextSubscription;
StreamSubscription? _sendspinBinarySubscription;
Timer? _healthCheckTimer;
Timer? _reconnectTimer;
// Connection parameters for reconnection
String? _remoteId;
String? _username;
String? _password;
bool _isReconnecting = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 3);
static const Duration _rateLimitDelay = Duration(seconds: 65);
bool _isRateLimited = false;
// Cached server hello to replay when WS client connects
String? _cachedServerHello;
// Debug counters
int _apiMessagesSent = 0;
int _apiMessagesReceived = 0;
int _sendspinMessagesSent = 0;
int _sendspinMessagesReceived = 0;
DateTime? _lastMessageReceived;
int _totalReconnections = 0;
// State tracking
RemoteBridgeState _state = RemoteBridgeState.disconnected;
final _stateController = StreamController<RemoteBridgeState>.broadcast();
final _errorController = StreamController<RemoteBridgeError>.broadcast();
final DebugLogger _logger = DebugLogger();
int? get port => _port;
bool get isRunning => _server != null;
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
RemoteBridgeState get state => _state;
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
/// Build the hidden WebView widget that must be added to the widget tree.
/// This must be called and the returned widget added to the tree BEFORE calling start().
/// The WebView is 1x1 pixel and invisible.
Widget buildWebView() {
// Create the connection if not already created
_webrtcConnection ??= WebViewWebRTCConnection();
return _webrtcConnection!.buildWebView();
}
/// Get current connection metrics
ConnectionMetrics get metrics => ConnectionMetrics(
requestsSent: _apiMessagesSent,
responsesReceived: _apiMessagesReceived,
reconnectionCount: _totalReconnections,
lastActivity: _lastMessageReceived,
);
/// Update state and notify listeners
void _setState(RemoteBridgeState newState) {
if (_state != newState) {
_logger.log('RemoteBridge: State changed: $_state -> $newState');
_state = newState;
_stateController.add(newState);
}
}
/// Emit an error to listeners
void _emitError(RemoteBridgeErrorType type, String message) {
final error = RemoteBridgeError(type: type, message: message);
_logger.log('RemoteBridge: Error [$type]: $message');
_errorController.add(error);
}
/// Start the bridge and connect to the remote MA server.
/// Returns the local port number to connect to, or null if failed.
Future<int?> start(String remoteId, String username, String password) async {
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
_setState(RemoteBridgeState.connecting);
// Store connection parameters for reconnection
_remoteId = remoteId;
_username = username;
_password = password;
_reconnectAttempts = 0;
try {
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
// The WebView must be in the widget tree first - call buildWebView() before start()
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state: $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_handleWebRTCDisconnection();
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error: $error');
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
};
final connected = await _webrtcConnection!.connect(remoteId);
if (!connected) {
_logger.log('RemoteBridge: WebRTC connection failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
await stop();
return null;
}
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
_setState(RemoteBridgeState.authenticating);
// Step 2: Authenticate with MA
final authenticated = await _authenticate(username, password);
if (!authenticated) {
_logger.log('RemoteBridge: Authentication failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
await stop();
return null;
}
_logger.log('RemoteBridge: Authentication successful');
_setState(RemoteBridgeState.connected);
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Cached server hello');
}
// Step 4: Start local WebSocket server
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_port = _server!.port;
_logger.log('RemoteBridge: Local WS server started on port $_port');
// Handle incoming connections
_server!.listen(_handleHttpRequest);
// Step 5: Subscribe to WebRTC API messages to forward to API client
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
// Forward binary audio data to Sendspin client
_sendspinClientSocket!.add(data);
}
});
// Step 8: Start health check timer
_startHealthCheck();
return _port;
} catch (e) {
_logger.log('RemoteBridge: Start failed: $e');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.unknown, e.toString());
await stop();
return null;
}
}
/// Authenticate with the MA server over WebRTC.
Future<bool> _authenticate(String username, String password) async {
try {
// Step 1: Login to get access token
_logger.log('RemoteBridge: Sending auth/login...');
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
'username': username,
'password': password,
'device_name': 'Ensemble Mobile App',
});
_logger.log('RemoteBridge: Login result: $loginResult');
// Check for error response
if (loginResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
return false;
}
String? accessToken;
if (loginResult['result'] != null) {
final result = loginResult['result'];
if (result is Map<String, dynamic>) {
if (result['success'] == true) {
accessToken = result['access_token'] as String?;
final user = result['user'] as Map<String, dynamic>?;
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
}
}
}
if (accessToken == null) {
_logger.log('RemoteBridge: No access token received');
return false;
}
// Step 2: Authenticate the session with the token
_logger.log('RemoteBridge: Authenticating session with token...');
final authResult = await _webrtcConnection!.sendRequest('auth', {
'token': accessToken,
});
_logger.log('RemoteBridge: Auth result: $authResult');
if (authResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
return false;
}
_logger.log('RemoteBridge: Session authenticated successfully!');
return true;
} catch (e) {
_logger.log('RemoteBridge: Auth error: $e');
return false;
}
}
/// Handle incoming HTTP requests and upgrade to WebSocket.
void _handleHttpRequest(HttpRequest request) async {
final path = request.uri.path;
_logger.log('RemoteBridge: Incoming HTTP request: $path');
if (WebSocketTransformer.isUpgradeRequest(request)) {
try {
final socket = await WebSocketTransformer.upgrade(request);
// Route based on path: /sendspin for audio, anything else for API
if (path == '/sendspin') {
_handleSendspinConnection(socket);
} else {
_handleApiConnection(socket);
}
} catch (e) {
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
request.response.statusCode = HttpStatus.internalServerError;
await request.response.close();
}
} else {
// Not a WebSocket request
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
}
}
/// Handle a new API WebSocket client connection.
void _handleApiConnection(WebSocket socket) {
_logger.log('RemoteBridge: API client connected');
// Only allow one API client at a time
if (_apiClientSocket != null) {
_logger.log('RemoteBridge: Closing existing API client connection');
_apiClientSubscription?.cancel();
_apiClientSocket?.close();
}
_apiClientSocket = socket;
// Step 1: Send cached server hello immediately
if (_cachedServerHello != null) {
_logger.log('RemoteBridge: Sending cached server hello to API client');
_apiClientSocket!.add(_cachedServerHello!);
}
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
_apiClientSubscription = _apiClientSocket!.listen(
(message) {
if (message is String && _webrtcConnection != null) {
_apiMessagesSent++;
_webrtcConnection!.sendRaw(message);
}
},
onDone: () {
_logger.log('RemoteBridge: API client disconnected');
_apiClientSocket = null;
_apiClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: API client error: $error');
_apiClientSocket = null;
_apiClientSubscription = null;
},
);
}
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
void _handleSendspinConnection(WebSocket socket) {
_logger.log('RemoteBridge: Sendspin client connected');
// Only allow one Sendspin client at a time
if (_sendspinClientSocket != null) {
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
_sendspinClientSubscription?.cancel();
_sendspinClientSocket?.close();
}
_sendspinClientSocket = socket;
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
_sendspinClientSubscription = _sendspinClientSocket!.listen(
(message) {
if (_webrtcConnection != null) {
_sendspinMessagesSent++;
if (message is String) {
// Text message (JSON control)
_webrtcConnection!.sendSendspinText(message);
} else if (message is List<int>) {
// Binary message (would be audio data from client, unlikely but handle it)
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
}
}
},
onDone: () {
_logger.log('RemoteBridge: Sendspin client disconnected');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: Sendspin client error: $error');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
);
}
/// Handle WebRTC disconnection - attempt reconnection.
void _handleWebRTCDisconnection() {
_logger.log('RemoteBridge: WebRTC disconnected');
// Don't close WS clients immediately - they will reconnect when WebRTC is back
// Instead, attempt to reconnect WebRTC
_scheduleReconnect();
}
/// Schedule a WebRTC reconnection attempt.
void _scheduleReconnect() {
if (_isReconnecting) {
_logger.log('RemoteBridge: Already reconnecting, skipping');
return;
}
if (_remoteId == null || _username == null || _password == null) {
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
_closeAllClients();
return;
}
if (_reconnectAttempts >= _maxReconnectAttempts) {
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
_closeAllClients();
return;
}
_setState(RemoteBridgeState.reconnecting);
_reconnectAttempts++;
_totalReconnections++;
// Use longer delay if rate limited, otherwise exponential backoff
final Duration delay;
if (_isRateLimited) {
delay = _rateLimitDelay;
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
} else {
delay = _reconnectDelay * _reconnectAttempts;
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, _reconnectWebRTC);
}
/// Attempt to reconnect WebRTC.
Future<void> _reconnectWebRTC() async {
if (_isReconnecting) return;
_isReconnecting = true;
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
try {
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
_apiWebrtcSubscription?.cancel();
_sendspinTextSubscription?.cancel();
_sendspinBinarySubscription?.cancel();
await _webrtcConnection?.disconnect();
// Reuse existing WebView connection (WebView is already in widget tree)
// If somehow null, create a new one (shouldn't happen in normal flow)
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_isReconnecting = false;
_handleWebRTCDisconnection();
} else if (state == WebViewWebRTCConnectionState.connected) {
_reconnectAttempts = 0; // Reset on successful connection
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
// Detect rate limiting from signaling server
if (error.toLowerCase().contains('rate limit')) {
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
_isRateLimited = true;
}
};
final connected = await _webrtcConnection!.connect(_remoteId!);
if (!connected) {
_logger.log('RemoteBridge: WebRTC reconnection failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
// Re-authenticate
final authenticated = await _authenticate(_username!, _password!);
if (!authenticated) {
_logger.log('RemoteBridge: Re-authentication failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: Re-authentication successful');
// Re-cache server hello
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Re-cached server hello');
}
// Re-subscribe to WebRTC streams
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(data);
}
});
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
_setState(RemoteBridgeState.connected);
_isReconnecting = false;
_reconnectAttempts = 0;
_isRateLimited = false;
} catch (e) {
_logger.log('RemoteBridge: Reconnection error: $e');
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
_isReconnecting = false;
_scheduleReconnect();
}
}
/// Close all WS clients (called when giving up on reconnection).
void _closeAllClients() {
_logger.log('RemoteBridge: Closing all WS clients');
_apiClientSocket?.close();
_apiClientSocket = null;
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_sendspinClientSocket?.close();
_sendspinClientSocket = null;
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
}
/// Start periodic health check to monitor connection state
void _startHealthCheck() {
_healthCheckTimer?.cancel();
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
final timeSinceLastMsg = _lastMessageReceived != null
? DateTime.now().difference(_lastMessageReceived!).inSeconds
: -1;
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
'lastRecv:${timeSinceLastMsg}s ago');
});
}
/// Stop the bridge and disconnect from the remote server.
Future<void> stop() async {
_logger.log('RemoteBridge: Stopping bridge');
_setState(RemoteBridgeState.disconnected);
// Cancel reconnection
_reconnectTimer?.cancel();
_reconnectTimer = null;
_isReconnecting = false;
_reconnectAttempts = 0;
_healthCheckTimer?.cancel();
_healthCheckTimer = null;
// Cancel API subscriptions
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_apiWebrtcSubscription?.cancel();
_apiWebrtcSubscription = null;
// Cancel Sendspin subscriptions
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
_sendspinTextSubscription?.cancel();
_sendspinTextSubscription = null;
_sendspinBinarySubscription?.cancel();
_sendspinBinarySubscription = null;
// Close client sockets
await _apiClientSocket?.close();
_apiClientSocket = null;
await _sendspinClientSocket?.close();
_sendspinClientSocket = null;
await _server?.close(force: true);
_server = null;
_port = null;
await _webrtcConnection?.disconnect();
_webrtcConnection = null;
_cachedServerHello = null;
// Clear connection parameters
_remoteId = null;
_username = null;
_password = null;
_logger.log('RemoteBridge: Bridge stopped');
}
/// Dispose the bridge and close all stream controllers.
void dispose() {
stop();
_stateController.close();
_errorController.close();
}
}
@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:ensemble/services/remote/webrtc_connection.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Manages remote connections to MA servers via WebRTC
class RemoteConnectionManager {
final WebRTCConnection _connection = WebRTCConnection();
StreamSubscription? _messageSubscription;
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
bool _isAuthenticated = false;
String? _serverVersion;
// Callbacks
Function(bool connected)? onConnectionChanged;
Function(String error)? onError;
bool get isConnected => _connection.isConnected && _isAuthenticated;
Stream<Map<String, dynamic>> get events => _eventController.stream;
String? get serverVersion => _serverVersion;
RemoteConnectionManager() {
_connection.onStateChanged = _onConnectionStateChanged;
_connection.onError = (error) => onError?.call(error);
// Forward events from the data channel
_messageSubscription = _connection.messages.listen((message) {
// Check if this is an event (has 'event' key)
if (message.containsKey('event')) {
_eventController.add(message);
}
});
}
/// Connect to a remote MA server using Remote Access ID
Future<bool> connect(String remoteId, String username, String password) async {
DebugLogger().log('RemoteConnection: Connecting to remote ID: $remoteId');
// Connect WebRTC
final connected = await _connection.connect(remoteId);
if (!connected) {
DebugLogger().log('RemoteConnection: WebRTC connection failed');
return false;
}
DebugLogger().log('RemoteConnection: WebRTC connected, authenticating...');
// Authenticate with MA
try {
final authenticated = await _authenticate(username, password);
if (!authenticated) {
DebugLogger().log('RemoteConnection: Authentication failed');
await _connection.disconnect();
return false;
}
_isAuthenticated = true;
DebugLogger().log('RemoteConnection: Fully connected and authenticated');
onConnectionChanged?.call(true);
return true;
} catch (e) {
DebugLogger().log('RemoteConnection: Auth error: $e');
await _connection.disconnect();
return false;
}
}
/// Authenticate with the MA server
Future<bool> _authenticate(String username, String password) async {
try {
// Step 1: Login to get access token
DebugLogger().log('RemoteConnection: Sending auth/login...');
final loginResult = await _connection.sendRequest('auth/login', {
'username': username,
'password': password,
'device_name': 'Ensemble Mobile App',
});
DebugLogger().log('RemoteConnection: Login result: $loginResult');
// Check for error response
if (loginResult.containsKey('error_code')) {
DebugLogger().log('RemoteConnection: Login error: ${loginResult['details']}');
return false;
}
String? accessToken;
if (loginResult['result'] != null) {
final result = loginResult['result'];
if (result is Map<String, dynamic>) {
if (result['success'] == true) {
accessToken = result['access_token'] as String?;
final user = result['user'] as Map<String, dynamic>?;
DebugLogger().log('RemoteConnection: Login successful!');
DebugLogger().log('RemoteConnection: User: ${user?['username']} (${user?['role']})');
}
}
}
if (accessToken == null) {
DebugLogger().log('RemoteConnection: No access token received');
return false;
}
// Step 2: Authenticate the session with the token
DebugLogger().log('RemoteConnection: Authenticating session with token...');
final authResult = await _connection.sendRequest('auth', {
'token': accessToken,
});
DebugLogger().log('RemoteConnection: Auth result: $authResult');
if (authResult.containsKey('error_code')) {
DebugLogger().log('RemoteConnection: Session auth failed: ${authResult['details']}');
return false;
}
// Store the token for later use
_accessToken = accessToken;
DebugLogger().log('RemoteConnection: Session authenticated successfully!');
return true;
} catch (e) {
DebugLogger().log('RemoteConnection: Login error: $e');
return false;
}
}
String? _accessToken;
/// Send a command to the MA server
Future<Map<String, dynamic>> sendCommand(
String command, [
Map<String, dynamic>? params,
]) async {
if (!_connection.isConnected) {
throw Exception('Not connected');
}
return await _connection.sendRequest(command, params);
}
/// Subscribe to events from the server
Future<void> subscribe(List<String> eventTypes) async {
await sendCommand('subscribe_events', {
'event_types': eventTypes,
});
}
/// Disconnect from the remote server
Future<void> disconnect() async {
_isAuthenticated = false;
await _connection.disconnect();
onConnectionChanged?.call(false);
}
void _onConnectionStateChanged(WebRTCConnectionState state) {
DebugLogger().log('RemoteConnection: State changed to $state');
if (state == WebRTCConnectionState.disconnected ||
state == WebRTCConnectionState.failed) {
_isAuthenticated = false;
onConnectionChanged?.call(false);
}
}
void dispose() {
_messageSubscription?.cancel();
_eventController.close();
_connection.dispose();
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Message types for the MA signaling protocol
class SignalingMessageType {
static const String ping = 'ping';
static const String pong = 'pong';
static const String connectRequest = 'connect-request';
static const String connected = 'connected';
static const String offer = 'offer';
static const String answer = 'answer';
static const String iceCandidate = 'ice-candidate';
static const String peerDisconnected = 'peer-disconnected';
static const String error = 'error';
}
/// ICE server configuration from signaling
class IceServer {
final List<String> urls;
final String? username;
final String? credential;
IceServer({
required this.urls,
this.username,
this.credential,
});
factory IceServer.fromJson(Map<String, dynamic> json) {
// Handle urls as either String or List<String>
final urlsRaw = json['urls'];
final List<String> urls;
if (urlsRaw is String) {
urls = [urlsRaw];
} else if (urlsRaw is List) {
urls = urlsRaw.cast<String>();
} else {
urls = [];
}
return IceServer(
urls: urls,
username: json['username'] as String?,
credential: json['credential'] as String?,
);
}
Map<String, dynamic> toWebRtcConfig() {
final config = <String, dynamic>{'urls': urls};
if (username != null) config['username'] = username;
if (credential != null) config['credential'] = credential;
return config;
}
}
/// Client for connecting to the MA signaling server
class SignalingClient {
static const String _signalingUrl = 'wss://signaling.music-assistant.io/ws';
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _pingTimer;
String? _sessionId;
String? _remoteId;
List<IceServer> _iceServers = [];
bool _isConnected = false;
// Callbacks
Function(String sessionId, List<IceServer> iceServers)? onConnected;
Function(Map<String, dynamic> answer)? onAnswer;
Function(Map<String, dynamic> candidate)? onIceCandidate;
Function(String error)? onError;
Function()? onPeerDisconnected;
Function()? onDisconnected;
String? get sessionId => _sessionId;
List<IceServer> get iceServers => _iceServers;
bool get isConnected => _isConnected;
/// Connect to the signaling server and request connection to a remote MA server
Future<bool> connect(String remoteId) async {
DebugLogger().log('Signaling: Connecting to $_signalingUrl');
try {
// Normalize remote ID (remove dashes, uppercase)
final normalizedId = remoteId.replaceAll('-', '').toUpperCase();
_remoteId = normalizedId;
DebugLogger().log('Signaling: Remote ID: $normalizedId');
_channel = WebSocketChannel.connect(Uri.parse(_signalingUrl));
await _channel!.ready;
_subscription = _channel!.stream.listen(
_handleMessage,
onError: (error) {
DebugLogger().log('Signaling: WebSocket error: $error');
onError?.call(error.toString());
_cleanup();
},
onDone: () {
DebugLogger().log('Signaling: WebSocket closed');
onDisconnected?.call();
_cleanup();
},
);
// Start ping timer to keep connection alive
_startPingTimer();
// Send connect request
_send({
'type': SignalingMessageType.connectRequest,
'remoteId': normalizedId,
});
return true;
} catch (e) {
DebugLogger().log('Signaling: Failed to connect: $e');
onError?.call(e.toString());
return false;
}
}
/// Send WebRTC offer to the remote server
void sendOffer(Map<String, dynamic> offer) {
if (_sessionId == null || _remoteId == null) {
DebugLogger().log('Signaling: Cannot send offer - no session');
return;
}
_send({
'type': SignalingMessageType.offer,
'remoteId': _remoteId,
'sessionId': _sessionId,
'data': offer,
});
}
/// Send ICE candidate to the remote server
void sendIceCandidate(Map<String, dynamic> candidate) {
if (_sessionId == null || _remoteId == null) {
DebugLogger().log('Signaling: Cannot send ICE candidate - no session');
return;
}
_send({
'type': SignalingMessageType.iceCandidate,
'remoteId': _remoteId,
'sessionId': _sessionId,
'data': candidate,
});
}
/// Disconnect from the signaling server
void disconnect() {
DebugLogger().log('Signaling: Disconnecting');
_cleanup();
}
void _handleMessage(dynamic data) {
try {
final message = jsonDecode(data as String) as Map<String, dynamic>;
final type = message['type'] as String?;
DebugLogger().log('Signaling: Received message type: $type');
switch (type) {
case SignalingMessageType.ping:
// Server is pinging us - respond with pong to keep connection alive
_send({'type': SignalingMessageType.pong});
break;
case SignalingMessageType.pong:
// Ignore pong responses to our pings
break;
case SignalingMessageType.connected:
_handleConnected(message);
break;
case SignalingMessageType.answer:
_handleAnswer(message);
break;
case SignalingMessageType.iceCandidate:
_handleIceCandidate(message);
break;
case SignalingMessageType.peerDisconnected:
DebugLogger().log('Signaling: Peer disconnected');
onPeerDisconnected?.call();
break;
case SignalingMessageType.error:
final error = message['error'] as String? ?? 'Unknown error';
DebugLogger().log('Signaling: Error from server: $error');
onError?.call(error);
break;
default:
DebugLogger().log('Signaling: Unknown message type: $type');
}
} catch (e) {
DebugLogger().log('Signaling: Failed to handle message: $e');
}
}
void _handleConnected(Map<String, dynamic> message) {
_sessionId = message['sessionId'] as String?;
_isConnected = true;
// Parse ICE servers
final iceServersJson = message['iceServers'] as List<dynamic>?;
if (iceServersJson != null) {
_iceServers = iceServersJson
.map((s) => IceServer.fromJson(s as Map<String, dynamic>))
.toList();
}
DebugLogger().log('Signaling: Connected with session $_sessionId');
DebugLogger().log('Signaling: Received ${_iceServers.length} ICE servers');
onConnected?.call(_sessionId!, _iceServers);
}
void _handleAnswer(Map<String, dynamic> message) {
final answer = message['data'] as Map<String, dynamic>?;
if (answer != null) {
DebugLogger().log('Signaling: Received answer');
onAnswer?.call(answer);
}
}
void _handleIceCandidate(Map<String, dynamic> message) {
final candidate = message['data'] as Map<String, dynamic>?;
if (candidate != null) {
DebugLogger().log('Signaling: Received ICE candidate');
onIceCandidate?.call(candidate);
}
}
void _send(Map<String, dynamic> message) {
if (_channel == null) return;
final json = jsonEncode(message);
DebugLogger().log('Signaling: Sending ${message['type']}');
_channel!.sink.add(json);
}
void _startPingTimer() {
_pingTimer?.cancel();
// Send ping every 15 seconds to keep connection alive
_pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
_send({'type': SignalingMessageType.ping});
});
}
void _cleanup() {
_pingTimer?.cancel();
_pingTimer = null;
_subscription?.cancel();
_subscription = null;
_channel?.sink.close();
_channel = null;
_sessionId = null;
_remoteId = null;
_isConnected = false;
}
}
+506
View File
@@ -0,0 +1,506 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:uuid/uuid.dart';
import 'package:ensemble/services/remote/signaling_client.dart';
import 'package:ensemble/services/debug_logger.dart';
/// WebRTC connection state
enum WebRTCConnectionState {
disconnected,
connecting,
connected,
failed,
}
/// Manages a WebRTC peer connection for remote MA access
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _dataChannel;
RTCDataChannel? _sendspinDataChannel;
final SignalingClient _signalingClient = SignalingClient();
final _uuid = const Uuid();
WebRTCConnectionState _state = WebRTCConnectionState.disconnected;
Completer<bool>? _connectionCompleter;
bool _serverHelloReceived = false;
Completer<void>? _serverHelloCompleter;
// Message handling for MA API
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
final _rawMessageController = StreamController<String>.broadcast();
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
// Sendspin message handling (text and binary)
final _sendspinTextController = StreamController<String>.broadcast();
final _sendspinBinaryController = StreamController<Uint8List>.broadcast();
// Server info
Map<String, dynamic>? serverInfo;
// Callbacks
Function(WebRTCConnectionState state)? onStateChanged;
Function(String error)? onError;
WebRTCConnectionState get state => _state;
Stream<Map<String, dynamic>> get messages => _messageController.stream;
Stream<String> get rawMessages => _rawMessageController.stream;
bool get isConnected => _state == WebRTCConnectionState.connected;
// Sendspin channel streams
Stream<String> get sendspinTextMessages => _sendspinTextController.stream;
Stream<Uint8List> get sendspinBinaryMessages => _sendspinBinaryController.stream;
bool get isSendspinConnected =>
_sendspinDataChannel?.state == RTCDataChannelState.RTCDataChannelOpen;
/// Connect to a remote MA server using the Remote Access ID
Future<bool> connect(String remoteId) async {
if (_state == WebRTCConnectionState.connecting) {
DebugLogger().log('WebRTC: Already connecting');
return false;
}
_setState(WebRTCConnectionState.connecting);
_connectionCompleter = Completer<bool>();
try {
// Set up signaling callbacks
_signalingClient.onConnected = _onSignalingConnected;
_signalingClient.onAnswer = _onAnswer;
_signalingClient.onIceCandidate = _onRemoteIceCandidate;
_signalingClient.onError = _onSignalingError;
_signalingClient.onPeerDisconnected = _onPeerDisconnected;
_signalingClient.onDisconnected = _onSignalingDisconnected;
// Connect to signaling server
final connected = await _signalingClient.connect(remoteId);
if (!connected) {
_setState(WebRTCConnectionState.failed);
return false;
}
// Wait for connection to complete (with timeout)
final result = await _connectionCompleter!.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
DebugLogger().log('WebRTC: Connection timeout');
_onSignalingError('Connection timeout');
return false;
},
);
return result;
} catch (e) {
DebugLogger().log('WebRTC: Connection error: $e');
_setState(WebRTCConnectionState.failed);
onError?.call(e.toString());
return false;
}
}
/// Send a JSON-RPC request and wait for response
Future<Map<String, dynamic>> sendRequest(
String command, [
Map<String, dynamic>? params,
]) async {
if (_dataChannel == null || _dataChannel!.state != RTCDataChannelState.RTCDataChannelOpen) {
throw Exception('Data channel not open');
}
final messageId = _uuid.v4();
final request = {
'message_id': messageId,
'command': command,
if (params != null) 'args': params,
};
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
final jsonStr = jsonEncode(request);
DebugLogger().log('WebRTC: Sending request: $jsonStr');
_dataChannel!.send(RTCDataChannelMessage(jsonStr));
// Timeout after 30 seconds
return completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
_pendingRequests.remove(messageId);
throw TimeoutException('Request timed out: $command');
},
);
}
/// Send a raw message without waiting for response (for bridge passthrough)
void sendRaw(String message) {
if (_dataChannel == null) {
DebugLogger().log('WebRTC: Cannot send raw - data channel is null');
return;
}
final state = _dataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send raw - data channel state: $state');
return;
}
DebugLogger().log('WebRTC: Sending raw: ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
_dataChannel!.send(RTCDataChannelMessage(message));
}
/// Check if the data channel is healthy
bool get isDataChannelHealthy {
if (_dataChannel == null) return false;
return _dataChannel!.state == RTCDataChannelState.RTCDataChannelOpen;
}
/// Get data channel state for debugging
String get dataChannelStateDebug {
if (_dataChannel == null) return 'null';
return _dataChannel!.state.toString();
}
/// Disconnect from the remote server
Future<void> disconnect() async {
DebugLogger().log('WebRTC: Disconnecting');
_signalingClient.disconnect();
await _dataChannel?.close();
await _sendspinDataChannel?.close();
await _peerConnection?.close();
_dataChannel = null;
_sendspinDataChannel = null;
_peerConnection = null;
// Complete all pending requests with an error before clearing
_completePendingRequestsWithError('WebRTC connection disconnected');
_serverHelloReceived = false;
_serverHelloCompleter = null;
serverInfo = null;
_setState(WebRTCConnectionState.disconnected);
}
/// Complete all pending requests with an error message
void _completePendingRequestsWithError(String reason) {
for (final entry in _pendingRequests.entries) {
if (!entry.value.isCompleted) {
entry.value.completeError(Exception(reason));
}
}
_pendingRequests.clear();
}
void _setState(WebRTCConnectionState newState) {
if (_state != newState) {
DebugLogger().log('WebRTC: State changed: $_state -> $newState');
_state = newState;
onStateChanged?.call(newState);
}
}
Future<void> _onSignalingConnected(String sessionId, List<IceServer> iceServers) async {
DebugLogger().log('WebRTC: Signaling connected, creating peer connection');
try {
// Configure Android audio to NOT manage focus since we only use data channels
// This prevents flutter_webrtc from interfering with system audio/speakers
await AndroidNativeAudioManagement.setAndroidAudioConfiguration(
AndroidAudioConfiguration(
manageAudioFocus: false,
),
);
DebugLogger().log('WebRTC: Android audio configured - manageAudioFocus: false');
// Create peer connection with ICE servers
final config = <String, dynamic>{
'iceServers': iceServers.map((s) => s.toWebRtcConfig()).toList(),
'sdpSemantics': 'unified-plan',
};
DebugLogger().log('WebRTC: ICE servers: ${config['iceServers']}');
_peerConnection = await createPeerConnection(config);
// Set up peer connection callbacks
_peerConnection!.onIceCandidate = _onLocalIceCandidate;
_peerConnection!.onIceConnectionState = _onIceConnectionState;
_peerConnection!.onConnectionState = _onConnectionState;
_peerConnection!.onDataChannel = _onDataChannel;
// Create data channel for MA API
final dataChannelInit = RTCDataChannelInit()
..ordered = true
..protocol = 'ma-api';
_dataChannel = await _peerConnection!.createDataChannel('ma-api', dataChannelInit);
_setupDataChannel(_dataChannel!);
// TEST: Sendspin channel DISABLED to test if multi-channel bug causes disconnects
// If stability improves, the flutter_webrtc multi-channel bug is confirmed
DebugLogger().log('WebRTC: ⚠️ TEST BUILD - Sendspin channel DISABLED (single channel mode)');
// final sendspinChannelInit = RTCDataChannelInit()
// ..ordered = true
// ..protocol = 'sendspin';
// _sendspinDataChannel = await _peerConnection!.createDataChannel('sendspin', sendspinChannelInit);
// _setupSendspinDataChannel(_sendspinDataChannel!);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
DebugLogger().log('WebRTC: Sending offer');
_signalingClient.sendOffer({
'type': offer.type,
'sdp': offer.sdp,
});
} catch (e) {
DebugLogger().log('WebRTC: Failed to create peer connection: $e');
_onSignalingError(e.toString());
}
}
Future<void> _onAnswer(Map<String, dynamic> answer) async {
DebugLogger().log('WebRTC: Received answer');
try {
final description = RTCSessionDescription(
answer['sdp'] as String?,
answer['type'] as String?,
);
await _peerConnection?.setRemoteDescription(description);
} catch (e) {
DebugLogger().log('WebRTC: Failed to set remote description: $e');
_onSignalingError(e.toString());
}
}
void _onLocalIceCandidate(RTCIceCandidate candidate) {
DebugLogger().log('WebRTC: Local ICE candidate: ${candidate.candidate?.substring(0, 50)}...');
_signalingClient.sendIceCandidate({
'candidate': candidate.candidate,
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
});
}
Future<void> _onRemoteIceCandidate(Map<String, dynamic> candidateData) async {
DebugLogger().log('WebRTC: Remote ICE candidate received');
try {
final candidate = RTCIceCandidate(
candidateData['candidate'] as String?,
candidateData['sdpMid'] as String?,
candidateData['sdpMLineIndex'] as int?,
);
await _peerConnection?.addCandidate(candidate);
} catch (e) {
DebugLogger().log('WebRTC: Failed to add ICE candidate: $e');
}
}
void _onIceConnectionState(RTCIceConnectionState state) {
DebugLogger().log('WebRTC: ICE connection state: $state');
switch (state) {
case RTCIceConnectionState.RTCIceConnectionStateFailed:
_onSignalingError('ICE connection failed');
break;
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
DebugLogger().log('WebRTC: ICE disconnected, may recover');
break;
default:
break;
}
}
void _onConnectionState(RTCPeerConnectionState state) {
DebugLogger().log('WebRTC: Peer connection state: $state');
switch (state) {
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
// Connection established, but wait for data channel
break;
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
_onSignalingError('Peer connection failed');
break;
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
_setState(WebRTCConnectionState.disconnected);
break;
default:
break;
}
}
void _onDataChannel(RTCDataChannel channel) {
DebugLogger().log('WebRTC: Remote data channel received: ${channel.label}');
if (channel.label == 'ma-api') {
_dataChannel = channel;
_setupDataChannel(channel);
} else if (channel.label == 'sendspin') {
_sendspinDataChannel = channel;
_setupSendspinDataChannel(channel);
}
}
void _setupDataChannel(RTCDataChannel channel) {
channel.onDataChannelState = (state) {
DebugLogger().log('WebRTC: Data channel state: $state');
if (state == RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Data channel open - waiting for server hello...');
// Don't complete yet - wait for server hello
_serverHelloCompleter = Completer<void>();
_serverHelloCompleter!.future.then((_) {
DebugLogger().log('WebRTC: Server hello received - connection complete!');
_setState(WebRTCConnectionState.connected);
_safeCompleteConnection(true);
});
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
DebugLogger().log('WebRTC: Data channel closed, completing pending requests with error');
_completePendingRequestsWithError('Data channel closed');
_setState(WebRTCConnectionState.disconnected);
}
};
channel.onMessage = _onDataChannelMessage;
}
void _onDataChannelMessage(RTCDataChannelMessage message) {
try {
DebugLogger().log('WebRTC: Raw message: ${message.text.substring(0, message.text.length > 200 ? 200 : message.text.length)}...');
final data = jsonDecode(message.text) as Map<String, dynamic>;
final messageId = data['message_id']?.toString();
DebugLogger().log('WebRTC: Parsed message keys: ${data.keys.toList()}, message_id: $messageId');
// Check for server hello message (has server_id but no message_id)
if (data.containsKey('server_id') && messageId == null && !_serverHelloReceived) {
DebugLogger().log('WebRTC: Server hello received - server_version: ${data['server_version']}');
serverInfo = data;
_serverHelloReceived = true;
_serverHelloCompleter?.complete();
// Don't forward server hello to rawMessages - bridge caches it separately
return;
}
// Check if this is a response to a pending request (for internal use like auth)
if (messageId != null && _pendingRequests.containsKey(messageId)) {
DebugLogger().log('WebRTC: Matched pending request $messageId');
_pendingRequests.remove(messageId)?.complete(data);
// Don't forward to rawMessages - this was an internal request
return;
}
// Forward all other messages to rawMessages stream (for bridge passthrough)
_rawMessageController.add(message.text);
// Also broadcast parsed event/notification for internal listeners
DebugLogger().log('WebRTC: No match for message_id=$messageId, pending: ${_pendingRequests.keys.toList()}');
_messageController.add(data);
} catch (e) {
DebugLogger().log('WebRTC: Failed to parse message: $e');
}
}
/// Setup Sendspin data channel for audio streaming
void _setupSendspinDataChannel(RTCDataChannel channel) {
channel.onDataChannelState = (state) {
DebugLogger().log('WebRTC: Sendspin data channel state: $state');
};
channel.onMessage = _onSendspinMessage;
}
/// Handle incoming Sendspin messages (both text and binary)
void _onSendspinMessage(RTCDataChannelMessage message) {
if (message.isBinary) {
// Binary audio data
final bytes = message.binary;
if (bytes != null && !_sendspinBinaryController.isClosed) {
_sendspinBinaryController.add(bytes);
}
} else {
// Text control message (JSON)
final text = message.text;
DebugLogger().log('WebRTC: Sendspin text message: ${text.substring(0, text.length > 100 ? 100 : text.length)}...');
if (!_sendspinTextController.isClosed) {
_sendspinTextController.add(text);
}
}
}
/// Send a text message to Sendspin channel (JSON control messages)
void sendSendspinText(String message) {
if (_sendspinDataChannel == null) {
DebugLogger().log('WebRTC: Cannot send sendspin text - channel is null');
return;
}
final state = _sendspinDataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send sendspin text - channel state: $state');
return;
}
_sendspinDataChannel!.send(RTCDataChannelMessage(message));
}
/// Send binary data to Sendspin channel (audio data from local player)
void sendSendspinBinary(Uint8List data) {
if (_sendspinDataChannel == null) {
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel is null');
return;
}
final state = _sendspinDataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel state: $state');
return;
}
_sendspinDataChannel!.send(RTCDataChannelMessage.fromBinary(data));
}
void _onSignalingError(String error) {
DebugLogger().log('WebRTC: Signaling error: $error');
_setState(WebRTCConnectionState.failed);
onError?.call(error);
_safeCompleteConnection(false);
}
void _onPeerDisconnected() {
DebugLogger().log('WebRTC: Peer disconnected, completing pending requests with error');
_completePendingRequestsWithError('Remote peer disconnected');
_setState(WebRTCConnectionState.disconnected);
onError?.call('Remote server disconnected');
}
void _onSignalingDisconnected() {
DebugLogger().log('WebRTC: Signaling disconnected');
// Only set failed if we were still connecting
if (_state == WebRTCConnectionState.connecting) {
_setState(WebRTCConnectionState.failed);
_safeCompleteConnection(false);
}
}
/// Safely complete the connection completer (only once)
void _safeCompleteConnection(bool success) {
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete(success);
}
}
void dispose() {
_messageController.close();
_rawMessageController.close();
_sendspinTextController.close();
_sendspinBinaryController.close();
disconnect();
}
}
+68 -5
View File
@@ -20,7 +20,17 @@ class SecureStorageService {
// Auth Token (for stream requests)
static Future<String?> getAuthToken() async {
return await _storage.read(key: _keyAuthToken);
try {
return await _storage.read(key: _keyAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setAuthToken(String? token) async {
@@ -33,7 +43,17 @@ class SecureStorageService {
// Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async {
return await _storage.read(key: _keyMaAuthToken);
try {
return await _storage.read(key: _keyMaAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyMaAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setMaAuthToken(String? token) async {
@@ -50,7 +70,18 @@ class SecureStorageService {
// Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
final json = await _storage.read(key: _keyAuthCredentials);
String? json;
try {
json = await _storage.read(key: _keyAuthCredentials);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthCredentials);
return null;
}
rethrow;
}
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
@@ -69,7 +100,17 @@ class SecureStorageService {
// Password
static Future<String?> getPassword() async {
return await _storage.read(key: _keyPassword);
try {
return await _storage.read(key: _keyPassword);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyPassword);
return null;
}
rethrow;
}
}
static Future<void> setPassword(String? password) async {
@@ -82,7 +123,17 @@ class SecureStorageService {
// Audiobookshelf API Token
static Future<String?> getAbsApiToken() async {
return await _storage.read(key: _keyAbsApiToken);
try {
return await _storage.read(key: _keyAbsApiToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAbsApiToken);
return null;
}
rethrow;
}
}
static Future<void> setAbsApiToken(String? token) async {
@@ -98,12 +149,21 @@ class SecureStorageService {
await _storage.deleteAll();
}
// Migration completed flag key
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
/// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users.
/// Uses a flag to skip migration on subsequent runs for performance.
static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Skip if migration already completed
if (prefs.getBool(_keyMigrationCompleted) == true) {
return;
}
// Migrate auth token
final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) {
@@ -144,5 +204,8 @@ class SecureStorageService {
await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token');
}
// Mark migration as completed to skip on future runs
await prefs.setBool(_keyMigrationCompleted, true);
}
}
+36 -5
View File
@@ -40,6 +40,10 @@ class SendspinService {
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 10;
static const Duration _baseReconnectDelay = Duration(seconds: 3);
static const Duration _maxReconnectDelay = Duration(seconds: 60);
// Connection state
SendspinConnectionState _state = SendspinConnectionState.disconnected;
@@ -154,7 +158,8 @@ class SendspinService {
}
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
Future<bool> connectWithUrl(String wsUrl) async {
/// If useProxyAuth is true, will authenticate with MA token (needed for proxy connections)
Future<bool> connectWithUrl(String wsUrl, {bool useProxyAuth = false}) async {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
@@ -173,7 +178,7 @@ class SendspinService {
_logger.log('Sendspin: Connecting with URL: $wsUrl');
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5), useProxyAuth: useProxyAuth);
if (!connected) {
_updateState(SendspinConnectionState.error);
@@ -278,6 +283,7 @@ class SendspinService {
_logger.log('Sendspin: Connected and registered successfully');
_updateState(SendspinConnectionState.connected);
_resetReconnectAttempts();
_startHeartbeat();
return true;
@@ -558,6 +564,7 @@ class SendspinService {
'state': playerState,
'volume': _volume,
'muted': _isMuted,
'position': _position,
},
},
});
@@ -626,6 +633,7 @@ class SendspinService {
'state': 'synchronized',
'volume': _volume,
'muted': _isMuted,
'position': _position,
},
},
}, allowDuringHandshake: true);
@@ -637,12 +645,27 @@ class SendspinService {
_heartbeatTimer = null;
}
/// Schedule reconnection attempt
/// Schedule reconnection attempt with exponential backoff
void _scheduleReconnect() {
if (_isDisposed) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
_logger.log('Sendspin: Max reconnection attempts reached ($_maxReconnectAttempts), giving up');
_updateState(SendspinConnectionState.disconnected);
return;
}
_reconnectAttempts++;
// Calculate exponential backoff delay: 3s, 6s, 12s, 24s, 48s, then cap at 60s
final delaySeconds = (_baseReconnectDelay.inSeconds * (1 << (_reconnectAttempts - 1)))
.clamp(0, _maxReconnectDelay.inSeconds);
final delay = Duration(seconds: delaySeconds);
_logger.log('Sendspin: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 5), () {
_reconnectTimer = Timer(delay, () {
if (!_isDisposed && _state != SendspinConnectionState.connected) {
_logger.log('Sendspin: Attempting reconnection...');
if (_connectedUrl != null) {
@@ -652,11 +675,19 @@ class SendspinService {
});
}
/// Reset reconnection attempts counter (call on successful connection)
void _resetReconnectAttempts() {
_reconnectAttempts = 0;
}
/// Update connection state
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
_stateController.add(newState);
// Don't add events if controller is closed (disposed)
if (!_isDisposed) {
_stateController.add(newState);
}
}
}
File diff suppressed because it is too large Load Diff
+444 -102
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../database/database.dart' show BatchCacheItem;
import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
@@ -17,6 +18,7 @@ enum SyncStatus {
/// Service for background library synchronization
/// Loads from database cache first, then syncs from MA API in background
/// Supports per-provider sync for accurate client-side filtering
class SyncService with ChangeNotifier {
static SyncService? _instance;
static final _logger = DebugLogger();
@@ -42,6 +44,13 @@ class SyncService with ChangeNotifier {
List<Audiobook> _cachedAudiobooks = [];
List<Playlist> _cachedPlaylists = [];
// Source provider tracking for client-side filtering
// Maps itemId -> list of provider instance IDs that provided the item
Map<String, List<String>> _albumSourceProviders = {};
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
@@ -54,8 +63,15 @@ class SyncService with ChangeNotifier {
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
// Source provider getters for client-side filtering
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
/// Load library data from database cache (instant)
/// Call this on app startup for immediate data
/// Also loads source provider info for client-side filtering
Future<void> loadFromCache() async {
if (!_db.isInitialized) {
_logger.log('⚠️ Database not initialized, skipping cache load');
@@ -65,52 +81,73 @@ class SyncService with ChangeNotifier {
try {
_logger.log('📦 Loading library from database cache...');
// Load albums from cache
final albumData = await _db.getCachedItems('album');
_cachedAlbums = albumData.map((data) {
// Load albums from cache with source providers
final albumDataWithProviders = await _db.getCachedItemsWithProviders('album');
_cachedAlbums = [];
_albumSourceProviders = {};
for (final (data, providers) in albumDataWithProviders) {
try {
return Album.fromJson(data);
final album = Album.fromJson(data);
_cachedAlbums.add(album);
if (providers.isNotEmpty) {
_albumSourceProviders[album.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached album: $e');
return null;
}
}).whereType<Album>().toList();
}
// Load artists from cache
final artistData = await _db.getCachedItems('artist');
_cachedArtists = artistData.map((data) {
// Load artists from cache with source providers
final artistDataWithProviders = await _db.getCachedItemsWithProviders('artist');
_cachedArtists = [];
_artistSourceProviders = {};
for (final (data, providers) in artistDataWithProviders) {
try {
return Artist.fromJson(data);
final artist = Artist.fromJson(data);
_cachedArtists.add(artist);
if (providers.isNotEmpty) {
_artistSourceProviders[artist.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached artist: $e');
return null;
}
}).whereType<Artist>().toList();
}
// Load audiobooks from cache
final audiobookData = await _db.getCachedItems('audiobook');
_cachedAudiobooks = audiobookData.map((data) {
// Load audiobooks from cache with source providers
final audiobookDataWithProviders = await _db.getCachedItemsWithProviders('audiobook');
_cachedAudiobooks = [];
_audiobookSourceProviders = {};
for (final (data, providers) in audiobookDataWithProviders) {
try {
return Audiobook.fromJson(data);
final audiobook = Audiobook.fromJson(data);
_cachedAudiobooks.add(audiobook);
if (providers.isNotEmpty) {
_audiobookSourceProviders[audiobook.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached audiobook: $e');
return null;
}
}).whereType<Audiobook>().toList();
}
// Load playlists from cache
final playlistData = await _db.getCachedItems('playlist');
_cachedPlaylists = playlistData.map((data) {
// Load playlists from cache with source providers
final playlistDataWithProviders = await _db.getCachedItemsWithProviders('playlist');
_cachedPlaylists = [];
_playlistSourceProviders = {};
for (final (data, providers) in playlistDataWithProviders) {
try {
return Playlist.fromJson(data);
final playlist = Playlist.fromJson(data);
_cachedPlaylists.add(playlist);
if (providers.isNotEmpty) {
_playlistSourceProviders[playlist.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached playlist: $e');
return null;
}
}).whereType<Playlist>().toList();
}
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
notifyListeners();
} catch (e) {
_logger.log('❌ Failed to load from cache: $e');
@@ -119,7 +156,12 @@ class SyncService with ChangeNotifier {
/// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
/// [providerInstanceIds] - list of provider IDs to sync (fetches each provider separately for accurate source tracking)
Future<void> syncFromApi(
MusicAssistantAPI api, {
bool force = false,
List<String>? providerInstanceIds,
}) async {
if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping');
return;
@@ -155,33 +197,193 @@ class SyncService with ChangeNotifier {
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, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
// Only clear cache for full syncs (no specific providers)
// Partial syncs preserve items from disabled providers for instant toggle-on
final isPartialSync = providerInstanceIds != null && providerInstanceIds.isNotEmpty;
if (!isPartialSync) {
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
}
final albums = results[0] as List<Album>;
final artists = results[1] as List<Artist>;
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
// Build NEW source tracking maps - don't modify existing until sync is complete
// This prevents UI flicker from partial tracking during sync
final syncingProviders = providerInstanceIds?.toSet() ?? <String>{};
final newAlbumSourceProviders = <String, List<String>>{};
final newArtistSourceProviders = <String, List<String>>{};
final newAudiobookSourceProviders = <String, List<String>>{};
final newPlaylistSourceProviders = <String, List<String>>{};
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// For partial syncs, copy tracking from non-syncing providers
if (isPartialSync) {
for (final entry in _albumSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newAlbumSourceProviders[entry.key] = preserved;
}
for (final entry in _artistSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newArtistSourceProviders[entry.key] = preserved;
}
for (final entry in _audiobookSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newAudiobookSourceProviders[entry.key] = preserved;
}
for (final entry in _playlistSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newPlaylistSourceProviders[entry.key] = preserved;
}
}
// 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');
// Collect all items (deduped by itemId, but tracking all source providers)
final albumMap = <String, Album>{};
final artistMap = <String, Artist>{};
final audiobookMap = <String, Audiobook>{};
final playlistMap = <String, Playlist>{};
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
await _saveAudiobooksToCache(audiobooks);
await _savePlaylistsToCache(playlists);
// If specific providers are requested, sync each separately for accurate source tracking
// Use smaller batches in remote mode to avoid WebRTC message size issues
final limit = api.isRemoteMode ? 100 : 1000;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers (limit: $limit, remote: ${api.isRemoteMode})');
for (final providerId in providerInstanceIds) {
_logger.log(' 📡 Syncing provider: $providerId');
List<Album> albums;
List<Artist> artists;
List<Audiobook> audiobooks;
List<Playlist> playlists;
// In remote mode, do sequential requests to avoid overwhelming WebRTC
if (api.isRemoteMode) {
albums = await api.getAlbums(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
audiobooks = await api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
playlists = await api.getPlaylists(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
} else {
// Local mode: parallel requests for speed
final results = await Future.wait([
api.getAlbums(limit: limit, providerInstanceIds: [providerId]),
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]),
api.getPlaylists(limit: limit, providerInstanceIds: [providerId]),
]);
albums = results[0] as List<Album>;
artists = results[1] as List<Artist>;
audiobooks = results[2] as List<Audiobook>;
playlists = results[3] as List<Playlist>;
}
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
// Add to maps and track source provider in NEW tracking maps
for (final album in albums) {
albumMap[album.itemId] = album;
newAlbumSourceProviders.putIfAbsent(album.itemId, () => []);
if (!newAlbumSourceProviders[album.itemId]!.contains(providerId)) {
newAlbumSourceProviders[album.itemId]!.add(providerId);
}
}
for (final artist in artists) {
artistMap[artist.itemId] = artist;
newArtistSourceProviders.putIfAbsent(artist.itemId, () => []);
if (!newArtistSourceProviders[artist.itemId]!.contains(providerId)) {
newArtistSourceProviders[artist.itemId]!.add(providerId);
}
}
for (final audiobook in audiobooks) {
audiobookMap[audiobook.itemId] = audiobook;
newAudiobookSourceProviders.putIfAbsent(audiobook.itemId, () => []);
if (!newAudiobookSourceProviders[audiobook.itemId]!.contains(providerId)) {
newAudiobookSourceProviders[audiobook.itemId]!.add(providerId);
}
}
for (final playlist in playlists) {
playlistMap[playlist.itemId] = playlist;
newPlaylistSourceProviders.putIfAbsent(playlist.itemId, () => []);
if (!newPlaylistSourceProviders[playlist.itemId]!.contains(providerId)) {
newPlaylistSourceProviders[playlist.itemId]!.add(providerId);
}
}
}
} else {
// No provider filter - fetch all at once (faster, but no source tracking)
_logger.log('📡 Fetching from all providers (no source tracking, limit: $limit)');
// In remote mode, do sequential requests to avoid overwhelming WebRTC
if (api.isRemoteMode) {
_logger.log('🌐 Remote mode: using sequential requests with delays');
final albums = await api.getAlbums(limit: limit);
for (final album in albums) {
albumMap[album.itemId] = album;
}
_logger.log(' 📥 Got ${albums.length} albums');
await Future.delayed(const Duration(milliseconds: 500));
final artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums);
for (final artist in artists) {
artistMap[artist.itemId] = artist;
}
_logger.log(' 📥 Got ${artists.length} artists');
await Future.delayed(const Duration(milliseconds: 500));
final audiobooks = await api.getAudiobooks(limit: limit);
for (final audiobook in audiobooks) {
audiobookMap[audiobook.itemId] = audiobook;
}
_logger.log(' 📥 Got ${audiobooks.length} audiobooks');
await Future.delayed(const Duration(milliseconds: 500));
final playlists = await api.getPlaylists(limit: limit);
for (final playlist in playlists) {
playlistMap[playlist.itemId] = playlist;
}
_logger.log(' 📥 Got ${playlists.length} playlists');
} else {
// Local mode: parallel requests for speed
final results = await Future.wait([
api.getAlbums(limit: limit),
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: limit),
api.getPlaylists(limit: limit),
]);
for (final album in results[0] as List<Album>) {
albumMap[album.itemId] = album;
}
for (final artist in results[1] as List<Artist>) {
artistMap[artist.itemId] = artist;
}
for (final audiobook in results[2] as List<Audiobook>) {
audiobookMap[audiobook.itemId] = audiobook;
}
for (final playlist in results[3] as List<Playlist>) {
playlistMap[playlist.itemId] = playlist;
}
}
}
final albums = albumMap.values.toList();
final artists = artistMap.values.toList();
final audiobooks = audiobookMap.values.toList();
final playlists = playlistMap.values.toList();
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
// Save to database cache with source provider info (using NEW tracking maps)
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
await _saveArtistsToCache(artists, newArtistSourceProviders);
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
// Update sync metadata
await _db.updateSyncMetadata('albums', albums.length);
@@ -190,14 +392,49 @@ class SyncService with ChangeNotifier {
await _db.updateSyncMetadata('playlists', playlists.length);
// Update in-memory cache
_cachedAlbums = albums;
_cachedArtists = artists;
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
if (isPartialSync) {
// Partial sync: merge with existing cache to preserve items from disabled providers
final albumIds = albums.map((a) => a.itemId).toSet();
final artistIds = artists.map((a) => a.itemId).toSet();
final audiobookIds = audiobooks.map((a) => a.itemId).toSet();
final playlistIds = playlists.map((p) => p.itemId).toSet();
// Keep items not in current sync, add/update items from sync
_cachedAlbums = [
..._cachedAlbums.where((a) => !albumIds.contains(a.itemId)),
...albums,
];
_cachedArtists = [
..._cachedArtists.where((a) => !artistIds.contains(a.itemId)),
...artists,
];
_cachedAudiobooks = [
..._cachedAudiobooks.where((a) => !audiobookIds.contains(a.itemId)),
...audiobooks,
];
_cachedPlaylists = [
..._cachedPlaylists.where((p) => !playlistIds.contains(p.itemId)),
...playlists,
];
} else {
// Full sync: replace entire cache
_cachedAlbums = albums;
_cachedArtists = artists;
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
}
// Atomically swap in the new source tracking maps
// This ensures filtering always sees complete data, never partial
_albumSourceProviders = newAlbumSourceProviders;
_artistSourceProviders = newArtistSourceProviders;
_audiobookSourceProviders = newAudiobookSourceProviders;
_playlistSourceProviders = newPlaylistSourceProviders;
_lastSyncTime = DateTime.now();
_status = SyncStatus.completed;
_logger.log('✅ Library sync complete');
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
} catch (e) {
_logger.log('❌ Library sync failed: $e');
_status = SyncStatus.error;
@@ -208,73 +445,90 @@ class SyncService with ChangeNotifier {
}
}
/// Save albums to database cache
Future<void> _saveAlbumsToCache(List<Album> albums) async {
/// Save albums to database cache with source provider tracking (batched)
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final album in albums) {
try {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
}
final providers = sourceProviders[album.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'album',
itemId: album.itemId,
data: jsonEncode(album.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${albums.length} albums to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache albums: $e');
}
_logger.log('💾 Saved ${albums.length} albums to cache');
}
/// Save artists to database cache
Future<void> _saveArtistsToCache(List<Artist> artists) async {
/// Save artists to database cache with source provider tracking (batched)
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final artist in artists) {
try {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
}
final providers = sourceProviders[artist.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: jsonEncode(artist.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${artists.length} artists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache artists: $e');
}
_logger.log('💾 Saved ${artists.length} artists to cache');
}
/// Save audiobooks to database cache
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
/// Save audiobooks to database cache with source provider tracking (batched)
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final audiobook in audiobooks) {
try {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
}
final providers = sourceProviders[audiobook.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: jsonEncode(audiobook.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
}
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
}
/// Save playlists to database cache
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
/// Save playlists to database cache with source provider tracking (batched)
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final playlist in playlists) {
try {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
}
final providers = sourceProviders[playlist.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: jsonEncode(playlist.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache playlists: $e');
}
_logger.log('💾 Saved ${playlists.length} playlists to cache');
}
/// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async {
await syncFromApi(api, force: true);
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
await syncFromApi(api, force: true, providerInstanceIds: providerInstanceIds);
}
/// Clear all cached data
@@ -286,12 +540,79 @@ class SyncService with ChangeNotifier {
_cachedArtists = [];
_cachedAudiobooks = [];
_cachedPlaylists = [];
_albumSourceProviders = {};
_artistSourceProviders = {};
_audiobookSourceProviders = {};
_playlistSourceProviders = {};
_lastSyncTime = null;
_status = SyncStatus.idle;
notifyListeners();
_logger.log('🗑️ Library cache cleared');
}
// ============================================
// Client-side filtering methods
// ============================================
/// Filter albums by source provider (instant, no network)
/// Empty enabledProviderIds = all providers enabled, show everything
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter artists by source provider (instant, no network)
List<Artist> getArtistsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedArtists;
}
return _cachedArtists.where((artist) {
final sources = _artistSourceProviders[artist.itemId];
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter audiobooks by source provider (instant, no network)
List<Audiobook> getAudiobooksFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedAudiobooks;
}
return _cachedAudiobooks.where((audiobook) {
final sources = _audiobookSourceProviders[audiobook.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter playlists by source provider (instant, no network)
List<Playlist> getPlaylistsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedPlaylists;
}
return _cachedPlaylists.where((playlist) {
final sources = _playlistSourceProviders[playlist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Check if we have source provider tracking data
bool get hasSourceTracking =>
_albumSourceProviders.isNotEmpty ||
_artistSourceProviders.isNotEmpty ||
_audiobookSourceProviders.isNotEmpty ||
_playlistSourceProviders.isNotEmpty;
/// Get albums (from cache or empty if not loaded)
List<Album> getAlbums() => _cachedAlbums;
@@ -307,4 +628,25 @@ class SyncService with ChangeNotifier {
/// Check if we have data available (from cache or sync)
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
/// Update cached albums with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedAlbums(List<Album> albums) {
_cachedAlbums = albums;
notifyListeners();
}
/// Update cached artists with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedArtists(List<Artist> artists) {
_cachedArtists = artists;
notifyListeners();
}
/// Update cached playlists with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedPlaylists(List<Playlist> playlists) {
_cachedPlaylists = playlists;
notifyListeners();
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
@@ -91,7 +91,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
+66
View File
@@ -124,3 +124,69 @@ class Durations {
static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500);
}
/// Button size tokens
class ButtonSizes {
ButtonSizes._();
// Touch target sizes
// WCAG 2.1 Level AA requires minimum 44x44, Level AAA requires 48x48
// Material Design recommends 48x48 minimum
// WARNING: sm and md are below WCAG minimum - use only for non-interactive elements
/// 32x32 - NOT WCAG compliant, use only for decorative/non-interactive elements
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double sm = 32.0;
/// 36x36 - NOT WCAG compliant, use only for dense layouts where space is critical
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double md = 36.0;
/// 44x44 - Meets WCAG Level AA minimum
static const double lg = 44.0;
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
static const double xl = 48.0;
/// 56x56 - Comfortable touch target for primary actions
static const double xxl = 56.0;
/// Minimum WCAG-compliant touch target size (48x48)
static const double minAccessible = 48.0;
// Icon sizes within buttons
static const double iconSm = 16.0;
static const double iconMd = 18.0;
static const double iconLg = 20.0;
static const double iconXl = 24.0;
}
/// Semantic status colors for consistent feedback
/// Use with Theme brightness for proper contrast
class StatusColors {
StatusColors._();
// Success states
static const Color success = Color(0xFF4CAF50);
static const Color successLight = Color(0xFF81C784);
static const Color successDark = Color(0xFF388E3C);
// Error states
static const Color error = Color(0xFFF44336);
static const Color errorLight = Color(0xFFE57373);
static const Color errorDark = Color(0xFFD32F2F);
// Warning states
static const Color warning = Color(0xFFFF9800);
static const Color warningLight = Color(0xFFFFB74D);
static const Color warningDark = Color(0xFFF57C00);
// Info states
static const Color info = Color(0xFF2196F3);
static const Color infoLight = Color(0xFF64B5F6);
static const Color infoDark = Color(0xFF1976D2);
// Favorites (heart icon)
static const Color favorite = Color(0xFFF44336);
static const Color favoriteActive = Color(0xFFE53935);
}
+18 -3
View File
@@ -9,6 +9,7 @@ final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return;
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
// Silently fail - colors will update when track plays
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
ColorScheme? _adaptiveLightScheme;
ColorScheme? _adaptiveDarkScheme;
// Track if we're on a detail screen (vs just player-extracted colors)
// When true, nav bar should use adaptive colors even when player is collapsed
bool _isOnDetailScreen = false;
ThemeProvider() {
_loadSettings();
}
@@ -55,6 +60,7 @@ 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;
@@ -162,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(
@@ -181,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();
}
}

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