Compare commits

..
55 Commits
Author SHA1 Message Date
Chris 6fc24539b1 On audit: Discarded multi-provider fixes - reverting to master 2026-02-07 20:36:50 +00:00
Chris 32f62a4731 index on audit: 9b8f6d3 Fix: Show all available home screen rows in settings 2026-02-07 20:36:50 +00:00
ChrisandClaude Opus 4.6 9b8f6d36c6 Fix: Show all available home screen rows in settings
Commit 0263c75 reduced defaultHomeRowOrder to 3 rows but also
introduced a bug where _filteredRowOrder only showed rows currently
in _homeRowOrder. This made most row options invisible in settings.

Added _allAvailableRows constant (13 rows) and modified _filteredRowOrder
to show all available static rows regardless of their current enabled state.

Users can now see and toggle all 13 home screen rows:
- 3 enabled by default (recent albums, discover artists, discover albums)
- 10 disabled by default (all other rows)

Fixes regression from commit 0263c75 (Feb 5, 2025)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:15:29 +00:00
ChrisandClaude Opus 4.6 a3e414dc4b Fix: Add Overlay wrapper and checks to prevent red screen error
- Wrap GlobalPlayerOverlay with Overlay in main.dart to provide Overlay ancestor
- Add Overlay.maybeOf check in HiResBadge before using Tooltip
- Add Overlay.maybeOf check in search_screen library-only toggle before using Tooltip

This fixes "No Overlay widget found" error that caused red screen in debug builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:15:20 +00:00
ChrisandClaude Opus 4.6 a6d6eb8d55 Fix QueueManagerService circular dependency in multi-provider architecture
- Make QueueManagerService optional in PlayerProvider to break circular dependency
- Set queueManager after initialization in main.dart
- Add castToSendspinIdMap getter to PlayerProvider for external access
- Wire up QueueManagerService with proper callbacks to provider state

This allows providers to be properly initialized without circular references
while maintaining the ability to access shared state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:53:59 +00:00
ChrisandClaude Opus 4.6 cd8c5a66e4 Complete multi-provider architecture
Created two core providers to complete the multi-provider split:

1. PlayerProvider (588 lines)
   - Player selection and available players list
   - Current playback state and track management
   - Play/pause/stop/next/previous/seek controls
   - Queue operations delegation
   - Position tracking integration
   - Sleep timer integration
   - Audio handler notification updates
   - Cast-to-Sendspin mapping support

2. LocalPlayerProvider (370 lines)
   - LocalPlayerService management
   - SendspinService integration (MA 2.7.0b20+)
   - PcmAudioPlayer for raw audio streaming
   - Player registration with Music Assistant
   - Volume and power control
   - Event handling from Sendspin
   - State reporting to Sendspin

**Multi-Provider Split Complete!**

All 7 focused providers created:
- ConnectionProvider (245 lines) - WebSocket, auth, connection state
- UIStateProvider (166 lines) - Search, filters, loading state
- LibraryProvider (290 lines) - Artists, albums, tracks, podcasts
- PlayerProvider (588 lines) - Player selection, controls, queue
- LocalPlayerProvider (370 lines) - Local audio engine, Sendspin
- SleepTimerProvider (133 lines) - Sleep timer functionality
- LocaleProvider (44 lines) - Locale/language settings
- NavigationProvider (40 lines) - Navigation state

Total new provider code: ~1,876 lines across 7 focused providers
Main provider can now act as a facade for gradual migration.

Next steps: Integrate into main.dart, migrate consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:24:53 +00:00
ChrisandClaude Opus 4.6 dadfe65e5a Create multi-provider architecture foundation
Created three new focused providers to begin splitting the monolithic
MusicAssistantProvider (5928 lines) into smaller, more maintainable
components:

1. ConnectionProvider (245 lines)
   - WebSocket connection to Music Assistant server
   - Authentication flow (tokens and credentials)
   - Connection state management
   - Event stream subscriptions

2. UIStateProvider (167 lines)
   - Search state (query and results)
   - Provider and player filters
   - User-controlled provider enable/disable
   - Loading states
   - Home refresh counter

3. LibraryProvider (290 lines)
   - Artists, albums, tracks lists
   - Radio stations and podcasts
   - Library loading and refresh
   - Provider filtering
   - Background sync

These providers will be integrated into the app gradually, with the main
MusicAssistantProvider acting as a facade for backward compatibility during
the transition period.

Next steps: Create PlayerProvider and LocalPlayerProvider, then integrate
all providers into main.dart and update consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:18:02 +00:00
ChrisandClaude Opus 4.6 0a853bd862 Extract and centralize constant definitions
Added new constant classes to timings.dart:
- ImageSizes: defaultSize (256), highRes (512), max (1024)
- VolumeConstants: min (0), max (100), range (100)
- PlayerStates: playing, paused, idle, off
- RepeatModes: off, all, one

Updated MusicAssistantProvider and QueueControls to use these
constants instead of magic numbers and string literals.

This improves code maintainability by:
- Eliminating magic numbers
- Providing single source of truth for common values
- Making code more self-documenting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:04:33 +00:00
ChrisandClaude Opus 4.6 694b7127a0 Extract queue controls utility
Extracted repeat mode cycling logic from MusicAssistantProvider
into QueueControls utility (26 lines). This isolates queue
playback control logic.

This reduces the main provider from 5941 to 5928 lines (-13 lines).

Extracted methods:
- getNextRepeatMode(String? currentMode) - Get next repeat mode in cycle
- isValidRepeatMode(String? mode) - Validate repeat mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:54:53 +00:00
ChrisandClaude Opus 4.6 4cbfd6a9e0 Extract network utilities
Extracted network-related utility method from MusicAssistantProvider
into NetworkUtils (27 lines). This isolates the logic for checking
if a hostname is a local/private network address.

This reduces the main provider from 5954 to 5941 lines (-13 lines).

Extracted methods:
- isLocalNetworkHost(String host) - Check if hostname is local/private

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:52:12 +00:00
ChrisandClaude Opus 4.6 8bf0d23972 Extract search service
Extracted search functionality from MusicAssistantProvider into
SearchService (96 lines). This isolates search operations with caching:

- searchWithCache - Search with caching support
- clearAllDetailCaches - Clear all search/detail caches
- isSearchCacheValid - Check cache validity
- getCachedSearchResults - Get cached results

This reduces the main provider from 5979 to 5954 lines (-25 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:48:16 +00:00
ChrisandClaude Opus 4.6 c17b8b5c5e Extract player sort utility
Extracted player sorting and filtering logic from MusicAssistantProvider
into PlayerSortUtility (162 lines). This isolates complex player list
management operations:

- Smart sort logic: local player first, then playing, then on, then off
- Default alphabetical sort fallback
- Player filtering: excludes MA Mobile, MA Web UI, "This Device" players
- Best player auto-selection: last selected > last active > builtin > first

This reduces the main provider from 5999 to 5979 lines (-20 lines).

Extracted methods:
- sortPlayers(List<Player>, {bool smartSort, String? builtinPlayerId})
- filterPlayers(List<Player>, {String? builtinPlayerId})
- selectBestPlayer({...}) - intelligent player auto-selection
- _statusPriority(Player) - private helper for sort order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:45:04 +00:00
ChrisandClaude Opus 4.6 4e3134df9c Extract queue management service
Extracted queue-related logic from MusicAssistantProvider into a new
QueueManagerService (221 lines). This isolates complex queue operations
including:

- Queue fetching from API with player ID translation
  - Handles group child players (fetches leader's queue)
  - Cast-to-Sendspin ID translation for queue operations
- Queue caching to database for instant display
- Queue restoration from cache on app resume
- Track validation for queue items (filters playable tracks)

This reduces the main provider from 6110 to 5999 lines (-111 lines).

Extracted methods:
- getQueue(String playerId)
- getCachedQueue(String playerId)
- persistQueueToDatabase(String playerId, PlayerQueue queue)
- restoreQueueFromCache(String playerId)
- _validateQueueTracks(List<QueueItem> queueItems)
- _getEffectivePlayerId(String playerId)
- _translateSendspinToCastId(...)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:37:03 +00:00
ChrisandClaude Opus 4.6 c4b80b9d33 Extract player sync state utility
Third incremental extraction to reduce monolithic provider size:
- Created PlayerSyncState utility (135 lines) for sync state determination
- Extracted isPlayerManuallySynced method with complex business logic
- Added helper methods: findPlayer, findSyncTarget, isGroupPlayer, isManualSyncLeader
- Reduced main provider from 6158 to 6110 lines

PlayerSyncState provides:
- Clean separation of sync state logic from UI provider
- Testable business rules for manual vs pre-configured groups
- Cast-to-Sendspin ID translation support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:25:28 +00:00
ChrisandClaude Opus 4.6 d28c6ab556 Extract services from MusicAssistantProvider
First incremental extraction to reduce monolithic provider size:
- Created ImageHelperService (97 lines) for image/artwork operations
- Created ProviderFilterService (319 lines) for provider filtering logic
- Reduced main provider from 6180 to 6158 lines

ImageHelperService methods:
- getImageUrl, getPodcastImageUrl, getArtistImageUrlWithFallback
- getProviderIconSvg, getCachedArtworkUrl
- Podcast cover cache management

ProviderFilterService methods:
- filterByProvider, filterPlayers, isItemAllowedByProviderFilter
- getProvidersWithArtists/Albums/Tracks/Playlists/etc.
- getRelevantProvidersForCategory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:20:20 +00:00
ChrisandClaude Opus 4.6 ccfea4168c Fix critical and high priority issues from codebase audit
Critical fixes:
- Remove empty setState() in debug_log_screen.dart
- Add disposed flag to sleep_timer_provider.dart to prevent
  notifyListeners() after dispose

High priority fixes:
- Convert .then() chains to async/await for better error handling
  in playlist_details_screen.dart, album_details_screen.dart,
  expandable_player.dart, player_reveal_overlay.dart,
  queue_panel.dart, and all card widgets
- Extract _loadHintSettings() and _fetchAuthorImage() methods

Also includes:
- CODEBASE_AUDIT_REPORT.md with comprehensive audit findings

Tested on device - no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:54:08 +00:00
ChrisandClaude Opus 4.6 8181e75a08 Fix detail screen layout: reduce cover size and spacing
- Reduce artist and audiobook author cover size from 70%/280px to 50%/200px
- Reduce expanded height padding for better space utilization
- Reduce top padding across all detail screens from 24px to 8px
- Move audiobook author three-dot menu from SliverAppBar to inline with author info

This fixes issue #60 where album art/titles were cut off by bottom menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:59:15 +00:00
ChrisandClaude Opus 4.6 9600f4bcfe Fix search race conditions with request tracking (#62)
Implement "latest request wins" pattern to prevent stale search results from overwriting current results when multiple searches are in flight. This was causing results to suddenly switch to only show "All", "Radio", "Podcasts" categories when typing quickly.

- Add _currentSearchId to track the active search request
- Add mounted check to Timer callback in _onSearchChanged
- Add search ID validation after each async operation (radio/podcast search)
- Only update state if the search is still current when results arrive
- Reset _hasPendingSearch flag when search completes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:02:54 +00:00
ChrisandClaude Opus 4.6 a2b8b1eeef Fix home screen refresh logic and prevent empty cache persistence
- Add home refresh counter to signal UI refresh after authentication
- Smart cache invalidation: only refresh home cache if it was empty
- Prevent caching empty results to avoid persisting auth errors
- Add ensureHomeRowsLoaded() to populate cache on first access
- Prevent duplicate home row loads with shared future
- Fix SendspinService emitting after disposal
- Reset auth state on WebSocket disconnect
- Change volume precision mode default to false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:51:14 +00:00
ChrisandClaude Opus 4.6 0263c75804 Refactor discovery rows: remove master toggle, simplify defaults, and fix bugs
- Remove Discovery Mixes master toggle entirely
- Discovery rows now auto-appear based on provider availability (e.g., Tidal)
- Discovery rows are off by default on first launch
- All rows (discovery and static) now behave consistently:
  * Enabled rows are added to end of list, not inserted at fixed positions
  * Users can drag rows to any position and they stay there
- Simplified default row order to only include: recently played,
  discover artists, and discover albums
- Fixed critical bug: discovery rows now correctly default to false
- Fixed SharedPreferences variable shadowing bug in setDiscoveryRowPreferences
- Fixed grey section in settings draggable list by filtering discovery-mixes
- Added localization strings for discovery rows UI
- Removed debug print statements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 22:57:02 +00:00
ChrisandClaude Opus 4.6 469e77c572 Add loading placeholder for discovery folders
Show a loading indicator when discovery folders are being fetched,
ensuring pull-to-refresh works even on empty screens while discovery
is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:00:58 +00:00
ChrisandClaude Opus 4.5 577b03bbea Move discovery mixes to draggable list and fix empty screen pull-to-refresh
- Move discovery mixes toggle from standalone to draggable row list in settings
- Add empty state widget when no home screen rows are enabled
- Fix pull-to-refresh not working when screen is empty by using SizedBox.expand
- Add localization strings for empty state hint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:23:57 +00:00
ChrisandClaude Opus 4.5 7bbba90fff Fix discovery row height - match AlbumRow textAreaHeight of 44px
DiscoveryRow was using 52px for textAreaHeight vs AlbumRow's 44px,
causing smaller artwork and different row appearance.
Changed to 44px to match other rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:48:48 +00:00
ChrisandClaude Opus 4.5 6f66a1ebff Fix discovery rows crash - add missing import and handle firstWhere error
- Add import for RecommendationFolder model
- Wrap firstWhere in try-catch to handle missing folders gracefully
- Fixes the grey screen issue when discovery toggle is enabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:39:07 +00:00
Chris 0ceab80a14 Fix compilation errors in discovery row implementation
- Remove references to non-existent RadioStation, Podcast, PodcastEpisode classes
- Fix MediaType.podcastEpisode enum value (camelCase, not snake_case)
- Use MediaItem base class for radio, podcast, podcast_episode types
2026-02-05 15:31:45 +00:00
ChrisandClaude Opus 4.5 efb84a4e50 Implement dynamic discovery rows architecture
Instead of rendering multiple discovery shelves in a single MixesRow,
each discovery folder now becomes its own row in the home screen,
following the standard 237px height pattern.

Changes:
- Add RecommendationFolder model with polymorphic JSON parsing
- Add DiscoveryRow widget for displaying a single discovery folder
- Add discovery folders to MusicAssistantProvider (getDiscoveryFoldersWithCache)
- Add discovery folders to CacheService and MusicAssistantAPI
- Add "Discovery Mixes" toggle to settings (default off)
- Update home screen to dynamically register discovery rows

This fixes the architectural issue where MixesRow broke the home
screen's assumption that each row entry renders exactly one row
with a fixed height.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:04:32 +00:00
Chris 731f45bea9 Style badges with for-the-badge 2026-02-05 09:54:38 +00:00
Chris 32e61f109e Add release version and license badges to README 2026-02-05 09:52:46 +00:00
Chris cd9e82b1fd Add FUNDING.yml for GitHub Sponsors button 2026-02-05 09:20:30 +00:00
ChrisandClaude Opus 4.5 04322cf768 Add donation support feature
Add GitHub Sponsors and Ko-fi donation buttons in settings screen,
along with an About section showing app version and links to
GitHub repository and issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:19:12 +00:00
Chris e6349c309b Add link to Ensemble TV repo 2026-01-31 09:03:22 +00:00
Chris 00f065fbe9 v3.0.2: Disable dependency metadata for F-Droid 2026-01-28 05:00:57 +00:00
ChrisandClaude Opus 4.5 e440f7b1d7 Bump version to 3.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:21:16 +00:00
ChrisandClaude Opus 4.5 5a73d2d65b Add fastlane metadata for F-Droid
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:18:43 +00:00
ChrisandClaude Opus 4.5 3b5a8631a8 Change package ID to com.collotsspot.ensemble
BREAKING CHANGE: Users must uninstall and reinstall the app.
The previous package ID (com.musicassistant.music_assistant) could be
confused with an official Music Assistant app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:55:23 +00:00
ChrisandClaude Opus 4.5 1f3a01c22f Bump version to 2.9.3-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:20:19 +00:00
ChrisandClaude Opus 4.5 27e1b8b746 Fix local player track data disappearing when selected
When selecting the builtin/local player from the player selector overlay,
the track info would disappear because the queue API returns null for
local players. The code was clearing _currentTrack unconditionally.

Now for the builtin player:
- Preserve cached track data instead of clearing it
- Restore from cache if _currentTrack is null
- Update notification with actual track info when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 9be3737a9d Add centralized LibraryStatusService for consistent library/favorite state
- Create LibraryStatusService singleton for single source of truth
- Add LibraryStatusMixin for reactive status in StatefulWidgets
- Convert all card widgets to use centralized service:
  - album_card, artist_card, playlist_card, podcast_card
  - radio_station_card, track_row, audiobook_row
- Convert detail screens: album_details_screen, artist_details_screen
- Fix album stuck in library after removal:
  - Add removeFromCacheByLibraryId() to SyncService
  - Add markCachedItemDeletedByLibraryId() to DatabaseService
  - Update removeFromLibrary() to sync all caches

This ensures library/favorite status changes are immediately reflected
across all instances of the same item throughout the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 882e8b2ea4 Simplify player selection logic
Remove confusing auto-switch behavior that could change the selected
player unexpectedly right before the user taps play.

Removed settings:
- "Prefer Local Player"
- "Disable Auto-Switch"

New simplified logic:
1. User's manual selection is always respected
2. On app reopen: restore last selected player
3. If selected player goes offline: switch to local player
4. First launch (no history): select local player
5. Local player unavailable: select first available

Kept "Smart Sort" setting for display order only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 aef9423b42 Improve adaptive color contrast using HCT color space
- Replace HSL-based color adjustments with HCT (Material Design 3 standard)
- Use proper WCAG contrast ratio calculations instead of luminance threshold
- Implement binary search to find minimum tone adjustment for contrast
- Ensure minimum chroma of 36 for primary colors to prevent washed-out highlights
- Add color scheme caching for instant loading on revisits
- Pre-extract colors on album tap for faster initial load

Fixes issue where highlighted library button was darker than unhighlighted
state on albums with dark/muted covers (e.g., Portishead, NIHT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
Chris c0138828a7 Revise reverse proxy details and remove auth methods
Updated reverse proxy options and removed authentication section.
2026-01-27 08:01:56 +00:00
Chris 53225138a2 chore: Bump version to 2.9.2-beta 2026-01-26 22:29:11 +00:00
ChrisandClaude Opus 4.5 ebc3288bfc Replace library icon with book_2 from Material Symbols
- Add material_symbols_icons package for access to book_2 icon
- Update library icon across all detail screens, context menus, bottom
  nav, and search screen to use Symbols.book_2 with fill parameter
- Add podcast episode detail screen with hero animation and full
  scrollable description
- Add episode context menu support with Play, Play On, Add to Queue,
  and View Details options
- Add sort controls for podcast episodes (newest, oldest, alpha, duration)
- Improve context menu: slightly larger buttons (46px), wider menu (185px),
  neutral text color for labels
- Search library filter now uses filled icon without background highlight

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:13:42 +00:00
ChrisandClaude Opus 4.5 5bd0cef76e Fix context menu width consistency and improve UI polish
- Fix menu width rendering inconsistently on dark album covers by:
  - Adding explicit secondaryContainer/onSecondaryContainer to ColorScheme
  - Wrapping menu content in Theme to ensure FilledButton.tonal uses
    correct colorScheme
  - Constraining text labels to 44px width to prevent row overflow
  - Shorten "Add to library" label to "Add" for better fit

- Standardize button styling across all detail screens:
  - Use FilledButton.tonal for three-dot menu buttons
  - Use filled heart icons with Colors.white70 inactive state
  - Standardize icon size to 25

- Add sort/view controls to artist and audiobook author context menus

- Remove duplicate haptic feedback (keep single haptic in
  MediaContextMenu.show)

- Make context menu dividers more subtle (15% opacity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:23:54 +00:00
ChrisandClaude Opus 4.5 9f399b438f Add library toggle to all context menus and improve menu contrast
- Add inLibrary getter to base MediaItem class for podcasts/radio
- Add library toggle support to all card widgets (podcast, track, radio, audiobook)
- Add library toggle methods to detail screens (album, playlist, podcast, audiobook)
- Add library toggle for tracks within album and playlist detail screens
- Add library toggle methods to new_library_screen for audiobooks, podcasts, radio
- Update all context menu calls to use proper library toggle callbacks
- Make context menu background 25% lighter when adaptive colors are used
  for better contrast against detail screen backgrounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 09:40:19 +00:00
ChrisandClaude Opus 4.5 63473226f5 Fix navigation transitions and localize hardcoded strings
Phase 1 audit fixes:
- Use FadeSlidePageRoute for DebugLogScreen navigation (consistent transitions)
- Add volumePrecisionMode and volumePrecisionModeDescription to localizations
- Replace hardcoded empty state strings in all row widgets with localized versions:
  - album_row, artist_row, track_row, playlist_row
  - podcast_row, radio_station_row, audiobook_row, series_row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:55:20 +00:00
ChrisandClaude Opus 4.5 e3d257a6dc Add hi-res audio badge to track listings
Display a hi-res badge on tracks with lossless codec and either
sample rate > 48kHz or bit depth > 16. Badge uses adaptive primary
color muted with white to match album theme.

- Add HiResBadge widget with vector-drawn badge (no external assets)
- Show badge in album, playlist, and library track listings
- Fixed-width duration column to keep badges aligned
- Tooltip shows exact sample rate and bit depth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:31:03 +00:00
ChrisandClaude Opus 4.5 671b31de61 Standardize library list item height and add addTracksToQueue API
- Set itemExtent to 67px for consistent 10-item display across all library lists
- Remove vertical padding from ListTile contentPadding for better fit
- Add dedicated addTracksToQueue method to properly add tracks without interrupting playback
- Update album and playlist detail screens to use new queue method
- Add memCacheWidth/Height to series row for consistent image caching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:08:20 +00:00
ChrisandClaude Opus 4.5 67a4ebd771 Refactor player selector and optimize library list performance
Use centralized GlobalPlayerOverlay.showPlayerSelectorForAction() for queue
actions, reducing code duplication. Add itemExtent to library lists and reduce
image sizes for smoother scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:25:10 +00:00
ChrisandClaude Opus 4.5 23af5379a6 Fix external source detection for Spotify as music provider (issue #27)
The external source detection was incorrectly flagging Spotify tracks
played through Music Assistant as "external" because it checked for
spotify:// URI patterns without considering whether MA had an active queue.

Changes:
- Add activeQueue field to Player model to track MA queue state
- Fix detection priority: active_queue presence now indicates MA control
- Remove spotify:// from fallback URI checks (needs app_id context)
- Only flag physical inputs (optical, hdmi, etc.) in URI fallback
- Update provider event handling to use same detection logic
- Improve debug logging to include active_queue state

Detection logic priority:
1. app_id == 'music_assistant' → NOT external
2. active_queue has value → NOT external (key fix for Spotify provider)
3. app_id set and != 'music_assistant' → external
4. Fallback: Check physical input URIs only

Fixes #27

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 06:07:15 +00:00
ChrisandClaude Opus 4.5 86bdad5534 Simplify auth to use MA built-in authentication
- Remove auth strategy pattern (NoAuth, BasicAuth, Authelia, MAAuth)
- Simplify AuthManager to use MA's native token-based auth
- Streamline login screen with cleaner UI
- Remove unused auth strategy files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:27:37 +00:00
ChrisandClaude Opus 4.5 7ee27f6e75 Performance optimization: isolate palette extraction and provider selects
- Move palette extraction to isolate using compute() to prevent main thread blocking
- Add image package for pure-Dart isolate-compatible image decoding
- Add extractColorSchemesFromUrl() with LRU cache for downloaded images
- Replace context.watch() with context.select() to reduce unnecessary rebuilds
- Update all detail screens to use isolate-based color extraction
- Optimize library screens and widgets to only rebuild on specific field changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:24:56 +00:00
ChrisandClaude Opus 4.5 633f489bfa Add remote connection documentation
Documents WebRTC remote connection investigation and findings.
Feature on hold due to one-way channel bug. See docs for details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:19:20 +00:00
ChrisandClaude Opus 4.5 61503cbe49 Fix Flutter 3.38 slider grey rectangles and library tab scroll
- Replace Material Slider with custom drawn sliders in:
  - expandable_player.dart (progress bar)
  - volume_control.dart (volume slider)
- Add seek debounce to prevent rapid-fire seeks
- Use standard PageScrollPhysics in library to fix multi-tab scroll

Flutter 3.38's Material 3 Slider uses OverlayPortal which causes
grey rectangles in our overlay context. Custom sliders avoid this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:48:43 +00:00
ChrisandClaude Opus 4.5 0b49c5158c Update to Flutter 3.38 compatibility
- Upgrade Gradle 8.1.0 → 8.7.0
- Upgrade Kotlin 1.9.10 → 2.1.0
- Update intl ^0.19.0 → ^0.20.0
- Fix CardTheme → CardThemeData (Flutter 3.38 API change)
- Update CI workflow to use Flutter 3.38

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:37:23 +00:00
135 changed files with 17901 additions and 9019 deletions
+3
View File
@@ -0,0 +1,3 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
+529
View File
@@ -0,0 +1,529 @@
# Ensemble Flutter Music Application - Codebase Audit Report
**Generated:** 2026-02-07
**Project Location:** `/home/chris/Ensemble`
**Code Size:** ~6,180 lines in main provider, ~50K+ total lines
**Flutter Version:** 3.0+
**Auditor:** Claude Code Analysis
---
## Executive Summary
This comprehensive audit examined the Ensemble Flutter music streaming application for security vulnerabilities, performance issues, code quality concerns, and architectural problems. The audit identified **23 distinct issues** across severity levels.
### Severity Breakdown
- **Critical:** 2 issues
- **High:** 6 issues
- **Medium:** 10 issues
- **Low:** 5 issues
### Overall Health Assessment: **GOOD** (with areas for improvement)
The codebase demonstrates:
- ✅ Strong secure storage practices (flutter_secure_storage with encryptedSharedPreferences)
- ✅ Comprehensive dispose() cleanup across widgets
- ✅ Good mounted check usage in most async operations
- ⚠️ One critical file requiring splitting (6,180 lines)
- ⚠️ Some timer callback issues
- ⚠️ Several .then() chains that should use async/await
---
## 1. CRITICAL ISSUES
### 1.1 Massive Single File - `music_assistant_provider.dart`
**File:** `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart`
**Lines:** 6,180
**Severity:** Critical
**Category:** Maintainability
**Description:**
The main provider file is extremely large (6,180 lines), making it difficult to navigate, test, and maintain. This violates single responsibility principle and increases the risk of merge conflicts.
**Recommendation:**
Split into smaller focused providers:
- `MusicAssistantProvider` (core coordination)
- `PlayerStateProvider` (player management)
- `LibraryStateProvider` (library data)
- `ConnectionProvider` (already exists but not fully utilized)
**Impact:** High complexity, difficult onboarding, increased bug risk
---
### 1.2 Empty setState() Call
**File:** `/home/chris/Ensemble/lib/screens/debug_log_screen.dart:367`
**Severity:** Critical
**Category:** Performance
**Code:**
```dart
onPressed: () {
setState(() {});
if (_scrollController.hasClients) {
_scrollController.animateTo(...)
}
},
```
**Description:**
An empty setState() triggers a complete widget rebuild without any state changes, wasting CPU cycles.
**Recommendation:**
```dart
onPressed: () {
if (_scrollController.hasClients) {
_scrollController.animateTo(...)
}
},
```
Or if state update is needed:
```dart
onPressed: () {
setState(() => _lastRefreshTime = DateTime.now());
if (_scrollController.hasClients) {
_scrollController.animateTo(...)
}
},
```
---
## 2. HIGH SEVERITY ISSUES
### 2.1 Timer Callback Without Mounted Check
**File:** `/home/chris/Ensemble/lib/providers/sleep_timer_provider.dart:76-77`
**Severity:** High
**Category:** Performance/Stability
**Code:**
```dart
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
```
**Description:**
Timer callback calls notifyListeners() without checking if the widget is still mounted. If the provider is disposed but timer fires, it could cause issues.
**Recommendation:**
Add a flag to track disposal state:
```dart
bool _disposed = false;
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!_disposed) {
notifyListeners();
}
});
@override
void dispose() {
_disposed = true;
_cancelInternal();
super.dispose();
}
```
---
### 2.2 Multiple .then() Chains (Should Use async/await)
**Files:**
- `/home/chris/Ensemble/lib/screens/playlist_details_screen.dart:598`
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:468, 502, 521, 577, 601, 1644, 1773`
- `/home/chris/Ensemble/lib/widgets/playlist_card.dart:258`
- `/home/chris/Ensemble/lib/widgets/podcast_card.dart:264`
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:212`
- `/home/chris/Ensemble/lib/widgets/player/player_reveal_overlay.dart:83, 115, 191`
- `/home/chris/Ensemble/lib/widgets/album_card.dart:335`
- `/home/chris/Ensemble/lib/widgets/artist_card.dart:321`
- `/home/chris/Ensemble/lib/screens/new_library_screen.dart:1213`
**Severity:** High
**Category:** Code Quality
**Example:**
```dart
// Current (.then pattern)
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
});
```
**Recommendation:**
```dart
// Preferred (async/await)
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
} catch (e) {
// Handle error
}
```
**Impact:** async/await provides better error handling and readability
---
### 2.3 TODO Comments for Unimplemented Features
**Files:**
- `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart:1418, 1424`
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart:351, 402`
- `/home/chris/Ensemble/lib/screens/queue_screen.dart:283`
**Severity:** Medium-High
**Category:** Functionality
**Description:**
Important features are marked as TODO but not implemented:
- Playlist modification functionality
- Server-side audiobook progress updates
- Queue item removal via API
**Recommendation:**
Create GitHub issues for these features and track implementation progress.
---
### 2.4 Unused Dependencies Detection
**File:** `/home/chris/Ensemble/pubspec.yaml`
**Severity:** High (if truly unused)
**Category:** Bundle Size
**Potential Unused Packages:**
- `package_info_plus` (used in debug_logger.dart - ✅ KEEP)
- `device_info_plus` (used in debug_logger.dart - ✅ KEEP)
- `share_plus` (used in debug_log_screen.dart - ✅ KEEP)
- `url_launcher` (used in settings_screen.dart - ✅ KEEP)
**Conclusion:** All listed packages are actually in use. No action needed.
---
### 2.5 Duplicate Auth Token Logic
**Files:**
- `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
- `/home/chris/Ensemble/lib/services/settings_service.dart` (not reviewed but mentioned in plan)
**Severity:** High
**Category:** Maintainability
**Description:**
Auth token storage exists in multiple places with slight variations:
- `SecureStorageService` has `getAuthToken()`, `getMaAuthToken()`
- `AuthManager` stores its own `_accessToken`, `_longLivedToken`
- Potential for inconsistencies
**Recommendation:**
Consolidate to single source of truth - make AuthManager the only place tokens are stored in memory, with SecureStorageService only for persistence.
---
## 3. MEDIUM SEVERITY ISSUES
### 3.1 Hardcoded WebSocket Port
**File:** `/home/chris/Ensemble/lib/constants/network.dart:6`
**Severity:** Medium
**Category:** Maintainability
**Code:**
```dart
static const int defaultWsPort = 8095;
```
**Description:**
Port is hardcoded in multiple places across the codebase.
**Recommendation:**
✅ Already extracted to `NetworkConstants.defaultWsPort` - GOOD PRACTICE!
However, the port is still hardcoded in:
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart:182`
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
**Fix:**
Replace `port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095)` with:
```dart
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? NetworkConstants.defaultHttpsPort : NetworkConstants.defaultWsPort)
```
---
### 3.2 Timer Cleanup Issues
**Files:**
- `/home/chris/Ensemble/lib/services/sendspin_service.dart:646`
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:2369, 349`
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:562, 675`
- `/home/chris/Ensemble/lib/screens/search_screen.dart:364`
**Severity:** Medium
**Category:** Memory Leaks
**Description:**
Multiple timers are created throughout the codebase. Need to verify all are properly cancelled in dispose().
**Recommendation:**
Audit each timer creation to ensure corresponding cancel() in dispose().
---
### 3.3 Deep Nesting in Detail Screens
**Files:**
- `/home/chris/Ensemble/lib/screens/artist_details_screen.dart`
- `/home/chris/Ensemble/lib/screens/album_details_screen.dart`
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart`
**Severity:** Medium
**Category:** Code Quality
**Description:**
These screens have deeply nested code (4+ levels) making them harder to read and maintain.
**Recommendation:**
Extract nested logic into separate methods:
```dart
// Instead of:
void build() {
if (condition) {
if (another) {
if (third) {
// do something
}
}
}
}
// Do:
void build() {
if (condition && another && third) {
_doSomething();
}
}
void _doSomething() {
// logic here
}
```
---
### 3.4 Potential Memory Leak - ImageStream Listeners
**File:** `/home/chris/Ensemble/lib/widgets/expandable_player.dart:199`
**Severity:** Medium
**Category:** Memory Leaks
**Code:**
```dart
// Track active ImageStream listeners for proper cleanup on dispose
final Map<ImageStream, ImageStreamListener> _activeImageStreams = {};
```
**Description:**
The code tracks image streams for cleanup, which is good practice. However, need to verify all listeners are properly removed.
**Recommendation:**
Ensure dispose() method includes:
```dart
_activeImageStreams.forEach((stream, listener) => stream.removeListener(listener));
_activeImageStreams.clear();
```
---
## 4. LOW SEVERITY ISSUES
### 4.1 Ignore Comments in Generated Files
**Files:**
- `/home/chris/Ensemble/lib/l10n/app_localizations_*.dart`
**Severity:** Low
**Category:** Code Quality
**Description:**
Generated localization files have `// ignore: unused_import` comments.
**Recommendation:**
This is acceptable for generated files. No action needed.
---
### 4.2 Debug Print Statements
**File:** `/home/chris/Ensemble/lib/services/debug_logger.dart:77, 104`
**Severity:** Low
**Category:** Security (potential)
**Code:**
```dart
debugPrint(entry.formatted);
debugPrint('Stack trace: $stackTrace');
```
**Description:**
Debug logger outputs to console. Need to verify no sensitive data (tokens, passwords) is logged.
**Recommendation:**
Add sanitization for sensitive data patterns:
- Filter out auth tokens
- Filter out passwords
- Filter out API keys
---
## 5. SECURITY AUDIT
### 5.1 Credential Storage - ✅ SECURE
**File:** `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
**Status:** PASS
- Uses `flutter_secure_storage` with `encryptedSharedPreferences: true`
- Proper error handling for decryption failures
- Migration logic from SharedPreferences to secure storage
- All sensitive data (tokens, passwords) stored securely
### 5.2 Network Security - ✅ GOOD
**Files:**
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
- `/home/chris/Ensemble/lib/services/sendspin_service.dart`
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
**Status:** PASS
- Properly upgrades HTTP to HTTPS where applicable
- WebSocket URL construction is secure
- Token-based authentication
- No hardcoded credentials found
### 5.3 Token Exposure Risk - ⚠️ REVIEW NEEDED
**Files:** Multiple (logging statements)
**Status:** NEEDS REVIEW
- Debug logger may expose tokens in logs
- Recommend adding token sanitization to logger
---
## 6. PERFORMANCE AUDIT
### 6.1 Provider Usage - ✅ GOOD
**Pattern:** Mostly using `context.read<>` correctly
**Status:** PASS
- `context.read<>` used for method calls (correct)
- `context.select<>` used where appropriate (expandable_player.dart:1796)
- No obvious unnecessary rebuilds detected
### 6.2 State Management - ✅ GOOD
**Status:** PASS
- Provider pattern used consistently
- ChangeNotifier properly implemented
- dispose() methods properly cancel timers and close streams
### 6.3 Stream Subscriptions - ✅ GOOD
**Files:** Multiple
**Status:** PASS
- All StreamSubscriptions are properly tracked
- dispose() methods cancel subscriptions
---
## 7. DEPENDENCY AUDIT
### Package Health Check
| Package | Version | Status | Notes |
|---------|---------|--------|-------|
| flutter_secure_storage | 9.2.2 | ✅ Current | Good |
| provider | 6.1.1 | ✅ Current | Good |
| drift | 2.22.0 | ✅ Current | Good |
| just_audio | 0.9.36 | ✅ Current | Good |
| audio_service | 0.18.12 | ⚠️ Check | May have updates |
| cached_network_image | 3.3.1 | ✅ Current | Good |
| web_socket_channel | 2.4.0 | ⚠️ Check | May have updates |
**Recommendation:** Run `flutter pub outdated` to check for available updates.
---
## 8. ARCHITECTURE ASSESSMENT
### 8.1 Strengths
- ✅ Clear separation of concerns (services, providers, screens, widgets)
- ✅ Consistent state management with Provider
- ✅ Proper use of secure storage
- ✅ Comprehensive dispose() cleanup
- ✅ Good error handling in most places
### 8.2 Areas for Improvement
- ⚠️ Main provider file too large (6,180 lines)
- ⚠️ Some duplicate auth logic
- ⚠️ Deep nesting in detail screens
- ⚠️ .then() chains should be async/await
### 8.3 Recommended Refactoring
1. Split `MusicAssistantProvider` into smaller focused providers
2. Consolidate auth token management
3. Reduce nesting in detail screens
4. Convert .then() chains to async/await
5. Add token sanitization to logger
---
## 9. PRIORITIZED REMEDIATION PLAN
### Phase 1: Critical Fixes (1-2 days)
1. Fix empty setState() in debug_log_screen.dart
2. Add disposed flag to SleepTimerProvider
### Phase 2: High Priority (1 week)
1. Convert all .then() chains to async/await
2. Consolidate auth token logic
3. Add token sanitization to debug logger
### Phase 3: Medium Priority (2-3 weeks)
1. Split MusicAssistantProvider into smaller providers
2. Reduce nesting in detail screens
3. Verify all timers are properly cancelled
4. Update outdated dependencies
### Phase 4: Low Priority (Ongoing)
1. Implement TODO features (playlist modification, etc.)
2. Add unit tests for critical paths
3. Set up dependency update automation
---
## 10. FILES REQUIRING IMMEDIATE ATTENTION
| File | Issue | Severity | Lines |
|------|-------|----------|-------|
| music_assistant_provider.dart | Too large | Critical | 6180 |
| sleep_timer_provider.dart:76 | No mounted check | High | 76-77 |
| debug_log_screen.dart:367 | Empty setState | Critical | 367 |
| playlist_details_screen.dart:598 | .then() chain | High | 598 |
---
## 11. CONCLUSION
The Ensemble codebase is **well-structured and secure** with good practices for:
- Secure credential storage
- Proper resource cleanup
- State management
The main areas for improvement are:
1. File size (need to split the main provider)
2. Code modernization (async/await instead of .then())
3. Some timer safety checks
Overall, this is a **healthy codebase** that would benefit from focused refactoring rather than major restructuring.
---
**Report End**
+208
View File
@@ -0,0 +1,208 @@
# Multi-Provider Split Plan
## Current Status (2026-02-07)
### Completed
| Provider | Lines | File | Status |
|----------|-------|------|--------|
| ConnectionProvider | 245 | lib/providers/connection_provider.dart | ✅ Created |
| UIStateProvider | 167 | lib/providers/ui_state_provider.dart | ✅ Created & Committed |
| LibraryProvider | 290 | lib/providers/library_provider.dart | ✅ Created & Committed |
| PlayerProvider | 589 | lib/providers/player_provider.dart | ✅ Created & Integrated |
| LocalPlayerProvider | 370 | lib/providers/local_player_provider.dart | ✅ Created & Integrated |
**Main Provider: 5928 lines** (down from 6180 original)
### Integration Status
✅ **Phase 1 Complete**: All 5 providers created
✅ **Phase 2 Started**: Integration into main.dart completed
- All providers added to MultiProvider tree
- Shared services (CacheService, PositionTracker, ImageHelperService) properly initialized
- Build successful (52.2MB APK)
### Remaining Work
| Task | Complexity | Notes |
|------|------------|-------|
| Update MusicAssistantProvider facade | Medium | Accept providers in constructor, delegate methods |
| Migrate consumers incrementally | High | Start with less critical screens |
| Remove facade when complete | Low | After all consumers migrated |
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ MusicAssistantProvider │
│ (Facade - during transition) │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Delegates to sub-providers: │ │
│ │ • ConnectionProvider - connect/disconnect, auth │ │
│ │ • UIStateProvider - search, filters, loading state │ │
│ │ • LibraryProvider - artists, albums, tracks, podcasts │ │
│ │ • PlayerProvider - player selection, controls, queue (TODO) │ │
│ │ • LocalPlayerProvider - local audio engine (TODO) │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Next Steps
### Phase 1: Complete Provider Creation (Current)
1. **Create PlayerProvider** (~800 lines)
- Extract player selection and switching logic
- Extract play/pause/stop/seek controls
- Extract queue management
- Extract sleep timer functionality
2. **Create LocalPlayerProvider** (~500 lines)
- Extract local audio playback engine
- Extract Sendspin integration
- Extract player registration
- Extract volume and power control
### Phase 2: Integration
3. **Update main.dart**
- Add all providers to MultiProvider
- Keep MusicAssistantProvider as facade initially
4. **Migrate one consumer at a time**
- Start with less critical screens
- Test thoroughly after each migration
- Use facade to maintain compatibility during transition
5. **Remove facade**
- Once all consumers migrated
- Remove MusicAssistantProvider
- Direct widget-provider relationships
---
## PlayerProvider Extraction Plan
### State to Extract
```dart
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Map<String, String> _castToSendspinIdMap = {};
// Current playback
Track? _currentTrack;
Audiobook? _currentAudiobook;
String? _currentPodcastName;
// Sleep timer
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes;
Timer? _sleepTimerDisplayTimer;
// Timers
Timer? _playerStateTimer;
Timer? _notificationPositionTimer;
Timer? _idleServiceTimer;
// Position tracking
PositionTracker _positionTracker;
```
### Methods to Extract
```dart
// Player selection
Future<void> _loadAndSelectPlayers({bool forceRefresh, bool coldStart});
Future<void> selectPlayer(Player player, {bool? sendToSendspin});
// Playback controls
Future<void> playPause();
Future<void> stop();
Future<void> nextTrack();
Future<void> previousTrack();
Future<void> seek(Duration position);
Future<void> setVolume(String playerId, int volumeLevel);
// Queue management
Future<PlayerQueue?> getQueue(String playerId);
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex});
// Sleep timer
Future<void> setSleepTimer(int minutes);
Future<void> cancelSleepTimer();
```
### Dependencies to Inject
- MusicAssistantAPI
- DatabaseService
- SettingsService
- QueueManagerService
- PositionTracker
- PlayerSyncState
- AudioHandler (from audio_service)
---
## Estimated Timeline
| Task | Time | Notes |
|------|------|-------|
| Create PlayerProvider | 2-3 hours | Complex, many dependencies |
| Create LocalPlayerProvider | 2-3 hours | Sendspin integration is complex |
| Integration & Testing | 3-4 hours | Update main.dart, migrate consumers |
| Bug Fixes | 1-2 hours | Edge cases in player switching |
**Total: ~8-12 hours of focused development**
---
## Risk Mitigation
1. **Keep facade during transition** - Maintain backward compatibility
2. **Migrate incrementally** - One screen/consumer at a time
3. **Test on device** - Test player controls thoroughly
4. **Commit frequently** - Each provider as it's completed
5. **Keep backup** - Can revert to monolithic provider if needed
---
## Success Criteria
- [ ] All 5 providers created and building
- [ ] Main.dart updated with all providers
- [ ] At least 3 screens migrated to new providers
- [ ] Player controls working correctly on device
- [ ] No regressions in core functionality
- [ ] Main provider reduced to < 1000 lines (or removed)
---
## Files Created So Far
```
lib/providers/
├── connection_provider.dart (245 lines) ✅
├── ui_state_provider.dart (167 lines) ✅
├── library_provider.dart (290 lines) ✅
├── player_provider.dart (TODO) ⏳
└── local_player_provider.dart (TODO) ⏳
```
---
## Notes from Analysis
1. **Player selection logic is complex** - Involves Cast-to-Sendspin mapping, filtering, smart sorting
2. **Sendspin integration is tricky** - Need to handle both MA 2.7.0+ (Sendspin) and older versions
3. **Queue management already extracted** - QueueManagerService handles most queue logic
4. **Position tracking is separate** - PositionTracker service handles interpolation
5. **Event handlers need coordination** - Player events update both provider and position tracker
+12 -15
View File
@@ -1,6 +1,11 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/github/v/release/CollotsSpot/Ensemble?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
---
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
@@ -17,6 +22,12 @@ This application was built with AI-assisted development using **Claude Code** an
---
## Also See
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
---
## Features
### Local Playback
@@ -127,25 +138,11 @@ To find the correct URL:
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
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
## Requirements
+41
View File
@@ -2,6 +2,33 @@
## v2.9.2-beta (TBD)
### New Features
#### Context Menus
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
#### Podcasts
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
#### Audio Quality
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
### Improvements
#### UI/UX
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
- **Search library filter**: Filled icon indicates active filter state without background highlight.
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
- **Navigation transitions**: Smoother fade/slide animations between screens.
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
#### Performance
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
### Bug Fixes
#### Audiobooks
@@ -29,9 +56,23 @@
#### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
#### Playback
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
#### UI Fixes
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
- **Fixed library tab scroll position**: Maintains position when switching tabs.
### Technical Changes
- Flutter 3.38 compatibility
- Simplified authentication using MA built-in auth
- Added `material_symbols_icons` package for book_2 icon
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
+8 -3
View File
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace "com.musicassistant.music_assistant"
compileSdk 35
namespace "com.collotsspot.ensemble"
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
@@ -58,7 +58,7 @@ android {
}
defaultConfig {
applicationId "com.musicassistant.music_assistant"
applicationId "com.collotsspot.ensemble"
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
@@ -70,6 +70,11 @@ android {
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
flutter {
+6
View File
@@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -1,4 +1,4 @@
package com.musicassistant.music_assistant
package com.collotsspot.ensemble
import android.util.Log
import android.view.KeyEvent
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
+1 -1
View File
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.0'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ 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
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
+242
View File
@@ -0,0 +1,242 @@
# Discovery Rows Submenu - Implementation Plan
## Option: Hybrid with Discovery Badge
### Overview
Transform the "Discovery Mixes" toggle into a section that:
1. Shows individual discovery row toggles
2. Adds those rows to the main home row list with visual distinction
3. Allows full reordering alongside other rows
---
## UI Design
### Settings Screen - Discovery Section
```
┌─────────────────────────────────────────────┐
│ ⚬ Discovery Mixes ● │
│ Show discovery-based rows on home screen │
│ ──────────────────────────────────────── │
│ Discovery rows from Music Assistant: │
│ │
│ ☑ New Releases ● │
│ ☑ Recently Played ● │
│ ☐ Recommended Albums ○ │
│ ☐ For You ○ │
│ │
│ [Refresh available rows] │
└─────────────────────────────────────────────┘
```
### Main Home Row List - With Badge
Discovery rows appear interleaved with a badge:
```
┌─────────────────────────────────────────────┐
│ ⋮ ☑ Recent Albums ● │
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
│ ⋮ ☑ Discover Artists ● │
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
│ ⋮ ☑ Favorite Albums ● │
└─────────────────────────────────────────────┘
```
Badge style: Small chip/icon indicating this is a dynamic discovery row.
---
## Technical Changes
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
**Add state for discovery folders:**
```dart
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
bool _isDiscoveryFoldersExpanded = false;
```
**New methods:**
```dart
Future<void> _loadDiscoveryFolders() async { }
Future<void> _refreshDiscoveryFolders() async { }
void _toggleDiscoveryRow(String itemId, bool enabled) { }
void _toggleDiscoveryMixes(bool enabled) { }
Widget _buildDiscoverySection() { }
```
**Modify existing:**
- `_loadSettings()` - load discovery row preferences
- Home row list builder - filter out individual discovery rows
- Add discovery section before home row list
### 2. Settings Service (`lib/services/settings_service.dart`)
**New methods:**
```dart
static Future<bool> getShowDiscoveryFolders() async { }
static Future<void> setShowDiscoveryFolders(bool value) async { }
// Per-row discovery preferences
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
```
**Storage key pattern:**
- `discovery_folders_enabled` (bool)
- `discovery_row_{itemId}` (bool per row)
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
**Already has:** `getDiscoveryFoldersWithCache()`
**Add method:**
```dart
Future<void> refreshDiscoveryFolders() async {
// Clear cache and refetch from API
_cacheService.clearDiscoveryFoldersCache();
await getDiscoveryFoldersWithCache();
}
```
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
**Changes to row rendering:**
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
```dart
// Before: static list of row IDs
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
// After: can include discovery row IDs
List<String> _homeRowOrder = [
'recent-albums',
'discovery:abc123', // Discovery row ID
'discover-artists',
'discovery:def456',
...
];
```
**New method:**
```dart
bool _isDiscoveryRowId(String rowId) {
return rowId.startsWith('discovery:');
}
String _getDiscoveryRowItemId(String rowId) {
return rowId.substring('discovery:'.length);
}
```
**Modify row rendering:**
- Check if rowId is a discovery row
- If yes, render DiscoveryRow widget with that folder
- Cache discovery folders for quick lookup
### 5. Localization (`lib/l10n/app_en.arb`)
**Add strings:**
```json
{
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
"discoveryRows": "Discovery rows from Music Assistant",
"refreshDiscoveryRows": "Refresh available rows",
"noDiscoveryRows": "No discovery rows available",
"discoveryRowBadge": "Music Assistant"
}
```
### 6. Models (`lib/models/recommendation_folder.dart`)
**Add property:**
```dart
class RecommendationFolder {
...
/// Unique ID for use in row ordering and settings
String get rowId => 'discovery:$itemId';
}
```
---
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Settings Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load discovery folders from Provider │
│ 2. Load per-row enabled state from Settings │
│ 3. Render section with toggles │
│ 4. User toggles row → save to Settings │
│ 5. User toggles main → save to Settings + update home row list │
│ 6. User refreshes → Provider.fetch → update UI │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Home Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load home row order from Settings │
│ 2. Identify discovery row IDs (prefix "discovery:") │
│ 3. For discovery rows: look up folder from Provider cache │
│ 4. Render appropriate widget (DiscoveryRow or other) │
│ 5. Handle reordering including discovery rows │
└─────────────────────────────────────────────────────────────────┘
```
---
## Migration Path
### Phase 1: Settings Screen (No breaking changes)
- Add discovery section to settings
- Load and display discovery folders
- Individual row toggles (but don't affect home screen yet)
- Refresh button
### Phase 2: Home Screen Integration
- Modify home row order to support discovery row IDs
- Render discovery rows in main list
- Add badge visual distinction
### Phase 3: Polish
- Translations
- Edge cases (folder disappears from MA)
- Performance (lazy loading)
---
## Edge Cases & Considerations
| Issue | Solution |
|-------|----------|
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
| User reorders then MA returns new folders | New folders appended to end of discovery section |
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
---
## Implementation Checklist
- [ ] Settings: Add `_discoveryFolders` state
- [ ] Settings: Add `_discoveryRowEnabled` map
- [ ] Settings: Implement `_loadDiscoveryFolders()`
- [ ] Settings: Implement `_buildDiscoverySection()`
- [ ] Settings: Add refresh button handler
- [ ] SettingsService: Add discovery row preference methods
- [ ] Provider: Add `refreshDiscoveryFolders()`
- [ ] HomeScreen: Support discovery row IDs in order list
- [ ] HomeScreen: Render discovery rows with badge
- [ ] Localization: Add new strings
- [ ] Test: Toggle discovery rows on/off
- [ ] Test: Reorder discovery rows
- [ ] Test: Refresh discovery folders
- [ ] Test: Migrate existing preference to new system
@@ -0,0 +1,328 @@
# Remote Connection - Future Implementation Guide
## Overview
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
## Current Status: On Hold
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
---
## Architecture
### How It Works
```
┌─────────────────┐ WebSocket ┌──────────────────────────┐
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
└─────────────────┘ └──────────────────────────┘
│ │
│ (when local unavailable) │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
└─────────────────┘ │ .io/ws │
│ └──────────────────────────┘
│ ICE candidates, SDP │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
│ Connection │ (ma-api) │ Endpoint │
└─────────────────┘ └──────────────────────────┘
```
### Components
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
### Data Channels
| Channel | Purpose | Traffic Pattern |
|---------|---------|-----------------|
| `ma-api` | JSON API requests/responses | High volume, large payloads |
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
---
## Key Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
### Files to Modify
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
---
## Implementation Steps
### Step 1: Add Dependencies
```yaml
# pubspec.yaml
dependencies:
flutter_webrtc: ^0.12.0 # or latest
```
### Step 2: Implement SignalingClient
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
```dart
class SignalingClient {
WebSocket? _socket;
Future<void> connect(String serverId) async {
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
// Send connect request
_send({
'type': 'connect-request',
'server_id': serverId,
});
// Listen for messages
_socket!.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final message = jsonDecode(data);
switch (message['type']) {
case 'answer':
onAnswer?.call(message['sdp']);
break;
case 'ice-candidate':
onIceCandidate?.call(message['candidate']);
break;
}
}
}
```
### Step 3: Implement WebRTCConnection
```dart
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _apiChannel;
RTCDataChannel? _sendspinChannel;
Future<void> connect(SignalingClient signaling) async {
// Create peer connection with STUN/TURN servers
_peerConnection = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
// TURN servers provided by signaling
],
});
// Create data channels BEFORE creating offer
_apiChannel = await _peerConnection!.createDataChannel(
'ma-api',
RTCDataChannelInit()..ordered = true,
);
_sendspinChannel = await _peerConnection!.createDataChannel(
'sendspin',
RTCDataChannelInit()..ordered = true,
);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
signaling.sendOffer(offer.sdp!);
// Handle answer from server
signaling.onAnswer = (sdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
};
}
}
```
### Step 4: Implement RemoteBridge
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
```dart
class RemoteBridge {
final WebRTCConnection _webrtc;
final StreamController<String> _messageController;
void sendCommand(String json) {
_webrtc.sendToApiChannel(json);
}
Stream<String> get messages => _messageController.stream;
}
```
### Step 5: Integrate with MusicAssistantAPI
```dart
class MusicAssistantAPI {
WebSocket? _localSocket;
RemoteBridge? _remoteBridge;
Future<void> connect(String host, {String? serverId}) async {
try {
// Try local connection first
_localSocket = await WebSocket.connect('ws://$host/ws');
} catch (e) {
// Fall back to remote if serverId provided
if (serverId != null) {
_remoteBridge = RemoteBridge();
await _remoteBridge!.connect(serverId);
}
}
}
}
```
---
## Critical Implementation Details
### 1. Channel Creation Order
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
- `ma-api` - for API traffic
- `sendspin` - for audio streaming (optional)
### 2. Health Monitoring
Implement aggressive health checks because data channels can silently die:
```dart
// Track message counts
int _sentCount = 0;
int _recvCount = 0;
DateTime _lastRecvTime = DateTime.now();
// Health check every 10 seconds
void _healthCheck() {
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
if (timeSinceRecv.inSeconds > 30) {
// Channel is dead, trigger reconnection
_reconnect();
}
}
```
### 3. Keepalive Messages
Send periodic keepalive on the data channel itself (not just signaling):
```dart
void _sendKeepalive() {
_apiChannel?.send(RTCDataChannelMessage(
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
));
}
```
### 4. Flow Control
Monitor buffer to prevent overflow:
```dart
void sendMessage(String json) {
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
// Queue message, don't send
_sendQueue.add(json);
return;
}
_apiChannel!.send(RTCDataChannelMessage(json));
}
```
---
## Known Issues & Pitfalls
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
**Workaround options:**
- Detect via health check and force reconnection
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
- Report to MA team and wait for server-side fix
### 2. flutter_webrtc Quirks
- `RTCDataChannel.state` may show "open" when actually dead
- `bufferedAmount` accuracy varies by platform
- `onBufferedAmountLow` callback support inconsistent
### 3. Android Background Throttling
Android aggressively throttles background WebRTC. Consider:
- Foreground service for sustained connections
- Reconnection on app resume
### 4. ICE/TURN Issues
- aiortc (server-side) has known TURN + DataChannel bugs
- May need ICE restart on network changes
---
## Testing Checklist
- [ ] Connect via remote when local unavailable
- [ ] Verify API commands work (players/all, library queries)
- [ ] Test sustained connection (30+ minutes)
- [ ] Test app backgrounding and resume
- [ ] Test network transitions (WiFi ↔ cellular)
- [ ] Verify reconnection after channel death
- [ ] Test sendspin audio streaming (if implemented)
---
## Useful Resources
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
- Signaling server: `wss://signaling.music-assistant.io/ws`
---
## Files in This Directory
| File | Description |
|------|-------------|
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
| `WEBRTC_PROGRESS.md` | Progress log |
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
| `webrtc-failure-logs.txt` | Raw failure logs |
| `console-export-*.log` | Browser console exports |
---
## Quick Start for Future Implementation
1. Read this guide fully
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
3. Check if MA team has fixed server-side issues
4. Start with basic implementation, add health monitoring early
5. Test extensively before shipping - the failure mode is subtle
@@ -0,0 +1,127 @@
# WebRTC Data Channel Failure on Android - Bug Report
## Summary
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
## Environment
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
## Reproduction Steps
1. Connect to MA server via WebRTC remote access (not local network)
2. Use the app normally - browse library, view players
3. After 1-3 minutes, the connection degrades
4. Commands time out, but signaling ping/pong continues working
## Observed Behavior
### Timeline from logs:
```
07:14:20 - Working normally
Health: API sent:51 recv:54 (balanced)
07:15:57 - Receive stops
Health: API sent:97 recv:97, lastRecv:12s ago
07:16:02 - Timeouts begin
"TimeoutException: Command timeout: players/all"
07:16:17 - Sendspin still works!
"Sendspin: Received server/time response"
Health: API sent:114 recv:97, lastRecv:27s ago
07:17:30 - Forced reconnect
Health: API sent:137 recv:97, lastRecv:42s ago
"Health check detected stale connection"
```
### Key Observation
| Channel | Traffic Pattern | Status |
|---------|-----------------|--------|
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
## Testing Performed
### Test 1: flutter_webrtc (Native WebRTC)
- **Result**: ma-api channel dies after 1-3 minutes
- **Sendspin**: Continues working
### Test 2: WebView-based WebRTC (Browser engine)
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
- **Conclusion**: Not a flutter_webrtc bug
### Test 3: Various Data Channel Settings
- Tried `ordered: false` with `maxRetransmits: 3`
- Tried letting server create channels (failed - server expects client to create)
- No improvement
## Analysis
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
1. **Server-side** - Different handling of ma-api vs sendspin channels
2. **TURN relay** - High-traffic channel may hit limits
3. **Not client-specific** - Both implementations fail identically
### Questions for MA Team
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
3. Does the MA Web UI use different data channel configurations?
4. Are there known issues with the TURN relay for sustained high-traffic channels?
## Comparison: MA Web UI vs Android App
| Aspect | MA Web UI | Android App |
|--------|-----------|-------------|
| Platform | Browser tab (visible, active) | Background-capable app |
| WebRTC | Browser native | flutter_webrtc / WebView |
| Result | Works | Fails after 1-3 min |
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
## Logs
### Health Check Pattern (shows channel death)
```
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
```
### Sendspin Still Working During Failure
```
07:16:17 Sendspin: Received server/time response <- Still alive!
07:16:47 Sendspin: Received server/time response <- Still alive!
```
## Requested Information
1. Data channel creation code/settings for both channels
2. Any server-side differences in channel handling
3. TURN server configuration details
4. Whether this has been reported by other Android/mobile clients
## Workaround
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
---
**Reporter**: Ensemble Android App Developer
**Date**: 2026-01-24
@@ -0,0 +1,149 @@
# Plan B: Single Data Channel Multiplexing
## Why This Might Be Needed
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
Evidence:
- MA Web UI works fine with local playback (same server, browser WebRTC)
- Our app uses flutter_webrtc which has known issues:
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
- Pattern matches: second channel survives while first dies
## Current Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├────────────────────┬────────────────────────────┤
│ ma-api channel │ sendspin channel │
│ (JSON API) │ (JSON + binary audio) │
│ SCTP stream 0 │ SCTP stream 1 │
└────────────────────┴────────────────────────────┘
```
## Proposed Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├─────────────────────────────────────────────────┤
│ unified channel │
│ (multiplexed: API + Sendspin text + binary) │
│ SCTP stream 0 │
└─────────────────────────────────────────────────┘
```
## Multiplexing Protocol
### Message Framing
Each message has a 1-byte type prefix:
| Prefix | Type | Content |
|--------|------|---------|
| 0x00 | API | JSON text (MA API request/response) |
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
### Implementation
#### Sending (Client → Server)
```dart
void sendApiMessage(String json) {
final prefix = Uint8List.fromList([0x00]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x00;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinText(String json) {
final prefix = Uint8List.fromList([0x01]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x01;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinBinary(Uint8List audio) {
final message = Uint8List(1 + audio.length);
message[0] = 0x02;
message.setRange(1, message.length, audio);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
```
#### Receiving (Server → Client)
```dart
void _onMessage(RTCDataChannelMessage message) {
final data = message.binary;
if (data == null || data.isEmpty) return;
final type = data[0];
final payload = data.sublist(1);
switch (type) {
case 0x00: // API
final json = utf8.decode(payload);
_handleApiMessage(json);
break;
case 0x01: // Sendspin text
final json = utf8.decode(payload);
_handleSendspinText(json);
break;
case 0x02: // Sendspin binary
_handleSendspinBinary(payload);
break;
}
}
```
## Server-Side Changes Required
The Music Assistant server would need to:
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
2. Multiplex responses using the same framing protocol
3. This requires changes to the MA remote connection code
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
## Client-Only Approach (No Server Changes)
If we can't change the server, we could:
1. Still create two channels to the server (ma-api + sendspin)
2. But internally route them through a single "virtual" channel for flutter_webrtc
3. This is more complex and may not avoid the underlying bug
## Files to Modify
| File | Changes |
|------|---------|
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
| `remote_bridge.dart` | Route messages based on type prefix |
## Risks
1. **Server compatibility**: Requires MA server changes or complex client proxy
2. **Performance**: Extra byte per message, decode overhead
3. **Binary handling**: Must handle all messages as binary (not text)
## Testing Plan
1. Implement client-side changes
2. Test with modified server (if available) or mock
3. Verify both API and Sendspin traffic flows correctly
4. Long-duration stability test
## Decision Point
Try this approach if:
- flutter_webrtc 0.14.x doesn't fix the issue
- The one-way failure pattern continues
- We've confirmed the issue is multi-channel specific
@@ -0,0 +1,406 @@
# WebRTC Data Channel Disconnect Fixes
## Document Purpose
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
---
## Problem Summary
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
2. **Silent disconnect**: No error events fire, channel just stops working
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
---
## Key Files
| File | Purpose |
|------|---------|
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
---
## Implemented Fixes (Chronological)
### Fix 1: Data Channel Keepalive (Original)
**Problem**: Data channel could silently die without any error events firing.
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
**Code** (`webrtc_connection.dart`):
- `_keepaliveTimer`: Runs every 10 seconds
- `_checkKeepalive()`: Checks time since last data received
- `_sendKeepalivePing()`: Sends JSON ping message
- `_recordDataReceived()`: Called on every message receipt
**Constants**:
```dart
static const _keepaliveInterval = Duration(seconds: 10);
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
static const _maxKeepaliveMisses = 2; // Reduced from 3
```
### Fix 2: ICE Recovery
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
**Solution**: Added ICE recovery timer before declaring failure.
**Code** (`webrtc_connection.dart`):
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
### Fix 3: Flow Control (Buffer Overflow Prevention)
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
**Solution**: Added send-side flow control with buffering.
**Code** (`webrtc_connection.dart`):
```dart
static const _maxBufferedAmount = 1048576; // 1MB threshold
static const _bufferLowThreshold = 65536; // 64KB resume threshold
bool _sendPaused = false;
final _sendQueue = <String>[];
```
**Modified `sendRaw()`**:
- Checks `bufferedAmount` before sending
- Queues messages if buffer > 1MB
- `onBufferedAmountLow` callback drains queue
### Fix 4: Health Check Stale Detection
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
**Solution**: Reduced stale detection threshold from 45s to 30s.
**Code** (`remote_bridge.dart`):
```dart
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
// Force reconnection
}
```
### Fix 5: Reduced Keepalive Timeouts
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
### Fix 6: Recv Stagnation Detection
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
**Code** (`remote_bridge.dart`):
```dart
// Track across health checks
int _lastHealthCheckRecvCount = 0;
int _lastHealthCheckSentCount = 0;
int _recvStagnationCount = 0;
// In health check:
if (recvStuck && sentIncreased) {
_recvStagnationCount++;
if (_recvStagnationCount >= 2) {
// Force reconnection - channel is one-way
}
}
```
**Detection time**: ~20 seconds (2 × 10s health check interval)
---
## Log Patterns to Watch For
### Healthy Connection
```
Health check - API sent:93 recv:88 ... lastRecv:0s ago
```
- `sent` and `recv` both increasing
- `lastRecv` low (< 5s typically)
### One-Way Failure (CRITICAL)
```
Health check - API sent:206 recv:163 ... lastRecv:30s ago
Health check - API sent:218 recv:163 ... lastRecv:40s ago
```
- `sent` increases, `recv` STUCK
- `lastRecv` keeps increasing
- Data channel still shows "open"
### Signaling Disconnect
```
WebRTC: Signaling disconnected
... (6-16 seconds later) ...
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
WebRTC: ICE connection state: RTCIceConnectionStateFailed
```
### Network Failure (DNS)
```
Signaling: WebSocket closed
... (reconnection attempt) ...
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
```
This indicates the phone lost network connectivity entirely.
### Reconnection Storm (Signaling Instability)
```
Signaling: Connecting to wss://signaling.music-assistant.io/ws
Signaling: Sending connect-request
Signaling: WebSocket closed ← Closes within 100-500ms
WebRTC: Signaling disconnected
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
```
Rapid reconnection loop - signaling never stays connected long enough.
### Flow Control Engaged
```
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
```
### Keepalive Detection
```
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
WebRTC: ❌ Data channel appears dead - triggering reconnection
```
---
## Known Issues / Limitations
### 1. flutter_webrtc Limitations
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
- `bufferedAmount` may not be accurate on all platforms
- `onBufferedAmountLow` callback support varies
### 2. SCTP Receive Window
- If server sends faster than client processes, receive window fills
- This causes server-side to stop sending, client sees no data
- No way to monitor receive-side buffer from client
### 3. TURN Server Issues
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
- Data channel may fail silently when using TURN relay
### 4. Network Transitions
- WiFi → Cellular or vice versa can cause ICE to fail
- App backgrounding on mobile can close WebSocket/WebRTC
---
## Root Cause Categories
### Category A: Network Issues (Not Code Bugs)
These are environmental issues, not bugs in our code:
- **DNS failure**: `Failed host lookup` - Phone lost internet
- **WiFi/Cellular switch**: Network changed, connections dropped
- **Server unreachable**: Signaling server down or blocked
- **Firewall/NAT issues**: TURN relay needed but unavailable
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
### Category B: WebRTC/SCTP Issues (Partial Code Control)
These can be mitigated but not fully prevented:
- **One-way channel death**: Channel appears open but data stops flowing
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
- **ICE failure**: Network path lost, no recovery possible
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
### Category C: Code Bugs (Fixable)
Issues in our implementation:
- **Detection too slow**: Timeouts too long, misses too many
- **Recovery not triggered**: State checks wrong, reconnection not started
- **Resource leaks**: Timers not cancelled, connections not cleaned up
**Evidence**: Logs show problem but no action taken, or wrong action taken.
---
## Debugging Checklist
When investigating a disconnect:
1. **Find the last successful response**:
```
grep "WebRTC API → WS" logs.txt | head -5
```
2. **Check health check progression**:
```
grep "Health check" logs.txt
```
Look for `recv` getting stuck while `sent` increases.
3. **Check for signaling issues**:
```
grep -i "signaling" logs.txt
```
4. **Check ICE state changes**:
```
grep "ICE connection state" logs.txt
```
5. **Check for keepalive detection**:
```
grep -i "keepalive" logs.txt
```
6. **Check buffer status**:
```
grep -E "(buffer|queue|paused)" logs.txt
```
---
## Potential Future Improvements
### 1. Server-Side Backpressure
MA protocol doesn't have explicit backpressure. Could add:
- Client-side request throttling
- Application-level ACKs for critical messages
### 2. More Aggressive Detection
- Track recv count across health checks, detect stagnation
- Shorter keepalive interval (5s instead of 10s)
### 3. Proactive Health Signals
- Monitor `bufferedAmount` trend (increasing = problem)
- Track round-trip latency of keepalive pings
### 4. Connection Quality Metrics
- Expose packet loss, jitter from WebRTC stats
- Use to predict imminent failure
---
## Test Scenarios
| Scenario | How to Test | Expected Behavior |
|----------|-------------|-------------------|
| Normal usage | Browse library, play music | No warnings |
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
| Long session | Leave connected 2+ hours | Should stay connected |
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
| Network change | WiFi → Cellular | Should detect and reconnect |
| App background | Background for 5+ min | May disconnect, should reconnect |
---
## Observed Failure Patterns
### Pattern 1: One-Way Death After Library Sync
**Observed**: 2026-01-23 (multiple times)
```
17:13:06.175 - Last response (playlists sync)
17:13:06.264 - Local batch save
-- 5 seconds silence --
17:13:11.005 - Send players/all → NO RESPONSE
17:13:13.914 - ICE state: completed (looks healthy!)
17:13:28 - Keepalive detects timeout
17:13:38 - Reconnection triggered
```
**Characteristics**:
- Often happens after library sync (large batch responses)
- ICE shows "completed" even though data channel is dead
- Signaling ping/pong continues working
- Only data channel receive is affected
**Hypothesis**:
- Server-side SCTP buffer exhausted from sending large responses?
- Client receive window full, server blocks?
- flutter_webrtc onMessage callback stops firing?
### Pattern 2: Reconnection Storm / Signaling Instability
**Observed**: 2026-01-23 17:30-17:31
```
17:31:05.093 - Signaling: Connecting
17:31:05.216 - Signaling: Sending connect-request
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
17:31:05.218 - WebRTC: Signaling disconnected
```
**Characteristics**:
- Signaling WebSocket closes almost immediately after connect
- Multiple rapid reconnection attempts (storm)
- Connection never stable long enough for data flow
- Often happens after network instability
- Artist detail, albums etc don't load because connection keeps failing
**Possible Causes**:
- Network instability (WiFi/cellular issues)
- Signaling server rate limiting
- DNS resolution failures
- Server rejecting rapid reconnection attempts
**Side Effects**:
- UI shows errors like "Not connected to Music Assistant server"
- Artist/album detail screens fail to load
- Players list empty or stale
### Pattern 3: API Channel Dies While Sendspin Lives
**Observed**: 2026-01-23 17:53
```
17:53:35.755 - Send album_tracks request → NO RESPONSE
17:53:40.596 - Health check: recv stuck at 197, sent increased
17:53:55.204 - Sendspin channel RECEIVES server/time response!
17:54:02.790 - Keepalive detects dead API channel
```
**Characteristics**:
- API data channel (ma-api) stops receiving
- Sendspin data channel continues working normally
- Same WebRTC peer connection, different data channels
- This proves it's NOT a network issue - Sendspin traffic flows fine
**Hypothesis**:
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
- Possible memory/GC issue with the callback
- Server-side might be sending to wrong channel?
**Key Evidence**:
- Recv count stays at 197 for API
- Sendspin recv count increases (7→8)
- Both channels use same peer connection
---
## Version History
| Date | Build | Changes |
|------|-------|---------|
| 2026-01-23 | ~v49 | Initial flow control implementation |
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
---
## References
- flutter_webrtc Issue #843: Data channel state unreliable
- aiortc Issue #1156: TURN + DataChannel bugs
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
- Music Assistant remote protocol: No explicit flow control
+118
View File
@@ -0,0 +1,118 @@
# WebRTC Remote Connection Progress
## Problem Statement
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
## Approaches Tried
### 1. Original Implementation (flutter_webrtc)
- **Status**: Fails
- **Issue**: ma-api data channel goes one-way after some time
- **Files**: `lib/services/remote/webrtc_connection.dart`
### 2. WebView-based WebRTC Implementation
- **Status**: Same failure mode
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
- **Result**: Same one-way channel death occurs
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
## Key Observations
From the logs:
```
lastRecv:26s ago <- No messages received in 26 seconds
API sent:674 recv:618 <- Gap growing, responses stopped
```
- Signaling ping/pong continues working
- Sendspin channel continues receiving (`server/time` responses)
- Only ma-api channel becomes one-way
- WebRTC peer connection itself remains "connected"
## Differences from Working MA Web UI
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
1. flutter_webrtc: Native WebRTC in background app
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
Android aggressively throttles hidden WebViews to save battery.
## Current Implementation (WebView)
### Files Created
| File | Purpose |
|------|---------|
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
### Files Modified
| File | Changes |
|------|---------|
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
| `lib/main.dart` | Adds WebView to widget tree |
### Architecture
```
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
WebViewWebRTCConnection
|
Hidden InAppWebView
|
JavaScript WebRTC Engine
|
MA Server via TURN
```
## Current Fixes Being Tested
### 1. Prevent WebView Throttling
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
- Added various InAppWebViewSettings to prevent background throttling
### 2. Data Channel Settings
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
- Hypothesis: Ordered delivery may cause buffer bloat issues
### 3. Buffer Monitoring
- Added `bufferedAmount` checking in JavaScript
- Logs warning when buffer exceeds 1MB
- Added `getChannelHealth()` function for diagnostics
### 4. Reduced Logging
- Removed per-message logging (was filling logs too fast)
- Keep only state changes, errors, and warnings
## Experiments Completed
### 5. Server-Created Channels (Failed)
- **Hypothesis**: Let server create data channels to avoid ID collisions
- **Result**: Connection timeout - server expects CLIENT to create channels
- **Learning**: Client must create channels before sending offer
## Next Steps to Try
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
2. **Single data channel** - Use only ma-api, disable sendspin
3. **Periodic channel recreation** - Detect stale channel and recreate
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
## Test APK Location
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
## Useful Debug Commands
Check channel health (in JS console or via Dart):
```javascript
getChannelHealth()
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
```
## Open Questions
1. Why does sendspin channel survive when ma-api dies?
2. Is there a difference in how MA server handles the two channels?
3. Does MA Web UI use different data channel settings?
4. Is there a TURN relay issue specific to high-traffic channels?
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
New package ID (com.collotsspot.ensemble) - reinstall required.
Prepares for F-Droid and Play Store submission.
@@ -0,0 +1 @@
Add fastlane metadata for F-Droid.
@@ -0,0 +1 @@
Disable dependency metadata in APK for F-Droid compliance.
@@ -0,0 +1,13 @@
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
Features:
* Stream music directly to your phone with background playback
* Control any speaker or device connected to Music Assistant
* Multi-room grouping capabilities
* Queue management with drag-to-reorder
* Background playback with media notifications
* Fast ~300ms response for play/pause controls
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

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.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

@@ -0,0 +1 @@
Unofficial mobile client for Music Assistant
@@ -0,0 +1 @@
Ensemble
+6
View File
@@ -32,6 +32,12 @@ class HeroTags {
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
+62
View File
@@ -8,6 +8,9 @@ class Timings {
/// Player state polling interval (for selected player updates)
static const Duration playerPollingInterval = Duration(seconds: 5);
/// Notification position update interval (for updating playback position in notification)
static const Duration notificationPositionUpdate = Duration(seconds: 1);
/// Local player state report interval
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
@@ -54,3 +57,62 @@ class LibraryConstants {
/// Default limit for recent/random items
static const int defaultRecentLimit = 10;
}
/// Image/artwork size constants
class ImageSizes {
ImageSizes._();
/// Default image size for artwork (256x256)
static const int defaultSize = 256;
/// High resolution image size for player/overlay (512x512)
static const int highRes = 512;
/// Maximum image size for large displays
static const int max = 1024;
}
/// Volume control constants
class VolumeConstants {
VolumeConstants._();
/// Minimum volume level
static const int min = 0;
/// Maximum volume level
static const int max = 100;
/// Volume range for validation (0-100)
static const int range = 100;
}
/// Player state constants
class PlayerStates {
PlayerStates._();
/// Player is playing media
static const String playing = 'playing';
/// Player is paused
static const String paused = 'paused';
/// Player is idle/nothing loaded
static const String idle = 'idle';
/// Player is off/disconnected
static const String off = 'off';
}
/// Repeat mode constants
class RepeatModes {
RepeatModes._();
/// Repeat is off
static const String off = 'off';
/// Repeat all tracks
static const String all = 'all';
/// Repeat single track
static const String one = 'one';
}
+7 -3
View File
@@ -2996,7 +2996,8 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username($_item.username));
.filter((f) => f.profileUsername.username
.sqlEquals($_itemColumn<String>('username')!));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager(
@@ -3189,7 +3190,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async {
return [
if (recentlyPlayedRefs)
await $_getPrefetchedData(
await $_getPrefetchedData<Profile, $ProfilesTable,
RecentlyPlayedData>(
currentTable: table,
referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db),
@@ -3254,8 +3256,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username($_item.profileUsername));
.filter((f) => f.username.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
+11 -1
View File
@@ -295,5 +295,15 @@
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
"swipeToAdjustVolume": "Wischen für Lautstärke",
"aboutAndSupport": "Über & Unterstützung",
"supportDevelopment": "Entwicklung unterstützen",
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
"sponsorOnGitHub": "Auf GitHub sponsoren",
"buyMeAKoFi": "Kauf mir einen Ko-fi",
"aboutEnsemble": "Über Ensemble",
"version": "Version",
"visitGitHubRepo": "GitHub-Repository besuchen",
"reportABug": "Einen Fehler melden"
}
+97 -32
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field"
},
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
"portDescription": "Leave blank unless using a custom port",
"@portDescription": {
"description": "Description for port field"
},
@@ -221,6 +221,36 @@
"description": "Home screen section title"
},
"discoveryMixes": "Discovery Mixes",
"@discoveryMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
},
"continueListening": "Continue Listening",
"@continueListening": {
"description": "Home screen section title"
@@ -604,6 +634,11 @@
"tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added"
},
@@ -1069,6 +1104,10 @@
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view",
"gridView": "Grid view",
"noBooksInSeries": "No books found in this series",
@@ -1087,11 +1126,12 @@
"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",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"addToLibrary": "Add",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1207,11 +1247,21 @@
"description": "Bottom sheet title for adding album to queue"
},
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...",
"@addToQueueOn": {
"description": "Bottom sheet title for adding to queue"
},
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...",
"@startRadioOn": {
"description": "Bottom sheet title for starting radio",
@@ -1357,6 +1407,16 @@
"description": "Description for show hints toggle"
},
"volumePrecisionMode": "Volume Precision Mode",
"@volumePrecisionMode": {
"description": "Setting for fine volume control"
},
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
"@volumePrecisionModeDescription": {
"description": "Description for volume precision mode setting"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
@@ -1655,53 +1715,58 @@
"description": "Hint for selecting player to add to queue"
},
"localConnection": "Server URL",
"@localConnection": {
"description": "Direct connection mode toggle label"
"aboutAndSupport": "About & Support",
"@aboutAndSupport": {
"description": "About and support section title"
},
"remoteConnection": "Remote Access ID",
"@remoteConnection": {
"description": "HA Remote Access connection mode toggle label"
"supportDevelopment": "Support Development",
"@supportDevelopment": {
"description": "Support development section"
},
"remoteAccessId": "Remote Access ID",
"@remoteAccessId": {
"description": "Remote access ID field label"
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
"@ensembleIsUnofficial": {
"description": "Description about Ensemble being unofficial"
},
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
"@remoteAccessIdHint": {
"description": "Hint for remote access ID field"
"sponsorOnGitHub": "Sponsor on GitHub",
"@sponsorOnGitHub": {
"description": "GitHub Sponsors button label"
},
"remoteAccessNote": "For Home Assistant add-on users only",
"@remoteAccessNote": {
"description": "Note about remote access availability"
"buyMeAKoFi": "Buy me a Ko-fi",
"@buyMeAKoFi": {
"description": "Ko-fi donation button label"
},
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
"@pleaseEnterRemoteId": {
"description": "Validation message for remote access ID"
"aboutEnsemble": "About Ensemble",
"@aboutEnsemble": {
"description": "About Ensemble section"
},
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
"@remoteConnectionFailed": {
"description": "Error message when remote connection fails"
"version": "Version",
"@version": {
"description": "Version label"
},
"scanQrCode": "Scan QR Code",
"@scanQrCode": {
"description": "Title for QR code scanner screen"
"visitGitHubRepo": "Visit GitHub Repository",
"@visitGitHubRepo": {
"description": "Link to GitHub repo"
},
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
"@pointCameraAtQr": {
"description": "Instructions for scanning QR code"
"reportABug": "Report a Bug",
"@reportABug": {
"description": "Link to report issues"
},
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
"@cameraPermissionDenied": {
"description": "Error message when camera permission is denied"
"noRowsEnabled": "No rows enabled",
"@noRowsEnabled": {
"description": "Empty state when no home screen rows are enabled"
},
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
"@pullToRefreshHint": {
"description": "Hint when home screen is empty"
}
}
+11 -1
View File
@@ -491,5 +491,15 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen"
"swipeToAdjustVolume": "Desliza para ajustar volumen",
"aboutAndSupport": "Acerca de y Soporte",
"supportDevelopment": "Apoyar el Desarrollo",
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
"sponsorOnGitHub": "Patrocina en GitHub",
"buyMeAKoFi": "Cómprame un Ko-fi",
"aboutEnsemble": "Acerca de Ensemble",
"version": "Versión",
"visitGitHubRepo": "Visitar Repositorio GitHub",
"reportABug": "Reportar un Error"
}
+11 -1
View File
@@ -1396,5 +1396,15 @@
"type": "String"
}
}
}
},
"aboutAndSupport": "À propos et Support",
"supportDevelopment": "Soutenir le Développement",
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
"sponsorOnGitHub": "Sponsoriser sur GitHub",
"buyMeAKoFi": "Offrez-moi un Ko-fi",
"aboutEnsemble": "À propos d'Ensemble",
"version": "Version",
"visitGitHubRepo": "Visiter le Dépôt GitHub",
"reportABug": "Signaler un Bug"
}
+135 -33
View File
@@ -164,7 +164,7 @@ abstract class S {
/// Description for port field
///
/// In en, this message translates to:
/// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
/// **'Leave blank unless using a custom port'**
String get portDescription;
/// Username field label
@@ -359,6 +359,42 @@ abstract class S {
/// **'Discover Albums'**
String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
///
/// In en, this message translates to:
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title
///
/// In en, this message translates to:
@@ -761,12 +797,18 @@ abstract class S {
/// **'Album added to queue'**
String get albumAddedToQueue;
/// Confirmation when tracks added
/// Snackbar message for tracks added to queue
///
/// In en, this message translates to:
/// **'Tracks added to queue'**
String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label
///
/// In en, this message translates to:
@@ -1229,6 +1271,30 @@ abstract class S {
/// **'Sort by series order'**
String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView.
///
/// In en, this message translates to:
@@ -1337,6 +1403,12 @@ abstract class S {
/// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites.
///
/// In en, this message translates to:
@@ -1364,7 +1436,7 @@ abstract class S {
/// No description provided for @addToLibrary.
///
/// In en, this message translates to:
/// **'Add to library'**
/// **'Add'**
String get addToLibrary;
/// No description provided for @unknown.
@@ -1673,12 +1745,24 @@ abstract class S {
/// **'Add album to queue on...'**
String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue
///
/// In en, this message translates to:
/// **'Add to queue on...'**
String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio
///
/// In en, this message translates to:
@@ -1835,6 +1919,18 @@ abstract class S {
/// **'Display helpful tips for discovering features'**
String get showHintsDescription;
/// Setting for fine volume control
///
/// In en, this message translates to:
/// **'Volume Precision Mode'**
String get volumePrecisionMode;
/// Description for volume precision mode setting
///
/// In en, this message translates to:
/// **'Hold still while adjusting volume for fine control'**
String get volumePrecisionModeDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
@@ -2123,65 +2219,71 @@ abstract class S {
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// Direct connection mode toggle label
/// About and support section title
///
/// In en, this message translates to:
/// **'Server URL'**
String get localConnection;
/// **'About & Support'**
String get aboutAndSupport;
/// HA Remote Access connection mode toggle label
/// Support development section
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteConnection;
/// **'Support Development'**
String get supportDevelopment;
/// Remote access ID field label
/// Description about Ensemble being unofficial
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteAccessId;
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
String get ensembleIsUnofficial;
/// Hint for remote access ID field
/// GitHub Sponsors button label
///
/// In en, this message translates to:
/// **'Find this in Music Assistant Settings → Remote Access'**
String get remoteAccessIdHint;
/// **'Sponsor on GitHub'**
String get sponsorOnGitHub;
/// Note about remote access availability
/// Ko-fi donation button label
///
/// In en, this message translates to:
/// **'For Home Assistant add-on users only'**
String get remoteAccessNote;
/// **'Buy me a Ko-fi'**
String get buyMeAKoFi;
/// Validation message for remote access ID
/// About Ensemble section
///
/// In en, this message translates to:
/// **'Please enter your Remote Access ID'**
String get pleaseEnterRemoteId;
/// **'About Ensemble'**
String get aboutEnsemble;
/// Error message when remote connection fails
/// Version label
///
/// In en, this message translates to:
/// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
String get remoteConnectionFailed;
/// **'Version'**
String get version;
/// Title for QR code scanner screen
/// Link to GitHub repo
///
/// In en, this message translates to:
/// **'Scan QR Code'**
String get scanQrCode;
/// **'Visit GitHub Repository'**
String get visitGitHubRepo;
/// Instructions for scanning QR code
/// Link to report issues
///
/// In en, this message translates to:
/// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
String get pointCameraAtQr;
/// **'Report a Bug'**
String get reportABug;
/// Error message when camera permission is denied
/// Empty state when no home screen rows are enabled
///
/// In en, this message translates to:
/// **'Camera permission is required to scan QR codes'**
String get cameraPermissionDenied;
/// **'No rows enabled'**
String get noRowsEnabled;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
}
class _SDelegate extends LocalizationsDelegate<S> {
+66 -15
View File
@@ -151,6 +151,25 @@ class SDe extends S {
@override
String get discoverAlbums => 'Alben entdecken';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Weiterhoren';
@@ -373,6 +392,9 @@ class SDe extends S {
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Abspielen';
@@ -646,6 +668,18 @@ class SDe extends S {
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Listenansicht';
@@ -705,6 +739,9 @@ class SDe extends S {
String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -718,7 +755,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unbekannt';
@@ -887,9 +924,15 @@ class SDe extends S {
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
@@ -984,6 +1027,13 @@ class SDe extends S {
String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1157,36 +1207,37 @@ class SDe extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'Über & Unterstützung';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Entwicklung unterstützen';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'Über Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'GitHub-Repository besuchen';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Einen Fehler melden';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+67 -17
View File
@@ -39,8 +39,7 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank';
@override
String get portDescription =>
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
String get portDescription => 'Leave blank unless using a custom port';
@override
String get username => 'Username';
@@ -149,6 +148,25 @@ class SEn extends S {
@override
String get discoverAlbums => 'Discover Albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continue Listening';
@@ -369,6 +387,9 @@ class SEn extends S {
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Play';
@@ -641,6 +662,18 @@ class SEn extends S {
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'List view';
@@ -698,6 +731,9 @@ class SEn extends S {
String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Added to favorites';
@@ -711,7 +747,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unknown';
@@ -879,9 +915,15 @@ class SEn extends S {
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
@@ -976,6 +1018,13 @@ class SEn extends S {
String get showHintsDescription =>
'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1149,36 +1198,37 @@ class SEn extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'About & Support';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Support Development';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Sponsor on GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Buy me a Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'About Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visit GitHub Repository';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Report a Bug';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+66 -15
View File
@@ -152,6 +152,25 @@ class SEs extends S {
@override
String get discoverAlbums => 'Descubrir álbumes';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuar escuchando';
@@ -376,6 +395,9 @@ class SEs extends S {
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Reproducir';
@@ -650,6 +672,18 @@ class SEs extends S {
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vista de lista';
@@ -712,6 +746,9 @@ class SEs extends S {
String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Añadido a favoritos';
@@ -725,7 +762,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Desconocido';
@@ -895,9 +932,15 @@ class SEs extends S {
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
@@ -992,6 +1035,13 @@ class SEs extends S {
String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1165,36 +1215,37 @@ class SEs extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'Acerca de y Soporte';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Apoyar el Desarrollo';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Patrocina en GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Cómprame un Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'Acerca de Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Versión';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Reportar un Error';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+65 -14
View File
@@ -153,6 +153,25 @@ class SFr extends S {
@override
String get discoverAlbums => 'Découvrir des albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuer l\'écoute';
@@ -377,6 +396,9 @@ class SFr extends S {
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Lire';
@@ -651,6 +673,18 @@ class SFr extends S {
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vue en liste';
@@ -716,6 +750,9 @@ class SFr extends S {
String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@@ -902,9 +939,15 @@ class SFr extends S {
String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
@@ -1000,6 +1043,13 @@ class SFr extends S {
String get showHintsDescription =>
'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1174,36 +1224,37 @@ class SFr extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'À propos et Support';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Soutenir le Développement';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'À propos d\'Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Signaler un Bug';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+207 -75
View File
@@ -6,14 +6,25 @@ import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'providers/connection_provider.dart';
import 'providers/library_provider.dart';
import 'providers/locale_provider.dart';
import 'providers/local_player_provider.dart';
import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'providers/player_provider.dart';
import 'providers/sleep_timer_provider.dart';
import 'providers/ui_state_provider.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/cache_service.dart';
import 'services/settings_service.dart';
import 'services/database_service.dart';
import 'services/image_helper_service.dart';
import 'services/position_tracker.dart';
import 'services/queue_manager_service.dart';
import 'services/profile_service.dart';
import 'services/provider_filter_service.dart';
import 'services/sync_service.dart';
import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart';
@@ -23,6 +34,7 @@ import 'services/music_assistant_api.dart' show MAConnectionState;
import 'theme/theme_provider.dart';
import 'theme/app_theme.dart';
import 'theme/system_theme_helper.dart';
import 'utils/player_sync_state.dart';
import 'widgets/global_player_overlay.dart';
// Global audio handler instance
@@ -125,6 +137,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider;
late LocaleProvider _localeProvider;
// New multi-provider architecture
late ConnectionProvider _connectionProvider;
late UIStateProvider _uiStateProvider;
late LibraryProvider _libraryProvider;
late PlayerProvider _playerProvider;
late LocalPlayerProvider _localPlayerProvider;
late SleepTimerProvider _sleepTimerProvider;
late NavigationProvider _navigationProvider;
// Shared services
late final CacheService _cacheService = CacheService();
late final PositionTracker _positionTracker = PositionTracker();
late ImageHelperService _imageHelper;
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
@@ -137,9 +164,91 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
void initState() {
super.initState();
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
_navigationProvider = NavigationProvider();
_sleepTimerProvider = SleepTimerProvider();
// Initialize connection provider first (others depend on it)
_connectionProvider = ConnectionProvider();
_uiStateProvider = UIStateProvider();
// Initialize shared image helper service
_imageHelper = ImageHelperService(
api: _connectionProvider.api,
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
);
// Initialize library provider (needs API from connection)
_libraryProvider = LibraryProvider(
api: _connectionProvider.api,
logger: DebugLogger(),
cacheService: _cacheService,
settings: null, // Will use static methods
providerFilterService: ProviderFilterService(
providerFilter: _connectionProvider.providerFilter,
playerFilter: _connectionProvider.playerFilter,
availableMusicProviders: [],
artists: [],
albums: [],
tracks: [],
podcasts: [],
radioStations: [],
),
);
// Initialize local player provider
_localPlayerProvider = LocalPlayerProvider(
audioHandler: audioHandler,
settings: null, // Will use static methods
);
// Initialize player provider (depends on several others)
// Note: queueManager will be set later to avoid circular dependency
_playerProvider = PlayerProvider(
api: _connectionProvider.api,
logger: DebugLogger(),
cacheService: _cacheService,
settings: null, // Will use static methods
positionTracker: _positionTracker,
queueManager: null, // Will be set after initialization
sleepTimerProvider: _sleepTimerProvider,
imageHelper: _imageHelper,
playerSyncState: PlayerSyncState(
availablePlayers: [],
castToSendspinIdMap: {},
),
audioHandler: audioHandler,
);
// Create queue manager with functions that access provider state
final queueManager = QueueManagerService(
api: _connectionProvider.api,
database: DatabaseService.instance,
logger: DebugLogger(),
getAvailablePlayers: () => _playerProvider.availablePlayers,
getCastToSendspinMap: () => _playerProvider.castToSendspinIdMap,
playTracks: (playerId, tracks, {int? startIndex, bool? clearQueue}) async {
await _connectionProvider.api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue ?? true);
},
);
// Set the queue manager after creation (breaks circular dependency)
_playerProvider.queueManager = queueManager;
// Initialize main provider (facade mode - sub-providers not yet wired)
// Sub-providers have null API at construction time and no update mechanism,
// so keep facade using its own internal logic until wiring is complete.
_musicProvider = MusicAssistantProvider();
// Wire up player provider callbacks
_playerProvider.onPlayerChanged = () {
setState(() {}); // Trigger rebuild on player change
};
_playerProvider.onTrackChanged = () {
setState(() {}); // Trigger rebuild on track change
};
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
@@ -252,9 +361,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Core facade provider (will be removed after migration)
ChangeNotifierProvider.value(value: _musicProvider),
// Theme providers
ChangeNotifierProvider.value(value: _themeProvider),
ChangeNotifierProvider.value(value: _localeProvider),
ChangeNotifierProvider.value(value: _navigationProvider),
// New multi-provider architecture (available for gradual migration)
ChangeNotifierProvider.value(value: _connectionProvider),
ChangeNotifierProvider.value(value: _uiStateProvider),
ChangeNotifierProvider.value(value: _libraryProvider),
ChangeNotifierProvider.value(value: _playerProvider),
ChangeNotifierProvider.value(value: _localPlayerProvider),
ChangeNotifierProvider.value(value: _sleepTimerProvider),
],
child: Consumer2<ThemeProvider, LocaleProvider>(
builder: (context, themeProvider, localeProvider, _) {
@@ -329,28 +450,13 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
// 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,
),
// Wrap entire app with global player overlay.
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
return Overlay(
initialEntries: [
OverlayEntry(
builder: (context) => GlobalPlayerOverlay(
child: child ?? const SizedBox.shrink(),
),
),
],
@@ -474,94 +580,120 @@ class _AppStartupState extends State<AppStartup> {
@override
void initState() {
super.initState();
_loadSettingsFirst();
try {
_loadSettingsFirst();
} catch (e) {
_logger.error('AppStartup init error: $e');
// Fallback - mark as loaded so app can continue
if (mounted) {
setState(() {
_settingsLoaded = true;
_hasCompletedOnboarding = true;
});
}
}
}
/// Load settings BEFORE checking connection.
/// This ensures we know if this is a first-time user before transitioning.
Future<void> _loadSettingsFirst() async {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
try {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
setState(() {
_settingsLoaded = true;
});
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
} catch (e) {
_logger.error('AppStartup settings load error: $e');
if (mounted) {
setState(() {
_settingsLoaded = true;
_hasCompletedOnboarding = true; // Default to true on error
});
}
}
// Now check connection
_checkAndConnect();
}
Future<void> _checkAndConnect() async {
final serverUrl = await SettingsService.getServerUrl();
try {
final serverUrl = await SettingsService.getServerUrl();
if (!mounted) return;
if (!mounted) return;
setState(() {
_savedServerUrl = serverUrl;
});
// If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) {
setState(() {
_isConnecting = true;
_savedServerUrl = serverUrl;
});
final provider = context.read<MusicAssistantProvider>();
// If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) {
setState(() {
_isConnecting = true;
});
// Connection is handled by MusicAssistantProvider._initialize()
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
final provider = context.read<MusicAssistantProvider>();
// For first-time users, we need to wait for both connection AND player selection
// so the welcome overlay can appear immediately without home screen flash.
// For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
// Connection is handled by MusicAssistantProvider._initialize()
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
// For first-time users, we need to wait for both connection AND player selection
// so the welcome overlay can appear immediately without home screen flash.
// For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
}
}
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
if (!provider.isConnected) {
_logger.log('🚀 AppStartup: Connection still pending or failed');
}
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
if (!provider.isConnected) {
_logger.log('🚀 AppStartup: Connection still pending or failed');
}
} catch (e) {
_logger.error('AppStartup connection error: $e');
if (mounted) {
setState(() {
_isConnecting = false;
_connectionAttempted = true;
});
}
} else {
setState(() {
_connectionAttempted = true;
});
}
_connectionAttempted = true;
}
@override
+32 -8
View File
@@ -31,20 +31,14 @@ class ProviderMapping {
});
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,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
@@ -143,6 +137,12 @@ class MediaItem {
@override
int get hashCode => Object.hash(itemId, provider);
/// Check if this item is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
}
class Artist extends MediaItem {
@@ -171,6 +171,12 @@ class Artist extends MediaItem {
);
}
/// Check if this artist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() => super.toJson();
}
@@ -292,6 +298,12 @@ class Track extends MediaItem {
String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Check if this track is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
@@ -338,6 +350,12 @@ class Playlist extends MediaItem {
favorite: item.favorite,
);
}
/// Check if this playlist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
}
class Chapter {
@@ -534,6 +552,12 @@ class Audiobook extends MediaItem {
String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0)
double get progress {
if (fullyPlayed == true) return 1.0;
+41 -24
View File
@@ -17,6 +17,7 @@ class Player {
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({
required this.playerId,
@@ -35,6 +36,7 @@ class Player {
this.activeSource,
this.isExternalSource = false,
this.appId,
this.activeQueue,
});
/// Create a copy of this Player with some fields replaced
@@ -55,6 +57,7 @@ class Player {
String? activeSource,
bool? isExternalSource,
String? appId,
String? activeQueue,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -73,6 +76,7 @@ class Player {
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
);
}
@@ -218,50 +222,61 @@ class Player {
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
// Parse active_queue - this is the key indicator for MA-controlled playback
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
// When external source: active_queue = null
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
// If MA explicitly says it's playing, trust that
if (appId == 'music_assistant') {
isExternalSource = false;
}
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue
else if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, airplay://, etc. as external here -
// those should only be external if app_id indicates external control
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
isExternalSource = isSimpleExternalId;
}
// Also check media_type - 'unknown' often indicates external source
// Also check media_type - 'unknown' with non-MA URI indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
@@ -287,6 +302,7 @@ class Player {
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
activeQueue: activeQueue,
);
}
@@ -308,6 +324,7 @@ class Player {
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
};
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'media_item.dart';
class RecommendationFolder {
final String itemId;
final String provider;
final String name;
final String? uri;
final List<MediaItem> items;
RecommendationFolder({
required this.itemId,
required this.provider,
required this.name,
this.uri,
required this.items,
});
/// Unique ID for use in row ordering and settings
/// Uses 'discovery:' prefix to distinguish from static row IDs
String get rowId => 'discovery:$itemId';
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>()
.map(_parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
name: json['name'] as String? ?? '',
uri: json['uri'] as String?,
items: parsedItems,
);
}
Map<String, dynamic> toJson() {
return {
'item_id': itemId,
'provider': provider,
'name': name,
if (uri != null) 'uri': uri,
'items': items.map((item) => item.toJson()).toList(),
};
}
}
/// Polymorphic parser for media items in recommendation folders
/// Handles heterogeneous items (playlists, radio, etc.)
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
final mediaType = json['media_type'] as String? ?? '';
switch (mediaType) {
case 'playlist':
return Playlist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'artist':
return Artist.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
default:
// For radio, podcast, podcast_episode - use MediaItem base class
return MediaItem.fromJson(json);
}
}
+287
View File
@@ -0,0 +1,287 @@
import 'package:flutter/foundation.dart';
import '../constants/timings.dart';
import '../models/media_item.dart';
import '../services/cache_service.dart';
import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import '../services/music_assistant_api.dart';
import '../services/provider_filter_service.dart';
import '../services/settings_service.dart';
import '../services/sync_service.dart';
/// Provider for library data management.
///
/// Manages:
/// - Artists, albums, tracks lists
/// - Radio stations and podcasts
/// - Library loading and refresh
/// - Provider filtering
/// - Background sync
///
/// Extracted from MusicAssistantProvider to isolate library logic.
class LibraryProvider extends ChangeNotifier {
final MusicAssistantAPI? _api;
final DebugLogger _logger;
final CacheService _cacheService;
final SettingsService? _settings;
final ProviderFilterService _providerFilterService;
// Library data
List<dynamic> _artists = [];
List<dynamic> _albums = [];
List<dynamic> _tracks = [];
List<dynamic> _radioStations = [];
List<dynamic> _podcasts = [];
// Getters
List<dynamic> get artists => _artists;
List<dynamic> get albums => _albums;
List<dynamic> get tracks => _tracks;
List<dynamic> get radioStations => _radioStations;
List<dynamic> get podcasts => _podcasts;
// Expose typed lists for convenience
List<Artist> get artistList => _artists.cast<Artist>();
List<Album> get albumList => _albums.cast<Album>();
List<Track> get trackList => _tracks.cast<Track>();
// Callback for when library status changes
Function()? onLibraryChanged;
LibraryProvider({
required MusicAssistantAPI? api,
required DebugLogger logger,
required CacheService cacheService,
SettingsService? settings,
required ProviderFilterService providerFilterService,
}) : _api = api,
_logger = logger,
_cacheService = cacheService,
_settings = settings,
_providerFilterService = providerFilterService;
/// Load library data from API with caching
Future<void> loadLibrary() async {
if (_api == null) return;
try {
_logger.log('📚 Loading library from cache (instant)...');
// Load from database cache first (instant)
final syncService = SyncService.instance;
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_syncLibraryStatusToService();
notifyListeners();
}
// Fetch tracks from API (not cached - too many items)
try {
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: _providerIdsForApiCalls(),
);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
notifyListeners();
// Trigger background sync (non-blocking)
_syncLibraryInBackground();
} catch (e) {
_logger.log('❌ Error loading library: $e');
}
}
/// Load artists from library
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
if (_api == null) return;
try {
final artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: false,
orderBy: orderBy,
);
// Update SyncService cache
SyncService.instance.updateCachedArtists(artists);
_artists = artists;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load artists: $e');
}
}
/// Load albums from library
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId, String? orderBy}) async {
if (_api == null) return;
try {
final albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
orderBy: orderBy,
);
// Update SyncService cache
SyncService.instance.updateCachedAlbums(albums);
_albums = albums;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load albums: $e');
}
}
/// Load radio stations
Future<void> loadRadioStations({String? orderBy}) async {
if (_api == null) return;
try {
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load radio stations: $e');
}
}
/// Load podcasts
Future<void> loadPodcasts({String? orderBy}) async {
if (_api == null) return;
try {
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load podcasts: $e');
}
}
/// Get playlists with provider filtering
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
if (_api == null) return [];
try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
return _providerFilterService.filterByProvider(playlists);
} catch (e) {
_logger.log('❌ Failed to fetch playlists: $e');
return [];
}
}
/// Get audiobooks with provider filtering
Future<List<Audiobook>> getAudiobooks({int? limit, bool? favoriteOnly}) async {
if (_api == null) return [];
try {
final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false);
return _providerFilterService.filterByProvider(audiobooks);
} catch (e) {
_logger.log('❌ Failed to fetch audiobooks: $e');
return [];
}
}
/// Sync library data in background without blocking UI
void _syncLibraryInBackground() {
if (_api == null) return;
final syncService = SyncService.instance;
// Listen for sync completion to update our lists
void onSyncComplete() {
if (syncService.status == SyncStatus.completed) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
onLibraryChanged?.call();
}
syncService.removeListener(onSyncComplete);
}
syncService.addListener(onSyncComplete);
syncService.syncFromApi(_api!, providerInstanceIds: _providerIdsForApiCalls());
}
/// Sync library status to centralized service for reactive UI updates
void _syncLibraryStatusToService() {
final service = LibraryStatusService.instance;
service.syncFromLibrary(
mediaType: 'album',
items: _albums,
getInLibrary: (a) => (a as Album).inLibrary,
getFavorite: (a) => (a as Album).favorite ?? false,
getProvider: (a) => (a as Album).provider,
getItemId: (a) => (a as Album).itemId,
);
service.syncFromLibrary(
mediaType: 'artist',
items: _artists,
getInLibrary: (a) => (a as Artist).inLibrary,
getFavorite: (a) => (a as Artist).favorite ?? false,
getProvider: (a) => (a as Artist).provider,
getItemId: (a) => (a as Artist).itemId,
);
service.syncFromLibrary(
mediaType: 'track',
items: _tracks,
getInLibrary: (t) => (t as Track).inLibrary,
getFavorite: (t) => (t as Track).favorite ?? false,
getProvider: (t) => (t as Track).provider,
getItemId: (t) => (t as Track).itemId,
);
}
/// Get provider instance IDs for API calls (respects user filter)
List<String> _providerIdsForApiCalls() {
// For now, always return empty list to use all available providers
// Provider filtering can be re-enabled later with async approach
return [];
}
/// Update the API reference
void setApi(MusicAssistantAPI? api) {
if (_api != api) {
// Note: We don't dispose the old API here as it's managed by ConnectionProvider
// This is just updating the reference
}
}
/// Load library from cache for instant display
Future<void> loadFromCache() async {
try {
final syncService = SyncService.instance;
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Pre-loaded library: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
}
} catch (e) {
_logger.log('⚠️ Error loading library from cache: $e');
}
}
}
+371
View File
@@ -0,0 +1,371 @@
import 'dart:async';
import 'dart:ui';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:flutter/foundation.dart';
import '../constants/timings.dart';
import '../services/audio/massiv_audio_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/debug_logger.dart';
import '../services/device_id_service.dart';
import '../services/local_player_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/sendspin_service.dart';
import '../services/settings_service.dart';
/// Provider for local audio playback and Sendspin integration.
///
/// Manages:
/// - LocalPlayerService for audio playback
/// - SendspinService integration (MA 2.7.0b20+)
/// - PcmAudioPlayer for raw audio streaming
/// - Player registration with Music Assistant
/// - Volume and power control
/// - Event handling from Sendspin
///
/// Extracted from MusicAssistantProvider to isolate local player logic.
class LocalPlayerProvider extends ChangeNotifier {
final AuthManager _authManager;
final DebugLogger _logger;
final SettingsService? _settings;
// Local player service
late final LocalPlayerService _localPlayer;
// Sendspin service (MA 2.7.0b20+ replacement for builtin_player)
SendspinService? _sendspinService;
bool _sendspinConnected = false;
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = VolumeConstants.max;
bool _builtinPlayerAvailable = true;
// State reporting timer
Timer? _localPlayerStateReportTimer;
// Audio handler (from audio_service package)
final MassivAudioHandler _audioHandler;
// Server info
String? _serverUrl;
// Callbacks
Function()? onSendspinConnected;
Function()? onSendspinDisconnected;
Function(Map<String, dynamic>)? onLocalPlayerEvent;
LocalPlayerProvider({
AuthManager? authManager,
DebugLogger? logger,
SettingsService? settings,
required MassivAudioHandler audioHandler,
}) : _authManager = authManager ?? AuthManager(),
_logger = logger ?? DebugLogger(),
_settings = settings,
_audioHandler = audioHandler {
_localPlayer = LocalPlayerService(_authManager);
}
// Getters
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
int get localPlayerVolume => _localPlayerVolume;
bool get sendspinConnected => _sendspinConnected;
bool get builtinPlayerAvailable => _builtinPlayerAvailable;
bool get isPcmPlaying => _sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying;
LocalPlayerService get localPlayer => _localPlayer;
SendspinService? get sendspinService => _sendspinService;
PcmAudioPlayer? get pcmAudioPlayer => _pcmAudioPlayer;
// ============================================================================
// INITIALIZATION
// ============================================================================
/// Initialize local playback engine
Future<void> initialize() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up notification button callbacks
_wireNotificationCallbacks();
_logger.log('🔊 Local player initialized');
}
/// Register the built-in/local player with Music Assistant
Future<String?> registerLocalPlayer() async {
if (_serverUrl == null) {
_logger.log('⚠️ Cannot register player: no server URL');
return null;
}
try {
final deviceId = await DeviceIdService.getOrCreateDevicePlayerId();
final ownerName = await SettingsService.getOwnerName() ?? 'Ensemble User';
_logger.log('📱 Registering local player: $deviceId');
// Check if server uses Sendspin (MA 2.7.0b20+)
if (_serverUsesSendspin()) {
return await _registerSendspinPlayer(deviceId, ownerName);
} else {
return await _registerBuiltinPlayer(deviceId, ownerName);
}
} catch (e) {
_logger.log('❌ Failed to register local player: $e');
return null;
}
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin)
bool _serverUsesSendspin() {
// This would check the server version from API
// For now, we'll try both methods
return true; // Default to Sendspin for modern MA
}
/// Register using Sendspin protocol (MA 2.7.0b20+)
Future<String?> _registerSendspinPlayer(String deviceId, String ownerName) async {
_logger.log('📡 Registering via Sendspin protocol...');
try {
// Initialize Sendspin service
_sendspinService = SendspinService(_serverUrl!);
_builtinPlayerAvailable = false;
// Initialize PCM audio player
_pcmAudioPlayer = PcmAudioPlayer();
final pcmInitialized = await _pcmAudioPlayer!.initialize();
if (!pcmInitialized) {
_logger.log('⚠️ Failed to initialize PCM audio player');
}
// Connect Sendspin events
_wireSendspinEvents();
// Try direct connection first
final localSendspinUrl = '$_serverUrl/sendspin/$deviceId';
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
if (!connected) {
// Try proxy connection
final proxyUrl = '$_serverUrl/sendspin/proxy/$deviceId';
final proxyConnected = await _sendspinService!.connectWithUrl(proxyUrl, useProxyAuth: true);
if (!proxyConnected) {
_logger.log('⚠️ Sendspin connection failed, will retry on connect');
return null;
}
}
// Connect PCM audio player to Sendspin audio stream
if (_pcmAudioPlayer != null && pcmInitialized) {
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
}
_sendspinConnected = true;
_startStateReporting();
onSendspinConnected?.call();
_logger.log('✅ Sendspin player registered: $deviceId');
return deviceId;
} catch (e) {
_logger.log('❌ Sendspin registration failed: $e');
return null;
}
}
/// Register using legacy builtin_player protocol
Future<String?> _registerBuiltinPlayer(String deviceId, String ownerName) async {
_logger.log('📻 Registering via builtin_player protocol...');
// Legacy registration would go here
return deviceId;
}
// ============================================================================
// SENDSPIN EVENT HANDLERS
// ============================================================================
void _wireSendspinEvents() {
if (_sendspinService == null) return;
_sendspinService!.onPlay = _handleSendspinPlay;
_sendspinService!.onPause = _handleSendspinPause;
_sendspinService!.onStop = _handleSendspinStop;
_sendspinService!.onSeek = _handleSendspinSeek;
_sendspinService!.onVolume = _handleSendspinVolume;
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
}
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) {
_logger.log('📡 Sendspin: Play $streamUrl');
_pcmAudioPlayer?.play();
notifyListeners();
}
void _handleSendspinPause() {
_logger.log('📡 Sendspin: Pause');
_pcmAudioPlayer?.pause();
notifyListeners();
}
void _handleSendspinStop() {
_logger.log('📡 Sendspin: Stop');
_pcmAudioPlayer?.stop();
notifyListeners();
}
void _handleSendspinSeek(int positionSeconds) {
_logger.log('📡 Sendspin: Seek to $positionSeconds');
_pcmAudioPlayer?.resetPosition();
notifyListeners();
}
void _handleSendspinVolume(int volumeLevel) {
_logger.log('📡 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel;
notifyListeners();
}
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) {
_logger.log('📡 Sendspin: Stream start');
_pcmAudioPlayer?.play();
notifyListeners();
}
void _handleSendspinStreamEnd() {
_logger.log('📡 Sendspin: Stream end');
_pcmAudioPlayer?.stop();
notifyListeners();
}
// ============================================================================
// LOCAL PLAYER CONTROLS
// ============================================================================
Future<void> setPower(bool powered) async {
_isLocalPlayerPowered = powered;
notifyListeners();
}
Future<void> setVolume(int volumeLevel) async {
_localPlayerVolume = volumeLevel;
_localPlayer.setVolume(volumeLevel / 100);
// Report to Sendspin if connected
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(volume: volumeLevel);
}
notifyListeners();
}
Future<void> playUrl(String url) async {
await _localPlayer.playUrl(url);
// Report to Sendspin if connected
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(playing: true, paused: false);
}
}
Future<void> pause() async {
await _localPlayer.pause();
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(playing: false, paused: true);
}
}
Future<void> stop() async {
await _localPlayer.stop();
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(playing: false, paused: false);
}
}
Future<void> seek(Duration position) async {
await _localPlayer.seek(position);
if (_sendspinConnected && _sendspinService != null) {
_sendspinService!.reportState(position: position.inSeconds);
}
}
// ============================================================================
// NOTIFICATION CALLBACKS
// ============================================================================
void _wireNotificationCallbacks() {
_audioHandler.onPlay = () {
_logger.log('🎵 Notification: Play pressed');
// This would be handled by PlayerProvider
};
_audioHandler.onPause = () {
_logger.log('🎵 Notification: Pause pressed');
// This would be handled by PlayerProvider
};
_audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
// This would be handled by PlayerProvider
};
_audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
// This would be handled by PlayerProvider
};
}
// ============================================================================
// STATE REPORTING
// ============================================================================
void _startStateReporting() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(
Timings.localPlayerReportInterval,
(_) => _reportState(),
);
}
Future<void> _reportState() async {
if (!_sendspinConnected || _sendspinService == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = _localPlayerVolume;
final muted = _localPlayerVolume == 0;
_sendspinService!.reportState(
playing: isPlaying,
paused: !isPlaying,
position: position,
volume: volume,
muted: muted,
);
}
// ============================================================================
// SERVER URL MANAGEMENT
// ============================================================================
void setServerUrl(String? serverUrl) {
_serverUrl = serverUrl;
}
// ============================================================================
// CLEANUP
// ============================================================================
@override
void dispose() {
_localPlayerStateReportTimer?.cancel();
_pcmAudioPlayer?.dispose();
_sendspinService?.dispose();
_localPlayer.dispose();
super.dispose();
}
}
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
import 'dart:async';
import 'dart:ui';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:flutter/foundation.dart';
import '../constants/timings.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../providers/sleep_timer_provider.dart';
import '../services/audio/massiv_audio_handler.dart';
import '../services/cache_service.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../services/image_helper_service.dart';
import '../services/local_player_service.dart';
import '../services/music_assistant_api.dart';
import '../services/position_tracker.dart';
import '../services/queue_manager_service.dart';
import '../services/settings_service.dart';
import '../utils/player_sort_utility.dart';
import '../utils/player_sync_state.dart';
/// Provider for player management and playback controls.
///
/// Manages:
/// - Player selection and available players list
/// - Current playback state and track
/// - Play/pause/stop/next/previous/seek controls
/// - Queue operations
/// - Position tracking
/// - Sleep timer integration
///
/// Extracted from MusicAssistantProvider to isolate player logic.
class PlayerProvider extends ChangeNotifier {
final MusicAssistantAPI? _api;
final DebugLogger _logger;
final CacheService _cacheService;
final SettingsService? _settings;
final PositionTracker _positionTracker;
QueueManagerService? _queueManager;
final SleepTimerProvider _sleepTimerProvider;
final ImageHelperService _imageHelper;
final PlayerSyncState _playerSyncState;
// Player state
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Map<String, String> _castToSendspinIdMap = {};
bool _selectPlayerInProgress = false;
// Current playback
Track? _currentTrack;
Audiobook? _currentAudiobook;
String? _currentPodcastName;
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
// Timers
Timer? _playerStateTimer;
Timer? _notificationPositionTimer;
Timer? _idleServiceTimer;
// Audio handler (MassivAudioHandler for custom notification methods)
final MassivAudioHandler _audioHandler;
// Callbacks for coordination
Function()? onPlayerChanged;
Function()? onTrackChanged;
Function()? onAudiobookChanged;
PlayerProvider({
required MusicAssistantAPI? api,
required DebugLogger logger,
required CacheService cacheService,
SettingsService? settings,
required PositionTracker positionTracker,
QueueManagerService? queueManager,
required SleepTimerProvider sleepTimerProvider,
required ImageHelperService imageHelper,
required PlayerSyncState playerSyncState,
required MassivAudioHandler audioHandler,
}) : _api = api,
_logger = logger,
_cacheService = cacheService,
_settings = settings,
_positionTracker = positionTracker,
_queueManager = queueManager,
_sleepTimerProvider = sleepTimerProvider,
_imageHelper = imageHelper,
_playerSyncState = playerSyncState,
_audioHandler = audioHandler {
// Connect position tracker to sleep timer
_positionTracker.positionStream.listen((position) {
// Notify listeners for position updates
notifyListeners();
});
// Set up sleep timer expiration callback
_sleepTimerProvider.onExpired = () {
pausePlayer(_selectedPlayer?.playerId ?? '');
};
}
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
Audiobook? get currentAudiobook => _currentAudiobook;
String? get currentPodcastName => _currentPodcastName;
bool get selectPlayerInProgress => _selectPlayerInProgress;
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
/// Set the queue manager (can be set after initialization to avoid circular deps)
set queueManager(QueueManagerService? manager) {
_queueManager = manager;
}
bool get isPlaying => _selectedPlayer?.state == 'playing';
bool get isPaused => _selectedPlayer?.state == 'paused';
bool get isStopped => _selectedPlayer?.state == 'idle' ||
_selectedPlayer?.state == 'off' ||
_selectedPlayer == null;
String? get currentArtworkUrl {
if (_currentTrack != null) {
return _imageHelper.getImageUrl(_currentTrack!, size: ImageSizes.highRes);
}
return null;
}
// ============================================================================
// PLAYER LOADING AND SELECTION
// ============================================================================
/// Load players and auto-select the best one
Future<void> loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
try {
// Check cache validity
if (!forceRefresh &&
!coldStart &&
_cacheService.isPlayersCacheValid() &&
_availablePlayers.isNotEmpty) {
return;
}
// Load Cast-to-Sendspin mappings
await _loadCastToSendspinMappings();
final allPlayers = await getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
// Filter players
_availablePlayers = _filterPlayers(allPlayers, builtinPlayerId);
// Handle Cast/Sendspin player switching
await _handleSendspinPlayers();
// Sort players
final smartSort = await SettingsService.getSmartSortPlayers();
_sortPlayersSync(smartSort, builtinPlayerId);
// Auto-select player if none selected or on cold start
if (_selectedPlayer == null || coldStart) {
final playerToSelect = _selectBestPlayer(builtinPlayerId);
if (playerToSelect != null) {
await selectPlayer(playerToSelect);
}
}
notifyListeners();
} catch (e) {
_logger.log('❌ Error loading players: $e');
}
}
/// Get all players from API
Future<List<Player>> getPlayers() async {
return await _api?.getPlayers() ?? [];
}
/// Select a player as the active player
Future<void> selectPlayer(Player player, {bool skipNotify = false}) async {
if (_selectPlayerInProgress) {
_logger.log('⚠️ selectPlayer already in progress, skipping');
return;
}
_selectPlayerInProgress = true;
try {
_selectedPlayer = player;
// Cache selection
_cacheService.setCachedSelectedPlayer(player);
await SettingsService.setLastSelectedPlayerId(player.playerId);
// Set current track from cache for instant display
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
// Initialize position tracker
_positionTracker.onPlayerSelected(player.playerId);
if (!player.isExternalSource) {
_positionTracker.updateFromServer(
playerId: player.playerId,
position: player.elapsedTime ?? 0.0,
isPlaying: player.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: player.elapsedTimeLastUpdated,
);
}
// Update audio handler notification
await _updateNotificationForPlayer(player);
// Start player state polling
_startPlayerStatePolling();
_manageNotificationPositionTimer();
if (!skipNotify) {
notifyListeners();
onPlayerChanged?.call();
}
} finally {
_selectPlayerInProgress = false;
}
}
/// Update audio handler notification for the selected player
Future<void> _updateNotificationForPlayer(Player player) async {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
if (isBuiltinPlayer) {
_audioHandler.setLocalMode();
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
_audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: player.state == 'playing',
duration: track.duration,
);
} else if (player.state == 'playing' || player.state == 'paused') {
final mediaItem = audio_service.MediaItem(
id: 'player_${player.playerId}',
title: player.name,
artist: 'Loading...',
);
_audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: player.state == 'playing',
);
} else {
_audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
} else {
// Remote player
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
final track = _currentTrack!;
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
final artistWithPlayer = track.artistsString.isNotEmpty
? '${track.artistsString} • ${player.name}'
: player.name;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: artistWithPlayer,
album: track.album?.name ?? '',
duration: track.duration,
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
_audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
position: position,
duration: track.duration,
);
} else if (player.state == 'playing' || player.state == 'paused') {
final mediaItem = audio_service.MediaItem(
id: 'player_${player.playerId}',
title: player.name,
artist: 'Loading...',
);
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
_audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
position: position,
duration: Duration.zero,
);
} else {
_audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
}
}
// ============================================================================
// PLAYBACK CONTROLS
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null) return;
final playerId = _selectedPlayer!.playerId;
if (_selectedPlayer?.state == 'playing') {
await pausePlayer(playerId);
} else {
await playPlayer(playerId);
}
}
Future<void> playPlayer(String playerId) async {
try {
await _api?.resumePlayer(playerId);
} catch (e) {
_logger.log('❌ Failed to play: $e');
rethrow;
}
}
Future<void> pausePlayer(String playerId) async {
try {
await _api?.pausePlayer(playerId);
} catch (e) {
_logger.log('❌ Failed to pause: $e');
rethrow;
}
}
Future<void> stopPlayer(String playerId) async {
try {
await _api?.stopPlayer(playerId);
} catch (e) {
_logger.log('❌ Failed to stop: $e');
rethrow;
}
}
Future<void> nextTrack(String playerId) async {
try {
await _api?.nextTrack(playerId);
} catch (e) {
_logger.log('❌ Failed to skip to next: $e');
rethrow;
}
}
Future<void> previousTrack(String playerId) async {
try {
await _api?.previousTrack(playerId);
} catch (e) {
_logger.log('❌ Failed to go to previous: $e');
rethrow;
}
}
Future<void> seek(String playerId, int positionSeconds) async {
try {
await _api?.seek(playerId, positionSeconds);
} catch (e) {
_logger.log('❌ Failed to seek: $e');
rethrow;
}
}
Future<void> seekRelative(String playerId, int deltaSeconds) async {
final currentPosition = _positionTracker.currentPosition.inSeconds;
await seek(playerId, (currentPosition + deltaSeconds).clamp(0, double.infinity).toInt());
}
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
await _api?.setVolume(playerId, volumeLevel);
} catch (e) {
_logger.log('❌ Failed to set volume: $e');
rethrow;
}
}
// ============================================================================
// TRACK PLAYBACK
// ============================================================================
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
try {
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
} catch (e) {
_logger.log('❌ Failed to play track: $e');
rethrow;
}
}
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
try {
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
if (tracks.isNotEmpty && startIndex != null && startIndex < tracks.length) {
_currentTrack = tracks[startIndex];
} else if (tracks.isNotEmpty) {
_currentTrack = tracks.first;
}
notifyListeners();
onTrackChanged?.call();
} catch (e) {
_logger.log('❌ Failed to play tracks: $e');
rethrow;
}
}
// ============================================================================
// QUEUE MANAGEMENT
// ============================================================================
Future<PlayerQueue?> getQueue(String playerId) async {
return _queueManager?.getQueue(playerId);
}
// ============================================================================
// POLLING AND STATE UPDATES
// ============================================================================
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
_playerStateTimer = Timer.periodic(
Timings.playerPollingInterval,
(_) => _updatePlayerState(),
);
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
final playerId = _selectedPlayer!.playerId;
// Get all players and find our selected one
final allPlayers = await _api?.getPlayers();
final updatedPlayer = allPlayers?.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer!,
);
if (updatedPlayer == null) {
_logger.log('⚠️ _updatePlayerState: Player not found: $playerId');
return;
}
// Check if player changed during fetch
if (_selectedPlayer?.playerId != playerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during fetch, aborting');
return;
}
final previousState = _selectedPlayer!.state;
_selectedPlayer = updatedPlayer;
// Update position tracker
_positionTracker.updateFromServer(
playerId: playerId,
position: updatedPlayer.elapsedTime ?? 0.0,
isPlaying: updatedPlayer.state == 'playing',
duration: _currentTrack?.duration,
serverTimestamp: updatedPlayer.elapsedTimeLastUpdated,
);
// Update notification if state changed
if (previousState != updatedPlayer.state) {
await _updateNotificationForPlayer(updatedPlayer);
}
notifyListeners();
} catch (e) {
_logger.log('⚠️ Error updating player state: $e');
}
}
void _manageNotificationPositionTimer() {
_notificationPositionTimer?.cancel();
// Only for remote/Sendspin players
// Note: We don't have a sync version, so we use a cached value or null
final builtinPlayerId = _selectedPlayer?.playerId; // Simplified for now
final isRemotePlayer = _selectedPlayer != null &&
(builtinPlayerId == null || _selectedPlayer!.playerId != builtinPlayerId);
if (isRemotePlayer && (_selectedPlayer?.state == 'playing' || _selectedPlayer?.state == 'paused')) {
_notificationPositionTimer = Timer.periodic(
Timings.notificationPositionUpdate,
(_) => _updateNotificationPosition(),
);
}
}
void _updateNotificationPosition() {
if (_currentTrack == null || _selectedPlayer == null) return;
final position = _positionTracker.currentPosition;
// Update notification position
// (This would be handled by audio_handler internally)
}
// ============================================================================
// SLEEP TIMER
// ============================================================================
Future<void> setSleepTimer(int? minutes) async {
_sleepTimerProvider.setTimer(minutes);
notifyListeners();
}
Future<void> cancelSleepTimer() async {
_sleepTimerProvider.setTimer(null);
notifyListeners();
}
// ============================================================================
// HELPER METHODS
// ============================================================================
Future<void> _loadCastToSendspinMappings() async {
if (_castToSendspinIdMap.isEmpty) {
try {
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
_castToSendspinIdMap.addAll(persistedMappings);
if (persistedMappings.isNotEmpty) {
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
}
} catch (e) {
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
}
}
}
List<Player> _filterPlayers(List<Player> allPlayers, String? builtinPlayerId) {
return PlayerSortUtility.filterPlayers(
allPlayers,
builtinPlayerId: builtinPlayerId,
);
}
Future<void> _handleSendspinPlayers() async {
// Complex Sendspin/Cast player switching logic
// This would need to be extracted fully
// For now, we'll keep a simplified version
}
void _sortPlayersSync(bool smartSort, String? builtinPlayerId) {
PlayerSortUtility.sortPlayers(
_availablePlayers,
smartSort: smartSort,
builtinPlayerId: builtinPlayerId,
);
}
Player? _selectBestPlayer(String? builtinPlayerId) {
return PlayerSortUtility.selectBestPlayer(
availablePlayers: _availablePlayers,
lastSelectedPlayer: null, // Could load from settings
lastActivePlayer: null,
builtinPlayerId: builtinPlayerId,
);
}
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_idleServiceTimer = Timer(const Duration(minutes: 30), () {
// After 30 min idle, stop position tracking
_positionTracker.clear();
});
}
void _cancelIdleServiceTimer() {
_idleServiceTimer?.cancel();
}
// ============================================================================
// CLEANUP
// ============================================================================
@override
void dispose() {
_playerStateTimer?.cancel();
_notificationPositionTimer?.cancel();
_idleServiceTimer?.cancel();
super.dispose();
}
}
+5 -1
View File
@@ -18,6 +18,7 @@ class SleepTimerProvider with ChangeNotifier {
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer;
bool _disposed = false;
/// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired;
@@ -74,7 +75,9 @@ class SleepTimerProvider with ChangeNotifier {
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
if (!_disposed) {
notifyListeners(); // Update UI with remaining time
}
});
notifyListeners();
@@ -123,6 +126,7 @@ class SleepTimerProvider with ChangeNotifier {
@override
void dispose() {
_disposed = true;
_cancelInternal();
super.dispose();
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
/// Provider for UI-only state that doesn't belong to any specific domain.
///
/// Manages:
/// - Search state (query and results)
/// - Provider and player filters (from MA user settings)
/// - User-controlled provider enable/disable
/// - Loading states
/// - Home refresh counter
///
/// This state is UI-focused and doesn't involve business logic operations.
class UIStateProvider extends ChangeNotifier {
// Search state
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Provider filter from MA user settings (empty = all providers allowed)
List<String> _providerFilter = [];
// Player filter from MA user settings (empty = all players allowed)
List<String> _playerFilter = [];
// User-controlled music provider filter (local settings in Ensemble)
// Empty list means all providers are enabled (no filtering)
List<String> _enabledProviderIds = [];
// Available music providers discovered from MA
List<ProviderInstance> _availableMusicProviders = [];
// Loading states
bool _isLoading = false;
bool _isLoadingRadio = false;
bool _isLoadingPodcasts = false;
// Home refresh counter - increments to signal home screen to refresh all rows
int _homeRefreshCounter = 0;
// Getters
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
List<String> get enabledProviderIds => _enabledProviderIds;
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
int get homeRefreshCounter => _homeRefreshCounter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// Whether player filtering is active
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
/// Whether user provider filter is active
bool get hasEnabledProviderFilter => _enabledProviderIds.isNotEmpty;
// Search state management
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
notifyListeners();
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
notifyListeners();
}
// Filter management
void setProviderFilter(List<String> filter) {
_providerFilter = filter;
notifyListeners();
}
void setPlayerFilter(List<String> filter) {
_playerFilter = filter;
notifyListeners();
}
void setAvailableMusicProviders(List<ProviderInstance> providers) {
_availableMusicProviders = providers;
notifyListeners();
}
void setEnabledProviderIds(List<String> enabledIds) {
_enabledProviderIds = enabledIds;
notifyListeners();
}
/// Toggle a specific music provider on/off
void toggleProviderEnabled(String instanceId, bool enabled) {
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
// Don't allow disabling the last provider
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
return;
}
// Update local state
if (_enabledProviderIds.isEmpty && !enabled) {
// Switch to explicit mode
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
} else if (enabled) {
if (!_enabledProviderIds.contains(instanceId)) {
_enabledProviderIds = [..._enabledProviderIds, instanceId];
}
} else {
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
}
notifyListeners();
}
/// Check if a provider should be visible based on both filters
bool isProviderEnabled(String instanceId) {
// Check MA user filter (server-side restriction)
if (hasProviderFilter && !_providerFilter.contains(instanceId)) {
return false;
}
// Check user filter (local preference)
if (hasEnabledProviderFilter && !_enabledProviderIds.contains(instanceId)) {
return false;
}
return true;
}
/// Check if a player should be visible based on MA user filter
bool isPlayerAllowed(String playerId) {
if (!hasPlayerFilter) return true;
return _playerFilter.contains(playerId);
}
// Loading state management
void setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void setLoadingRadio(bool loading) {
_isLoadingRadio = loading;
notifyListeners();
}
void setLoadingPodcasts(bool loading) {
_isLoadingPodcasts = loading;
notifyListeners();
}
void incrementHomeRefreshCounter() {
_homeRefreshCounter++;
notifyListeners();
}
}
+390 -361
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
@@ -9,8 +11,12 @@ import '../theme/theme_provider.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../services/library_status_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/library_status_builder.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
@@ -32,15 +38,12 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
}
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
final _logger = DebugLogger();
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata
@@ -50,24 +53,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.initLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.initFavoriteStatus(key, true);
}
_loadTracks();
_loadAlbumDescription();
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer fresh data loading until after hero animation completes
// This prevents setState with new image URL during animation → grey icon flash
WidgetsBinding.instance.addPostFrameCallback((_) {
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
@@ -90,8 +111,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Sync fresh data to centralized service
final service = LibraryStatusService.instance;
service.syncSingleItem(
key: libraryItemKey,
inLibrary: freshAlbum.inLibrary,
favorite: freshAlbum.favorite ?? false,
);
// Re-extract colors now that we have fresh album with images
_extractColors();
}
@@ -107,15 +134,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for album: $e');
@@ -124,9 +154,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
final newState = !_isFavorite;
bool success;
if (newState) {
@@ -172,6 +206,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this album');
}
@@ -182,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
completeFavoriteOperation();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
@@ -197,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -220,10 +254,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
final newState = !_isInLibrary;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
@@ -253,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -268,20 +300,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background
maProvider.addToLibrary(
final success = await 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;
});
}
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
// Remove from library
int? libraryItemId;
@@ -302,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -316,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
final success = await 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;
});
}
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -444,6 +469,89 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>();
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
@@ -542,161 +650,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _addAlbumToQueue() {
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: [
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addAlbumToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
}
void _addTrackToQueue(BuildContext context, int index) {
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: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
// Add tracks from this index onwards to queue
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
// Add tracks from this index onwards to queue
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
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 to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
}
void _navigateToArtist() {
@@ -928,7 +935,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -993,7 +1000,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -1013,37 +1019,26 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add Album to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Symbols.book_2,
size: 25,
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
),
child: const Icon(Icons.playlist_add),
),
),
@@ -1062,33 +1057,53 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
Icons.favorite,
size: 25,
color: isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Library Button
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false, // Only show list items since buttons are already visible
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
@@ -1122,138 +1137,78 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: track,
isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
trailing: track.duration != null
? Text(
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
// Single tap to collapse when expanded
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
textAlign: TextAlign.right,
),
),
],
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
onTap: () => _playTrack(index),
),
);
},
childCount: _tracks.length,
@@ -1309,6 +1264,80 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () async {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addAlbumToQueue();
},
),
const SizedBox(height: 8),
],
),
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
File diff suppressed because it is too large Load Diff
+270 -71
View File
@@ -1,16 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '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/media_context_menu.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 '../theme/design_tokens.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
@@ -33,6 +37,7 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -116,6 +121,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}
}
/// Toggle favorite status for an audiobook
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(book.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = book.provider;
String actualItemId = book.itemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => book.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success && mounted) {
setState(() {
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
if (index != -1) {
_audiobooks[index] = Audiobook(
itemId: book.itemId,
provider: book.provider,
name: book.name,
sortName: book.sortName,
uri: book.uri,
providerMappings: book.providerMappings,
favorite: newState,
metadata: book.metadata,
year: book.year,
);
}
});
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
}
}
/// Toggle library status for an audiobook
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !book.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
// Refresh would require reloading from parent - for now just show success
_logger.log('Audiobook library status toggled');
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
@override
Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -150,9 +298,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface,
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;
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
final expandedHeight = coverSize + 60;
return CustomScrollView(
slivers: [
@@ -220,77 +368,84 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
// Author info on the left
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
// Three-dot Menu Button on the right
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
],
),
),
),
// Audiobooks Section Header with controls
// Audiobooks Section Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
child: Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
),
),
@@ -344,10 +499,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0,
@@ -438,7 +614,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
@@ -450,9 +627,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -533,7 +731,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
);
}
+158 -16
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -35,6 +38,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger();
bool _isDescriptionExpanded = false;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedChapterIndex;
@@ -46,17 +50,24 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
void initState() {
super.initState();
_isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
@@ -107,15 +118,18 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for audiobook: $e');
@@ -209,6 +223,88 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.audiobook.provider != 'library') {
actualProvider = widget.audiobook.provider;
actualItemId = widget.audiobook.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -556,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -652,7 +748,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
// Play/Resume Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -686,26 +781,33 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.replay),
child: const Icon(Icons.replay, size: 25),
),
),
],
const SizedBox(width: 12),
// Play On Button
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
),
@@ -724,10 +826,50 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
+2 -1
View File
@@ -271,7 +271,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
// Use read since we only need the provider for non-reactive operations (getImageUrl)
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
+2 -7
View File
@@ -41,12 +41,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() {
// Copy only the filtered entries that are currently displayed
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: filteredLogs));
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -63,8 +61,6 @@ 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);
@@ -368,7 +364,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
child: FloatingActionButton(
onPressed: () {
setState(() {});
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
+43 -10
View File
@@ -6,12 +6,52 @@ import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryAlbumsScreen extends StatelessWidget {
class LibraryAlbumsScreen extends StatefulWidget {
const LibraryAlbumsScreen({super.key});
@override
State<LibraryAlbumsScreen> createState() => _LibraryAlbumsScreenState();
}
class _LibraryAlbumsScreenState extends State<LibraryAlbumsScreen> {
List<Album> _albums = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
final provider = context.read<MusicAssistantProvider>();
_albums = provider.albums;
_isLoading = provider.isLoading;
provider.addListener(_onProviderChanged);
// Auto-load library if empty
if (_albums.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
provider.loadLibrary();
}
});
}
}
@override
void dispose() {
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
super.dispose();
}
void _onProviderChanged() {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
setState(() {
_albums = provider.albums;
_isLoading = provider.isLoading;
});
}
@override
Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -34,13 +74,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
),
centerTitle: true,
),
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading),
builder: (context, data, _) {
final (albums, isLoading) = data;
return _buildAlbumsList(context, albums, isLoading);
},
),
body: _buildAlbumsList(context, _albums, _isLoading),
);
}
@@ -93,4 +127,3 @@ class LibraryAlbumsScreen extends StatelessWidget {
);
}
}
+43 -9
View File
@@ -8,12 +8,52 @@ import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryArtistsScreen extends StatelessWidget {
class LibraryArtistsScreen extends StatefulWidget {
const LibraryArtistsScreen({super.key});
@override
State<LibraryArtistsScreen> createState() => _LibraryArtistsScreenState();
}
class _LibraryArtistsScreenState extends State<LibraryArtistsScreen> {
List<Artist> _artists = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
final provider = context.read<MusicAssistantProvider>();
_artists = provider.artists;
_isLoading = provider.isLoading;
provider.addListener(_onProviderChanged);
// Auto-load library if empty
if (_artists.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
provider.loadLibrary();
}
});
}
}
@override
void dispose() {
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
super.dispose();
}
void _onProviderChanged() {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
setState(() {
_artists = provider.artists;
_isLoading = provider.isLoading;
});
}
@override
Widget build(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -36,13 +76,7 @@ class LibraryArtistsScreen extends StatelessWidget {
),
centerTitle: true,
),
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) {
final (artists, isLoading) = data;
return _buildArtistsList(context, artists, isLoading);
},
),
body: _buildArtistsList(context, _artists, _isLoading),
);
}
+4 -3
View File
@@ -43,7 +43,6 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -66,11 +65,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
centerTitle: true,
),
body: _buildPlaylistsList(context, provider),
body: _buildPlaylistsList(context),
);
}
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
Widget _buildPlaylistsList(BuildContext context) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
+16 -11
View File
@@ -11,7 +11,6 @@ class LibraryTracksScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -34,35 +33,41 @@ class LibraryTracksScreen extends StatelessWidget {
),
centerTitle: true,
),
body: _buildTracksList(context, provider),
body: _buildTracksList(context),
);
}
Widget _buildTracksList(
BuildContext context, MusicAssistantProvider provider) {
Widget _buildTracksList(BuildContext context) {
// Use select to only rebuild when tracks or isLoading changes
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
(p) => (p.tracks, p.isLoading),
);
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
if (tracks.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.tracks.isEmpty) {
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
if (tracks.isEmpty) {
return EmptyState.tracks(
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
}
return ListView.builder(
itemCount: provider.tracks.length,
itemCount: tracks.length,
padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) {
final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index);
final track = tracks[index] as Track;
return _buildTrackTile(context, track, index);
},
);
}
@@ -70,9 +75,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile(
BuildContext context,
Track track,
MusicAssistantProvider maProvider,
int index,
) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128)
: null;
File diff suppressed because it is too large Load Diff
+311 -33
View File
@@ -1,8 +1,8 @@
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 '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
@@ -17,6 +17,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -48,6 +49,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Discovery folders (dynamic rows from provider recommendations)
List<RecommendationFolder> _discoveryFolders = [];
bool _isLoadingDiscoveryFolders = false;
// Discovery row preferences (itemId -> enabled)
Map<String, bool> _discoveryRowEnabled = {};
@override
bool get wantKeepAlive => true;
@@ -57,6 +63,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override
void initState() {
@@ -67,12 +74,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
}
@override
void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
// Clean up provider listener
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
@@ -97,21 +110,35 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
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;
if (!mounted || _providerListeningTo == null) return;
final provider = _providerListeningTo!;
final currentRefreshCounter = provider.homeRefreshCounter;
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
if (currentRefreshCounter != _lastHomeRefreshCounter) {
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
_lastHomeRefreshCounter = currentRefreshCounter;
setState(() {
_refreshKey = UniqueKey();
_refreshKey = UniqueKey(); // Forces all rows to rebuild and re-fetch data
});
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
@@ -157,6 +184,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -172,8 +200,91 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders dynamically (after settings load)
// This will add enabled discovery rows to the home order
await _loadDiscoveryFolders(forceRefresh: false);
}
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
// Set loading state if not already loaded
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
try {
var folders = await provider.getDiscoveryFoldersWithCache();
// If cached result is empty, force a refresh to get from API/database
if (folders.isEmpty && !forceRefresh) {
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
}
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
// Add enabled discovery rows to home row order if not already present
if (mounted) {
_addDiscoveryRowsToHomeOrder(folders);
}
} catch (e) {
_logger.log('⚠️ Failed to load discovery folders: $e');
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Add discovery rows to the home row order (at the end, like static rows)
void _addDiscoveryRowsToHomeOrder(List<RecommendationFolder> folders) {
if (!mounted) return;
bool didModify = false;
_logger.log('🔍 Discovery: Processing ${folders.length} folders, prefs: $_discoveryRowEnabled');
for (final folder in folders) {
final discoveryRowId = 'discovery:${folder.itemId}';
// Check if this discovery row is enabled (default to false)
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
_logger.log('🔍 Discovery: ${folder.name} (${folder.itemId}) enabled=$isEnabled, inOrder=${_homeRowOrder.contains(discoveryRowId)}');
if (isEnabled && !_homeRowOrder.contains(discoveryRowId)) {
// Add at the end (consistent with static rows behavior)
_homeRowOrder.add(discoveryRowId);
_logger.log('✅ Discovery: Adding $discoveryRowId to home order at end');
didModify = true;
} else if (!isEnabled && _homeRowOrder.contains(discoveryRowId)) {
// Remove disabled discovery row from order
_homeRowOrder.remove(discoveryRowId);
_logger.log('❌ Discovery: Removing $discoveryRowId from home order');
didModify = true;
}
}
// Save the updated row order to settings and trigger rebuild
if (didModify) {
_logger.log('💾 Discovery: Saving updated home row order: $_homeRowOrder');
SettingsService.setHomeRowOrder(_homeRowOrder);
if (mounted) {
setState(() {}); // Trigger rebuild to show new row order
}
}
}
Future<void> _onRefresh() async {
@@ -227,21 +338,6 @@ 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,
@@ -342,11 +438,34 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++;
}
// Count discovery folders that are NOT in the main row order yet (legacy support during load)
// This handles the brief moment between when discovery folders load and when they're added to the order
if (_discoveryFolders.isNotEmpty) {
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId) && (_discoveryRowEnabled[folder.itemId] ?? false)) {
count++;
}
}
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
// Count as 1 row when loading so RefreshIndicator works
// Only if no discovery rows are in the main order
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
if (!hasDiscoveryRowsInOrder) {
count += 1;
}
}
return count;
}
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
// Discovery folders use the pattern 'discovery:{itemId}'
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
// Default to true if not explicitly set
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
@@ -364,6 +483,57 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
}
/// Build a loading placeholder for discovery rows
Widget _buildDiscoveryLoadingPlaceholder(double rowHeight) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return SizedBox(
height: rowHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
S.of(context)!.discoveryMixes,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
),
const SizedBox(height: 12),
Text(
S.of(context)!.loading,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
),
],
),
);
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height
@@ -402,10 +572,14 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight),
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
),
),
),
),
@@ -415,9 +589,54 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
final rows = <Widget>[];
// Show empty state with refresh hint when no rows are enabled
if (isEmpty) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
rows.add(
SizedBox.expand(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
S.of(context)!.noRowsEnabled,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
S.of(context)!.pullToRefreshHint,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
return rows;
}
// Track which discovery folders have been rendered (from main row order)
final renderedDiscoveryFolders = <String>{};
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
@@ -426,6 +645,42 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
// Track discovery folders that were rendered from main row order
if (rowId.startsWith('discovery:')) {
renderedDiscoveryFolders.add(rowId.substring('discovery:'.length));
}
}
}
// Add legacy discovery folders that aren't in the main row order yet
// This ensures existing discovery folders still show even if not yet in _homeRowOrder
if (_discoveryFolders.isNotEmpty) {
for (final folder in _discoveryFolders) {
if (!renderedDiscoveryFolders.contains(folder.itemId)) {
final discoveryRowId = 'discovery:${folder.itemId}';
// Check if this specific discovery row is enabled
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
if (isEnabled) {
final widget = _buildRowWidget(discoveryRowId, provider, rowHeight);
if (widget != null) {
// Add spacing between rows
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
}
}
}
}
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
// Show loading placeholder when discovery folders are loading
// Only if no discovery rows are in the main order
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
if (!hasDiscoveryRowsInOrder) {
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(_buildDiscoveryLoadingPlaceholder(rowHeight));
}
}
@@ -541,6 +796,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight,
);
default:
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final folderId = rowId.substring('discovery:'.length);
// Check per-row preference
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
if (!isEnabled) return null;
try {
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == folderId,
);
return DiscoveryRow(
key: ValueKey(rowId),
title: folder.name,
loadItems: () => provider.getDiscoveryFolderItems(folderId),
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
} catch (e) {
// Folder not found, skip this row
}
}
return null;
}
}
File diff suppressed because it is too large Load Diff
+429 -337
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -10,6 +11,8 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
@@ -41,9 +44,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -55,15 +58,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() {
super.initState();
_isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
_loadTracks();
// Defer color extraction until after hero animation completes
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
}
@@ -74,15 +77,18 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for playlist: $e');
@@ -227,6 +233,86 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.playlist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.playlist.provider != 'library') {
actualProvider = widget.playlist.provider;
actualItemId = widget.playlist.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.playlist.provider == 'library') {
libraryItemId = int.tryParse(widget.playlist.itemId);
} else if (widget.playlist.providerMappings != null) {
final libraryMapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.playlist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
// Silent failure
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -324,6 +410,89 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
}
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _playPlaylist() async {
if (_tracks.isEmpty) return;
@@ -387,158 +556,135 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
}
void _addPlaylistToQueue() {
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
},
),
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () async {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
try {
await maProvider.addTracksToQueue(player.playerId, _tracks);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
}
}
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addPlaylistToQueue();
},
),
const SizedBox(height: 8),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -741,7 +887,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -782,7 +928,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -802,42 +947,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add to Queue" Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addPlaylistToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
@@ -851,10 +960,53 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
),
),
@@ -888,168 +1040,108 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: track,
isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 28,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 28,
width: 40,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
],
),
// Expandable action buttons
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
onTap: () => _playTrack(index),
),
);
},
childCount: _tracks.length,
+375 -193
View File
@@ -1,16 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
@@ -36,7 +43,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
bool _isFavorite = false;
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,16 +52,109 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
_isFavorite = widget.podcast.favorite ?? false;
_loadSortPreference();
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
_loadEpisodes();
});
}
Future<void> _loadSortPreference() async {
final sortOrder = await SettingsService.getPodcastEpisodesSortOrder();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
});
}
}
void _toggleSortOrder() {
// Cycle through sort orders: newest -> oldest -> alpha -> duration -> newest
String newOrder;
switch (_sortOrder) {
case 'newest':
newOrder = 'oldest';
break;
case 'oldest':
newOrder = 'alpha';
break;
case 'alpha':
newOrder = 'duration';
break;
default:
newOrder = 'newest';
}
setState(() {
_sortOrder = newOrder;
_sortEpisodes();
});
SettingsService.setPodcastEpisodesSortOrder(newOrder);
}
void _sortEpisodes() {
switch (_sortOrder) {
case 'newest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return bDate.compareTo(aDate); // Newest first
});
break;
case 'oldest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return aDate.compareTo(bDate); // Oldest first
});
break;
case 'alpha':
_episodes.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case 'duration':
_episodes.sort((a, b) {
final aDur = a.duration ?? Duration.zero;
final bDur = b.duration ?? Duration.zero;
return aDur.compareTo(bDur); // Shortest first
});
break;
}
}
DateTime? _getPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
if (dateValue is String) {
return DateTime.tryParse(dateValue);
} else if (dateValue is int) {
return DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
} catch (e) {
// Silent failure
}
return null;
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
@@ -186,6 +287,71 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
/// Toggle favorite status
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
try {
bool success;
if (newState) {
String actualProvider = widget.podcast.provider;
String actualItemId = widget.podcast.itemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final mapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.podcast.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.podcast.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'podcast',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
setState(() => _isFavorite = newState);
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling podcast favorite: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
@@ -193,14 +359,17 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
@@ -261,6 +430,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
setState(() {
_episodes = episodes;
_sortEpisodes();
});
}
}
@@ -406,6 +576,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
/// Show context menu for an episode
void _showEpisodeContextMenu(BuildContext context, Offset position, MediaItem episode, String? episodeImageUrl) {
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcastEpisode,
item: episode,
isFavorite: false,
isInLibrary: false,
onPlay: () => _playEpisode(episode),
onPlayOn: () => _showPlayOnMenu(context, episode),
onAddToQueue: () => _addToQueue(episode),
onViewDetails: () {
final maProvider = this.context.read<MusicAssistantProvider>();
final initialImageUrl = episodeImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
Navigator.push(
this.context,
FadeSlidePageRoute(
child: PodcastEpisodeDetailScreen(
episode: episode,
podcast: widget.podcast,
initialImageUrl: initialImageUrl,
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -537,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -569,7 +768,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -591,24 +789,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
@@ -622,10 +802,76 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Favorite button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoadingEpisodes || _episodes.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoadingEpisodes || _episodes.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
),
),
@@ -732,190 +978,126 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
return GestureDetector(
onTap: () => _playEpisode(episode),
onLongPressStart: (details) => _showEpisodeContextMenu(
context,
details.globalPosition,
episode,
episodeImageUrl,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover
Hero(
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
],
],
),
),
),
],
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
);
},
childCount: _episodes.length,
@@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastEpisodeDetailScreen extends StatefulWidget {
final MediaItem episode;
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastEpisodeDetailScreen({
super.key,
required this.episode,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastEpisodeDetailScreen> createState() => _PodcastEpisodeDetailScreenState();
}
class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors();
}
});
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Try episode image first, fallback to podcast image
final imageUrl = widget.initialImageUrl ??
maProvider.getImageUrl(widget.episode, size: 512) ??
maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Error extracting colors for episode: $e');
}
}
}
void _playEpisode() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, widget.episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${widget.episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu();
}
}
void _showPlayOnMenu() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, widget.episode);
},
);
}
Future<void> _addToQueue() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
final track = Track(
itemId: widget.episode.itemId,
provider: widget.episode.provider,
name: widget.episode.name,
uri: widget.episode.uri,
duration: widget.episode.duration,
metadata: widget.episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '$minutes min';
}
String? _formatPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('Error parsing release date: $e');
}
return null;
}
String _stripHtml(String htmlText) {
final withoutTags = htmlText.replaceAll(RegExp(r'<[^>]*>'), '');
final decoded = withoutTags
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
return decoded.replaceAll(RegExp(r'\s+'), ' ').trim();
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final episodeImageUrl = maProvider.getImageUrl(widget.episode, size: 512);
final podcastImageUrl = maProvider.getPodcastImageUrl(widget.podcast);
final imageUrl = widget.initialImageUrl ?? episodeImageUrl ?? podcastImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.episode.metadata?['description'] as String?;
final duration = widget.episode.duration;
final publishDate = _formatPublishDate(widget.episode.metadata);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastEpisodeCover +
(widget.episode.uri ?? widget.episode.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: coverSize,
height: coverSize,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
if (widget.episode.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.episode.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
],
),
),
),
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
widget.episode.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
// Podcast name (tappable to go back)
InkWell(
onTap: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.podcast.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
),
Spacing.vGap16,
// Duration and publish date
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (duration != null && duration > Duration.zero && publishDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (publishDate != null)
Text(
publishDate,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 24),
// Action buttons row
Row(
children: [
// Main Play Button
Expanded(
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _playEpisode,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// Play On... Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _showPlayOnMenu,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 25),
),
),
const SizedBox(width: 12),
// Add to Queue Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 25),
),
),
],
),
],
),
),
),
// Full scrollable description (key feature)
if (description != null && description.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap12,
// Full description - no maxLines, fully scrollable with selectable text
SelectableText(
_stripHtml(description),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
),
],
),
),
),
// Bottom spacing for mini player
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
),
);
}
}
-257
View File
@@ -1,257 +0,0 @@
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,
),
),
],
],
),
),
],
),
),
],
),
);
}
}
+6 -5
View File
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
// Use select to only rebuild when selectedPlayer changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
@@ -153,7 +153,7 @@ class _QueueScreenState extends State<QueueScreen> {
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider),
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()),
),
IconButton(
icon: const Icon(Icons.refresh),
@@ -161,11 +161,12 @@ class _QueueScreenState extends State<QueueScreen> {
),
],
),
body: _buildBody(maProvider),
body: _buildBody(context),
);
}
Widget _buildBody(MusicAssistantProvider maProvider) {
Widget _buildBody(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
File diff suppressed because it is too large Load Diff
+564 -76
View File
@@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -21,6 +26,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
@@ -39,19 +45,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
rows.add(discoveryRowId);
}
}
return rows;
}
@override
void initState() {
super.initState();
_loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
}
Future<void> _loadSettings() async {
@@ -85,9 +149,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder();
// 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
@@ -96,6 +158,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -111,14 +176,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
}
@override
@@ -128,9 +195,84 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.dispose();
}
/// Load discovery folders from Music Assistant
Future<void> _loadDiscoveryFolders() async {
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
final provider = context.read<MusicAssistantProvider>();
try {
final folders = await provider.getDiscoveryFoldersWithCache();
// Also reload discovery row preferences to ensure they're up to date
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Refresh discovery folders from Music Assistant
Future<void> _refreshDiscoveryFolders() async {
final provider = context.read<MusicAssistantProvider>();
try {
setState(() {
_isLoadingDiscoveryFolders = true;
});
await provider.refreshDiscoveryFolders();
final folders = provider.getCachedDiscoveryFolders() ?? [];
if (mounted) {
setState(() {
_discoveryFolders = folders;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Toggle a specific discovery row on/off
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
setState(() {
_discoveryRowEnabled[itemId] = enabled;
});
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == itemId,
orElse: () => RecommendationFolder(
itemId: itemId,
provider: 'unknown',
name: rowId,
items: [],
),
);
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
}
switch (rowId) {
case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -138,6 +280,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
@@ -163,6 +307,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
@@ -196,6 +345,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
_discoveryRowEnabled[itemId] = value;
SettingsService.setDiscoveryRowPreference(itemId, value);
return;
}
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
@@ -267,7 +424,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
// Use select to only rebuild when connectionState changes
final connectionState = context.select<MusicAssistantProvider, MAConnectionState>((p) => p.connectionState);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -295,9 +453,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DebugLogScreen(),
),
FadeSlidePageRoute(child: const DebugLogScreen()),
);
},
color: colorScheme.onBackground,
@@ -345,15 +501,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getStatusIcon(provider.connectionState),
color: _getStatusColor(provider.connectionState, colorScheme),
_getStatusIcon(connectionState),
color: _getStatusColor(connectionState, colorScheme),
size: 20,
),
const SizedBox(width: 8),
Text(
_getStatusText(provider.connectionState),
_getStatusText(connectionState),
style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(provider.connectionState, colorScheme),
color: _getStatusColor(connectionState, colorScheme),
fontWeight: FontWeight.bold,
),
),
@@ -703,33 +859,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -754,7 +883,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -763,36 +892,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
S.of(context)!.disableAutoSwitch,
S.of(context)!.volumePrecisionMode,
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(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
S.of(context)!.volumePrecisionModeDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
@@ -903,7 +1007,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Reorderable home rows list
// Reorderable home rows list (exclude discovery-mixes from list)
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -914,24 +1018,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _homeRowOrder.length,
itemCount: _filteredRowOrder.length,
onReorder: (oldIndex, newIndex) {
// Create a mutable copy of the filtered list for reordering
final displayRows = List<String>.from(_filteredRowOrder);
if (newIndex > oldIndex) newIndex--;
final item = displayRows.removeAt(oldIndex);
displayRows.insert(newIndex, item);
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
// Update _homeRowOrder with the new order
_homeRowOrder.clear();
// Add discovery rows that are enabled
for (final rowId in displayRows) {
if (rowId.startsWith('discovery:')) {
// Only add enabled discovery rows to home order
final itemId = rowId.substring('discovery:'.length);
if (_discoveryRowEnabled[itemId] ?? false) {
_homeRowOrder.add(rowId);
}
} else {
_homeRowOrder.add(rowId);
}
}
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _homeRowOrder[index];
final rowId = _filteredRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _homeRowOrder.length - 1
border: index < _filteredRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
@@ -1062,6 +1184,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// About & Support section
Text(
S.of(context)!.aboutAndSupport,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Support Development banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFEA4AAA).withOpacity(0.15),
Color(0xFF1F6FEB).withOpacity(0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.favorite_rounded,
color: Color(0xFFEA4AAA),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
S.of(context)!.supportDevelopment,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
S.of(context)!.ensembleIsUnofficial,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
// GitHub Sponsors button
Expanded(
child: _DonationButton(
label: S.of(context)!.sponsorOnGitHub,
color: Color(0xFF1F6FEB),
iconWidget: Icon(MdiIcons.github, size: 20),
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
),
),
const SizedBox(width: 12),
// Ko-fi button
Expanded(
child: _DonationButton(
icon: null,
label: S.of(context)!.buyMeAKoFi,
color: Color(0xFFFF5E5B),
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// About Ensemble card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.aboutEnsemble,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
S.of(context)!.version,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(width: 4),
Text(
_appVersion,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
_LinkButton(
icon: Icons.code_rounded,
label: S.of(context)!.visitGitHubRepo,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
),
const SizedBox(height: 8),
_LinkButton(
icon: Icons.bug_report_outlined,
label: S.of(context)!.reportABug,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -1139,4 +1404,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
/// Build the discovery section with individual row toggles
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
final s = S.of(context)!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and refresh button
Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
s.discoveryRows,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
if (_isLoadingDiscoveryFolders)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _refreshDiscoveryFolders,
tooltip: s.refreshDiscoveryRows,
color: colorScheme.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
const SizedBox(height: 12),
// Discovery rows list
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
s.noDiscoveryRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
)
else
..._discoveryFolders.map((folder) {
final itemId = folder.itemId;
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SwitchListTile(
title: Text(
folder.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
subtitle: folder.provider.isNotEmpty
? Text(
folder.provider,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 11,
),
)
: null,
value: isEnabled,
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
dense: true,
),
);
}).toList(),
],
),
);
}
}
// Custom donation button widget
class _DonationButton extends StatelessWidget {
final String? icon;
final String label;
final Color color;
final VoidCallback onTap;
final Widget? iconWidget;
const _DonationButton({
this.icon,
required this.label,
required this.color,
required this.onTap,
this.iconWidget,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconWidget != null)
iconWidget!
else if (icon != null)
Image.asset(
icon!,
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
// Custom link button widget
class _LinkButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _LinkButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.primary.withOpacity(0.7),
),
],
),
),
),
);
}
}
+126 -482
View File
@@ -1,542 +1,186 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
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
/// Simple authentication manager for Music Assistant
/// MA now uses its own built-in authentication system
class AuthManager {
final _logger = DebugLogger();
// Available auth strategies
final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
String? _accessToken;
String? _longLivedToken;
AuthStrategy? _currentStrategy;
AuthCredentials? _currentCredentials;
/// Get the best available token (prefer long-lived)
String? get token => _longLivedToken ?? _accessToken;
/// Get current auth strategy (null if none selected)
AuthStrategy? get currentStrategy => _currentStrategy;
/// Check if we have valid credentials
bool get hasCredentials => token != null;
/// Get current credentials (null if none stored)
AuthCredentials? get currentCredentials => _currentCredentials;
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// 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.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 (_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
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
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.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
// First, check if this is a Music Assistant server with native auth
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) {
if (maAuthResult == 'required') {
_logger.log('✓ Detected Music Assistant native authentication');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
} else if (maAuthResult == 'none') {
_logger.log('✓ Music Assistant server - no auth required');
return _strategies.firstWhere((s) => s.name == 'none');
}
}
// Try no-auth (direct connection without any auth)
_logger.log('Testing no-auth strategy...');
final canConnect = await _canConnectWithoutAuth(baseUrl);
_logger.log('No-auth result: $canConnect');
if (canConnect) {
_logger.log('✓ Server does not require authentication');
return _strategies.firstWhere((s) => s.name == 'none');
}
// Probe server for auth requirements (reverse proxy scenarios)
/// Login to Music Assistant and get an access token
Future<String?> login(String serverUrl, String username, String password) async {
try {
_logger.log('Probing server for auth headers...');
// Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final apiUrl = _buildApiUrl(serverUrl);
_logger.log('🔐 Logging in to Music Assistant at $apiUrl');
final response = await http.get(
Uri.parse(probeUrl),
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}');
// Check for redirect (common with reverse proxy auth)
if (response.statusCode == 302 || response.statusCode == 307) {
final location = response.headers['location'];
_logger.log('Redirect to: $location');
// If redirects to Authelia login, it's Authelia
if (location != null && location.contains('authelia')) {
_logger.log('✓ Detected Authelia (redirect)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
}
// Server returned 401 Unauthorized - check WWW-Authenticate header
if (response.statusCode == 401) {
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
_logger.log('401 with WWW-Authenticate: $wwwAuth');
// Check for Basic Auth
if (wwwAuth != null && wwwAuth.contains('basic')) {
_logger.log('✓ Detected Basic Authentication');
return _strategies.firstWhere((s) => s.name == 'basic');
}
// Check for Bearer token - could be MA native auth
if (wwwAuth != null && wwwAuth.contains('bearer')) {
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
}
}
// If we got here and there's any auth challenge, assume Authelia
// (since we're behind a reverse proxy that requires auth)
if (response.statusCode >= 300) {
_logger.log('Server requires authentication (status ${response.statusCode})');
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
return null;
}
/// Check if server is a Music Assistant server with native auth
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
// 401/403 means auth required - check this first
if (response.statusCode == 401 || response.statusCode == 403) {
_logger.log('MA API returned ${response.statusCode} - auth required');
return 'required';
}
// Also check for "Authentication required" text response
if (response.body.toLowerCase().contains('authentication required')) {
_logger.log('MA API returned auth required message');
return 'required';
}
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
// Try to parse as JSON
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check if we got server info successfully (no auth required)
if (data.containsKey('result')) {
final result = data['result'] as Map<String, dynamic>?;
final serverVersion = result?['server_version'] as String?;
final schemaVersion = result?['schema_version'] as int?;
if (serverVersion != null) {
_logger.log('MA server version: $serverVersion, schema: $schemaVersion');
// Schema 28+ requires auth, but if we got here without auth, it's not enabled
return 'none';
}
}
// Check for auth error in JSON
if (data.containsKey('error_code')) {
final errorCode = data['error_code'] as int?;
// Error 50 = AuthenticationRequired
if (errorCode == 50) {
_logger.log('MA server requires authentication (error 50)');
return 'required';
}
}
} catch (jsonError) {
_logger.log('MA API response not JSON: $jsonError');
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
final result = data['result'] as Map<String, dynamic>?;
_accessToken = result?['access_token'] as String?;
if (_accessToken != null) {
_logger.log('✓ Got access token from MA');
// Try to create a long-lived token
_longLivedToken = await _createLongLivedToken(apiUrl, _accessToken!);
return _longLivedToken ?? _accessToken;
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
return null;
} catch (e) {
_logger.log('MA auth check error: $e');
_logger.log('✗ Login error: $e');
return null;
}
}
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
_logger.log('Creating long-lived token...');
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
// 200 = no auth required
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (statusCode == 200) {
return true;
/// Validate a stored token
Future<bool> validateToken(String serverUrl, String storedToken) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $storedToken',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
_longLivedToken = storedToken;
return true;
}
}
return false;
} catch (e) {
_logger.log('No-auth error: $e');
_logger.log('Token validation failed: $e');
return false;
}
}
/// Attempt login with specified strategy
/// Returns true if login successful and credentials stored
/// authServerUrl is optional - only used for Authelia when auth is on a different domain
Future<bool> login(
String serverUrl,
String username,
String password,
AuthStrategy strategy, {
String? authServerUrl,
}) async {
_logger.log('Attempting login with ${strategy.name} strategy');
// Use auth server URL if provided, otherwise use main server URL
final loginUrl = authServerUrl?.isNotEmpty == true ? authServerUrl! : serverUrl;
_logger.log('Login URL: $loginUrl');
final credentials = await strategy.login(loginUrl, username, password);
if (credentials != null) {
_currentStrategy = strategy;
_currentCredentials = credentials;
_logger.log('✓ Login successful with ${strategy.name}');
return true;
}
_logger.log('✗ Login failed with ${strategy.name}');
return false;
}
/// Validate current credentials are still valid
Future<bool> validateCurrentCredentials(String serverUrl) async {
if (_currentStrategy == null || _currentCredentials == null) {
return false;
}
return await _currentStrategy!.validateCredentials(
serverUrl,
_currentCredentials!,
);
}
/// Get WebSocket connection headers for current credentials
/// Build headers for WebSocket connection
Map<String, dynamic> getWebSocketHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
return {};
}
/// Get HTTP streaming headers for current credentials
/// Build headers for HTTP streaming requests
Map<String, String> getStreamingHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
return {};
}
/// Serialize current credentials for persistent storage
Map<String, dynamic>? serializeCredentials() {
if (_currentStrategy == null || _currentCredentials == null) {
return null;
}
return {
'strategy': _currentStrategy!.name,
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
};
/// Set token from external source (e.g., restored from settings)
void setToken(String? storedToken) {
_longLivedToken = storedToken;
}
/// Deserialize and restore credentials from persistent storage
void deserializeCredentials(Map<String, dynamic> stored) {
final strategyName = stored['strategy'] as String?;
final data = stored['data'] as Map<String, dynamic>?;
if (strategyName == null || data == null) {
return;
}
// Find matching strategy
try {
final strategy = _strategies.firstWhere((s) => s.name == strategyName);
_currentStrategy = strategy;
_currentCredentials = strategy.deserializeCredentials(data);
_logger.log('✓ Restored ${strategy.name} credentials from storage');
} catch (e) {
_logger.log('✗ Could not restore credentials: $e');
}
}
/// Clear current authentication state
/// Clear authentication state
void logout() {
_currentStrategy = null;
_currentCredentials = null;
_accessToken = null;
_longLivedToken = null;
_logger.log('Logged out - cleared auth state');
}
/// Get strategy by name (for manual selection)
AuthStrategy? getStrategyByName(String name) {
try {
return _strategies.firstWhere((s) => s.name == name);
} catch (e) {
return null;
/// Build API URL from server URL
Uri _buildApiUrl(String serverUrl) {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'http://$baseUrl';
}
final uri = Uri.parse(baseUrl);
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
}
}
-42
View File
@@ -1,42 +0,0 @@
/// Authentication credentials container
class AuthCredentials {
final String strategyName;
final Map<String, dynamic> data;
AuthCredentials(this.strategyName, this.data);
}
/// Abstract authentication strategy interface
/// Implementations handle different auth methods (none, basic, Authelia, HA, etc.)
abstract class AuthStrategy {
/// Strategy name identifier (e.g., 'none', 'basic', 'authelia')
String get name;
/// Attempt login and return auth credentials/token
/// Returns null if login fails
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
);
/// Test if current credentials are still valid
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
);
/// Build headers for WebSocket connection
/// Example: {'Authorization': 'Basic xyz'} or {'Cookie': 'session=abc'}
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials);
/// Build headers for HTTP streaming requests
/// Example: {'Authorization': 'Basic xyz'}
Map<String, String> buildStreamingHeaders(AuthCredentials credentials);
/// Serialize credentials for persistent storage
Map<String, dynamic> serializeCredentials(AuthCredentials credentials);
/// Deserialize credentials from persistent storage
AuthCredentials deserializeCredentials(Map<String, dynamic> data);
}
-172
View File
@@ -1,172 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Authelia authentication strategy
/// Used when Music Assistant is behind Authelia reverse proxy authentication
/// Authelia-specific endpoints: /api/firstfactor, /api/verify
class AutheliaStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'authelia';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
_logger.log('🔐 Attempting Authelia login to $baseUrl');
// Parse URL and construct Authelia firstfactor endpoint
final uri = Uri.parse(baseUrl);
final authUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/firstfactor',
);
_logger.log('Auth URL: $authUrl');
// POST to Authelia's firstfactor endpoint
final response = await http.post(
authUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
'keepMeLoggedIn': true,
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
// Check for successful authentication
if (response.statusCode == 200) {
_logger.log('✓ Authentication successful');
// Extract session cookie from Set-Cookie header
final cookies = response.headers['set-cookie'];
if (cookies != null && cookies.isNotEmpty) {
_logger.log('✓ Received session cookie');
final sessionCookie = _extractSessionCookie(cookies);
if (sessionCookie != null) {
_logger.log('✓ Extracted session cookie');
return AuthCredentials('authelia', {
'session_cookie': sessionCookie,
'username': username,
});
}
}
// If 200 but no cookie, something is wrong
_logger.log('✗ No session cookie in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final sessionCookie = credentials.data['session_cookie'] as String?;
if (sessionCookie == null) return false;
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
// Parse URL and construct Authelia verify endpoint
final uri = Uri.parse(baseUrl);
final verifyUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/verify',
);
final response = await http.get(
verifyUrl,
headers: {
'Cookie': 'authelia_session=$sessionCookie',
},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
_logger.log('Auth validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('authelia', data);
}
/// Extract authelia_session cookie value from Set-Cookie header
/// Migrated from auth_service.dart:81-98
String? _extractSessionCookie(String setCookieHeader) {
final cookies = setCookieHeader.split(',');
for (final cookie in cookies) {
if (cookie.trim().startsWith('authelia_session=')) {
final parts = cookie.split(';');
if (parts.isNotEmpty) {
final value = parts[0].trim();
if (value.startsWith('authelia_session=')) {
return value.substring('authelia_session='.length);
}
}
}
}
return null;
}
}
@@ -1,82 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// Basic HTTP Authentication strategy
/// Used by reverse proxies (nginx, Apache, Caddy, etc.) configured with basic auth
/// Example: Authorization: Basic base64(username:password)
class BasicAuthStrategy implements AuthStrategy {
@override
String get name => 'basic';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// Encode credentials to base64
final credentials = base64Encode(utf8.encode('$username:$password'));
// Test credentials against server root endpoint
// (Music Assistant doesn't have /api/info)
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $credentials'},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('basic', {
'credentials': credentials,
'username': username, // Store for re-login if needed
});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final base64Creds = credentials.data['credentials'] as String?;
if (base64Creds == null) return false;
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $base64Creds'},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('basic', data);
}
}
-254
View File
@@ -1,254 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Music Assistant native authentication strategy
/// Used when MA server has built-in authentication enabled (schema 28+)
///
/// Flow:
/// 1. Connect WebSocket (receives server_info with auth requirements)
/// 2. If auth required, login with username/password to get access token
/// 3. Send 'auth' command with token to authenticate the session
/// 4. Optionally create long-lived token for persistent storage
class MusicAssistantAuthStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'music_assistant';
/// Login to Music Assistant and get an access token
/// This is called AFTER WebSocket connection is established
/// Returns credentials containing the access token
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Determine protocol based on URL pattern
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl';
} else {
baseUrl = 'https://$baseUrl';
}
}
_logger.log('🔐 Attempting Music Assistant login to $baseUrl');
// MA auth endpoint - POST /api with JSON-RPC style command
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
_logger.log('Auth API URL: $apiUrl');
// Login request to get access token
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check for error in response
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
// Extract access token from result
final result = data['result'] as Map<String, dynamic>?;
final accessToken = result?['access_token'] as String?;
if (accessToken != null) {
_logger.log('✓ Got access token from MA');
// Now try to create a long-lived token for persistent auth
final longLivedToken = await _createLongLivedToken(apiUrl, accessToken);
return AuthCredentials('music_assistant', {
'access_token': accessToken,
'long_lived_token': longLivedToken,
'username': username,
'server_url': baseUrl,
});
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
_logger.log('Creating long-lived token...');
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
}
}
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Try long-lived token first, then access token
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token == null) return false;
try {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
// Test token by getting server info
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return !data.containsKey('error_code');
}
return false;
} catch (e) {
_logger.log('Token validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// MA auth happens AFTER WebSocket connection, not via headers
// But we can still include token if available for initial handshake
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('music_assistant', data);
}
/// Get the best available token (prefer long-lived)
String? getToken(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
return longLivedToken ?? accessToken;
}
}
-64
View File
@@ -1,64 +0,0 @@
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// No authentication strategy for open Music Assistant servers
/// Typical scenarios:
/// - Local network deployment (http://192.168.1.100:8095)
/// - Home network without reverse proxy
/// - Development servers
class NoAuthStrategy implements AuthStrategy {
@override
String get name => 'none';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// No authentication required - just test server connectivity
try {
final response = await http.get(
Uri.parse('$serverUrl/api/info'),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('none', {});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Always valid for no-auth
return true;
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return {};
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('none', {});
}
}
+99 -1
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
@@ -10,6 +11,7 @@ import '../constants/timings.dart';
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -20,12 +22,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
@@ -55,6 +59,14 @@ class CacheService {
// HOME SCREEN ROW CACHING
// ============================================================================
/// Ensure home rows are loaded from database before accessing cache
/// This ensures the cache is populated on first access if not already loaded
Future<void> ensureHomeRowsLoaded() async {
if (!_homeRowsLoaded) {
await loadHomeRowsFromDatabase();
}
}
/// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -69,6 +81,11 @@ class CacheService {
/// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty recent albums result');
return;
}
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
@@ -90,6 +107,11 @@ class CacheService {
/// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (artists.isEmpty) {
_logger.log('⚠️ Not caching empty discover artists result');
return;
}
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
@@ -111,6 +133,11 @@ class CacheService {
/// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty discover albums result');
return;
}
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
@@ -118,6 +145,32 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Set cached discovery folders
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (folders.isEmpty) {
_logger.log('⚠️ Not caching empty discovery folders result');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -132,6 +185,11 @@ class CacheService {
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
return;
}
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -151,6 +209,11 @@ class CacheService {
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty discover audiobooks result');
return;
}
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -170,6 +233,11 @@ class CacheService {
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
// Don't cache empty results
if (series.isEmpty) {
_logger.log('⚠️ Not caching empty discover series result');
return;
}
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
@@ -191,6 +259,7 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
@@ -448,10 +517,22 @@ class CacheService {
// ============================================================================
/// Load home row data from database for instant display on startup
/// Returns the existing future if already loading, to prevent duplicate loads
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
// Create the load future
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
await _homeRowsLoadFuture;
_homeRowsLoadFuture = null; // Clear after completion
}
/// Internal implementation of home row loading
Future<void> _doLoadHomeRowsFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
@@ -499,8 +580,25 @@ class CacheService {
_logger.log('⚠️ Failed to parse discover albums: $e');
}
}
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
try {
final items = (jsonDecode(foldersData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoveryFolders = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
}
}
+27
View File
@@ -193,6 +193,33 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]);
}
/// Mark a cached item as deleted by searching for library ID in the data
/// This handles cases where itemId is different from library ID
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
// First try direct match (for provider=library items)
await db.markItemsDeleted(itemType, [libraryId]);
// Also search through cached items to find any with matching library mapping
final items = await db.getCachedItems(itemType);
for (final item in items) {
try {
// Check if this item's data contains a library mapping with this ID
if (item.data.contains('"provider_instance":"library"') &&
item.data.contains('"item_id":"$libraryId"')) {
// Found a match - extract the actual cache key's itemId
final cacheKey = item.cacheKey;
final parts = cacheKey.split('_');
if (parts.length >= 2) {
final actualItemId = parts.sublist(1).join('_');
await db.markItemsDeleted(itemType, [actualItemId]);
}
}
} catch (_) {
// Ignore parse errors, continue checking other items
}
}
}
/// 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.
-38
View File
@@ -7,9 +7,6 @@ enum ErrorType {
network,
playback,
library,
remote, // Remote connection (WebRTC) errors
dataChannel, // WebRTC data channel errors
signaling, // WebRTC signaling server errors
unknown,
}
@@ -110,41 +107,6 @@ 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,
+1 -1
View File
@@ -12,7 +12,7 @@ class HardwareVolumeService {
factory HardwareVolumeService() => _instance;
HardwareVolumeService._internal();
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
final _logger = DebugLogger();
final _volumeUpController = StreamController<void>.broadcast();
+97
View File
@@ -0,0 +1,97 @@
import '../models/media_item.dart';
import '../models/provider_manifest.dart';
import '../services/music_assistant_api.dart';
import '../services/metadata_service.dart';
/// Service for handling image/artwork URL generation and caching.
///
/// Extracted from MusicAssistantProvider to reduce complexity.
class ImageHelperService {
final MusicAssistantAPI? _api;
/// Podcast cover cache: podcastId -> best available cover URL
/// This is populated with episode covers when podcast covers are low-res
final Map<String, String> _podcastCoverCache = {};
/// Callback to get cached track for a player
final Track? Function(String playerId) getCachedTrackForPlayer;
ImageHelperService({
required MusicAssistantAPI? api,
required this.getCachedTrackForPlayer,
}) : _api = api;
/// Get artwork URL for a media item from Music Assistant
String? getImageUrl(MediaItem item, {int size = 256}) {
return _api?.getImageUrl(item, size: size);
}
/// Get best available podcast cover URL
///
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy.
/// The cache is persisted to storage and loaded on app start for instant high-res display.
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
// Return cached iTunes URL if available (persisted across app launches)
final cachedUrl = _podcastCoverCache[podcast.itemId];
if (cachedUrl != null) {
return cachedUrl;
}
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
return _api?.getImageUrl(podcast, size: size);
}
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
///
/// Returns a Future since fallback requires async API calls.
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
// Try Music Assistant first
final maUrl = _api?.getImageUrl(artist, size: size);
if (maUrl != null) {
return maUrl;
}
// Fall back to external sources (Deezer, Fanart.tv, etc.)
return MetadataService.getArtistImageUrl(artist.name);
}
/// Get SVG icon for a provider domain
///
/// Returns null if no icon is available.
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _api?.getProviderManifest(domain);
}
/// Get artwork URL for a player from cache
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
final track = getCachedTrackForPlayer(playerId);
if (track == null) return null;
return getImageUrl(track, size: size);
}
/// Load podcast cover cache from storage for instant high-res display
///
/// Should be called during initialization.
Future<void> loadPodcastCoverCache(Map<String, String> cache) async {
if (cache.isNotEmpty) {
_podcastCoverCache.addAll(cache);
}
}
/// Get the current podcast cover cache (for persistence)
Map<String, String> get podcastCoverCache => Map.unmodifiable(_podcastCoverCache);
/// Update podcast cover cache entry
void updatePodcastCoverCache(String podcastId, String coverUrl) {
_podcastCoverCache[podcastId] = coverUrl;
}
/// Clear all podcast cover cache entries
void clearPodcastCoverCache() {
_podcastCoverCache.clear();
}
}
+189
View File
@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Centralized service for tracking library and favorite status of media items.
///
/// This service implements the "single source of truth" pattern:
/// - All widgets query this service instead of maintaining local state
/// - Status changes notify all listeners immediately (optimistic updates)
/// - API failures trigger rollback notifications
///
/// Usage:
/// ```dart
/// // Check status
/// final isInLibrary = LibraryStatusService.instance.isInLibrary(itemKey);
///
/// // Listen to changes
/// LibraryStatusService.instance.addListener(() {
/// // Rebuild UI
/// });
///
/// // Update status (optimistically)
/// LibraryStatusService.instance.setLibraryStatus(itemKey, true);
/// ```
class LibraryStatusService extends ChangeNotifier {
LibraryStatusService._();
static final LibraryStatusService instance = LibraryStatusService._();
// Status maps: itemKey -> status
// itemKey format: "mediaType:provider:itemId" for uniqueness
final Map<String, bool> _libraryStatus = {};
final Map<String, bool> _favoriteStatus = {};
// Pending operations for rollback on failure
final Map<String, bool> _pendingLibraryOps = {};
final Map<String, bool> _pendingFavoriteOps = {};
/// Generate a unique key for an item
static String makeKey(String mediaType, String provider, String itemId) {
return '$mediaType:$provider:$itemId';
}
/// Check if item is in library
bool isInLibrary(String key) => _libraryStatus[key] ?? false;
/// Check if item is favorite
bool isFavorite(String key) => _favoriteStatus[key] ?? false;
/// Set library status (optimistic update)
/// Call this immediately when user taps the button
void setLibraryStatus(String key, bool inLibrary) {
if (_libraryStatus[key] == inLibrary) return;
_libraryStatus[key] = inLibrary;
notifyListeners();
}
/// Set favorite status (optimistic update)
void setFavoriteStatus(String key, bool favorite) {
if (_favoriteStatus[key] == favorite) return;
_favoriteStatus[key] = favorite;
notifyListeners();
}
/// Initialize library status without notifying listeners.
/// Use during initState to sync service from widget data without triggering rebuilds.
void initLibraryStatus(String key, bool inLibrary) {
_libraryStatus[key] ??= inLibrary;
}
/// Initialize favorite status without notifying listeners.
/// Use during initState to sync service from widget data without triggering rebuilds.
void initFavoriteStatus(String key, bool favorite) {
_favoriteStatus[key] ??= favorite;
}
/// Mark operation as pending (for rollback tracking)
void markLibraryPending(String key, bool previousValue) {
_pendingLibraryOps[key] = previousValue;
}
void markFavoritePending(String key, bool previousValue) {
_pendingFavoriteOps[key] = previousValue;
}
/// Complete operation successfully (clear pending)
void completeLibraryOperation(String key) {
_pendingLibraryOps.remove(key);
}
void completeFavoriteOperation(String key) {
_pendingFavoriteOps.remove(key);
}
/// Rollback operation on failure
void rollbackLibraryOperation(String key) {
final previousValue = _pendingLibraryOps.remove(key);
if (previousValue != null) {
_libraryStatus[key] = previousValue;
notifyListeners();
}
}
void rollbackFavoriteOperation(String key) {
final previousValue = _pendingFavoriteOps.remove(key);
if (previousValue != null) {
_favoriteStatus[key] = previousValue;
notifyListeners();
}
}
/// Bulk update from provider's library data
/// Called when library is loaded/refreshed
void syncFromLibrary({
required String mediaType,
required List<dynamic> items,
required bool Function(dynamic item) getInLibrary,
required bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId,
}) {
bool changed = false;
for (final item in items) {
final key = makeKey(mediaType, getProvider(item), getItemId(item));
final inLibrary = getInLibrary(item);
final favorite = getFavorite(item);
// Only update if not pending (don't overwrite optimistic updates)
if (!_pendingLibraryOps.containsKey(key)) {
if (_libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
}
if (!_pendingFavoriteOps.containsKey(key)) {
if (_favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
}
}
if (changed) {
notifyListeners();
}
}
/// Update a single item's status from fresh data
void syncSingleItem({
required String key,
required bool inLibrary,
required bool favorite,
}) {
bool changed = false;
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
if (changed) {
notifyListeners();
}
}
/// Clear all status (on logout/disconnect)
void clear() {
_libraryStatus.clear();
_favoriteStatus.clear();
_pendingLibraryOps.clear();
_pendingFavoriteOps.clear();
notifyListeners();
}
/// Get debug info
Map<String, dynamic> get debugInfo => {
'libraryCount': _libraryStatus.length,
'favoriteCount': _favoriteStatus.length,
'pendingLibrary': _pendingLibraryOps.length,
'pendingFavorite': _pendingFavoriteOps.length,
};
}
+62 -158
View File
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import '../models/recommendation_folder.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
@@ -25,20 +26,6 @@ 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;
@@ -52,7 +39,7 @@ class MusicAssistantAPI {
MAConnectionState _currentState = MAConnectionState.disconnected;
MAConnectionState get currentConnectionState => _currentState;
final Map<String, _PendingRequest> _pendingRequests = {};
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
Completer<void>? _connectionCompleter;
@@ -79,12 +66,6 @@ class MusicAssistantAPI {
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
@@ -234,6 +215,7 @@ class MusicAssistantAPI {
},
onDone: () {
_logger.log('WebSocket connection closed');
_isAuthenticated = false; // Reset auth state on disconnect
_updateConnectionState(MAConnectionState.disconnected);
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.completeError(Exception('Connection closed'));
@@ -305,15 +287,15 @@ class MusicAssistantAPI {
// Handle response to a request
if (messageId != null && _pendingRequests.containsKey(messageId)) {
final pending = _pendingRequests.remove(messageId);
final completer = _pendingRequests.remove(messageId);
if (data.containsKey('error_code')) {
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
pending!.completer.completeError(
completer!.completeError(
Exception('${data['error_code']}: ${data['details']}'),
);
} else {
pending!.completer.complete(data);
completer!.complete(data);
}
return;
}
@@ -372,9 +354,6 @@ class MusicAssistantAPI {
}
}
// Clean up stale requests before checking limits
_cleanupStaleRequests();
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
@@ -385,7 +364,7 @@ class MusicAssistantAPI {
final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = _PendingRequest(completer: completer);
_pendingRequests[messageId] = completer;
final message = {
'message_id': messageId,
@@ -408,27 +387,6 @@ 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,
@@ -1238,6 +1196,28 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
/// Get the audiobookshelf provider instance ID
Future<String?> getAudiobookshelfProviderId() async {
try {
@@ -2120,6 +2100,37 @@ class MusicAssistantAPI {
}
}
/// Add multiple tracks to queue without interrupting playback
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
final tracksToAdd = startIndex > 0 ? tracks.sublist(startIndex) : tracks;
// Filter to only tracks with available provider mappings
final playableTracks = tracksToAdd.where((track) {
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
return false;
}
return track.providerMappings!.any((m) => m.available);
}).toList();
if (playableTracks.isEmpty) return;
final uris = playableTracks.map((track) => _buildTrackUri(track)).toList();
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': uris,
'option': 'add', // Add to queue without starting playback
},
);
} catch (e) {
_logger.log('Error adding tracks to queue: $e');
rethrow;
}
}
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
@@ -3260,12 +3271,6 @@ 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) {
@@ -3328,107 +3333,6 @@ 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
@@ -3828,8 +3732,8 @@ class MusicAssistantAPI {
/// Cancel all pending requests with an error message
void _cancelPendingRequests(String reason) {
for (final entry in _pendingRequests.entries) {
if (!entry.value.completer.isCompleted) {
entry.value.completer.completeError(Exception(reason));
if (!entry.value.isCompleted) {
entry.value.completeError(Exception(reason));
}
}
_pendingRequests.clear();
+319
View File
@@ -0,0 +1,319 @@
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../services/sync_service.dart';
/// Service for filtering media items and players based on user settings.
///
/// Extracted from MusicAssistantProvider to reduce complexity.
class ProviderFilterService {
/// Provider filter from MA user settings (empty = all providers allowed)
final List<String> providerFilter;
/// Player filter from MA user settings (empty = all players allowed)
final List<String> playerFilter;
/// Available music providers discovered from MA
final List<ProviderInstance> availableMusicProviders;
/// Library data for provider counting
final List<dynamic> artists;
final List<dynamic> albums;
final List<dynamic> tracks;
final List<dynamic> podcasts;
final List<dynamic> radioStations;
ProviderFilterService({
required this.providerFilter,
required this.playerFilter,
required this.availableMusicProviders,
required this.artists,
required this.albums,
required this.tracks,
required this.podcasts,
required this.radioStations,
});
// ===========================================================================
// PROVIDER FILTER (from MA user settings)
// ===========================================================================
/// Whether provider filtering is active
bool get hasProviderFilter => providerFilter.isNotEmpty;
/// Check if a media item should be visible based on provider filter.
///
/// Returns true if:
/// - No filter is active (empty list = all providers allowed)
/// - The item has at least one provider mapping in the allowed list
/// - The item's primary provider is in the allowed list
bool isItemAllowedByProviderFilter(MediaItem item) {
// No filter = show everything
if (providerFilter.isEmpty) return true;
// Check if item's provider mappings include any allowed provider
final mappings = item.providerMappings;
if (mappings != null && mappings.isNotEmpty) {
for (final mapping in mappings) {
if (providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
}
// Also check primary provider field (for items without full mappings)
if (providerFilter.contains(item.provider)) {
return true;
}
return false;
}
/// Filter a list of media items based on provider filter.
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
if (providerFilter.isEmpty) return items;
return items.where(isItemAllowedByProviderFilter).toList();
}
/// Filter search results map based on provider filter.
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
if (providerFilter.isEmpty) return results;
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
};
}
// ===========================================================================
// PLAYER FILTER (from MA user settings)
// ===========================================================================
/// Whether player filtering is active
bool get hasPlayerFilter => playerFilter.isNotEmpty;
/// Check if a player should be visible based on player filter.
bool isPlayerAllowedByFilter(Player player) {
if (playerFilter.isEmpty) return true;
return playerFilter.contains(player.playerId);
}
/// Filter a list of players based on player filter.
List<Player> filterPlayers(List<Player> players) {
if (playerFilter.isEmpty) return players;
return players.where(isPlayerAllowedByFilter).toList();
}
// ===========================================================================
// PROVIDER COUNTING
// ===========================================================================
/// Get providers that have content in artists library (with item counts).
Map<String, int> getProvidersWithArtists() {
return _getProvidersWithCounts(artists);
}
/// Get providers that have content in albums library (with item counts).
Map<String, int> getProvidersWithAlbums() {
return _getProvidersWithCounts(albums);
}
/// Get providers that have content in tracks library (with item counts).
Map<String, int> getProvidersWithTracks() {
return _getProvidersWithCounts(tracks);
}
/// Get providers that have content in playlists (with item counts).
Map<String, int> getProvidersWithPlaylists() {
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
}
/// Get providers that have audiobooks (with item counts).
Map<String, int> getProvidersWithAudiobooks() {
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
}
/// Get providers that have radio stations (with item counts).
Map<String, int> getProvidersWithRadio() {
return _getProvidersWithCounts(radioStations);
}
/// Get providers that have podcasts (with item counts).
///
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the podcast from (vs providers that can play it).
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in podcasts) {
final mappings = (item as MediaItem).providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Internal helper to count items per provider from a list of media items.
///
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the item from (vs providers that can play it).
Map<String, int> _getProvidersWithCounts(List<dynamic> items) {
final counts = <String, int>{};
for (final item in items) {
if (item is! MediaItem) continue;
final mediaItem = item as MediaItem;
final mappings = mediaItem.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Get ProviderInstance objects for providers that have content in a category.
///
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name.
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
final Map<String, int> counts;
switch (category) {
case 'artists':
counts = getProvidersWithArtists();
break;
case 'albums':
counts = getProvidersWithAlbums();
break;
case 'tracks':
counts = getProvidersWithTracks();
break;
case 'playlists':
counts = getProvidersWithPlaylists();
break;
case 'audiobooks':
counts = getProvidersWithAudiobooks();
break;
case 'radio':
counts = getProvidersWithRadio();
break;
case 'podcasts':
counts = getProvidersWithPodcasts();
break;
default:
counts = {};
}
// Build list of providers that support this category, with their item counts
// Only include providers that have at least 1 item (hides providers not synced to library)
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
// but we dedupe if names match exactly (same account re-added with new instance ID)
final result = <(ProviderInstance, int)>[];
final addedInstanceIds = <String>{};
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
for (final provider in availableMusicProviders) {
// Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
if (count > 0) {
final existingCount = addedNames[provider.name];
if (existingCount == null) {
// First provider with this name
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
} else if (count > existingCount) {
// Same name but more items - likely same account re-added, keep better one
result.removeWhere((r) => r.$1.name == provider.name);
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
}
}
}
}
// Also include providers that have items but aren't in availableMusicProviders
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
// For these synthetic providers, dedupe by domain since we don't have account-specific names
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
final addedSyntheticDomains = <String>{};
for (final entry in counts.entries) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
final instanceId = entry.key;
final domain = instanceId.contains('--')
? instanceId.split('--').first
: instanceId;
// Skip if this domain is already represented by a real provider or synthetic
if (addedRealDomains.contains(domain)) continue;
if (addedSyntheticDomains.contains(domain)) continue;
// Only add if this domain supports the category
final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) {
result.add((
ProviderInstance(
instanceId: instanceId,
domain: domain,
name: _formatProviderName(domain),
available: true,
),
entry.value,
));
addedSyntheticDomains.add(domain);
}
}
}
// Sort by name
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
return result;
}
/// Format a provider domain into a readable display name.
String _formatProviderName(String domain) {
const names = {
'spotify': 'Spotify',
'tidal': 'Tidal',
'qobuz': 'Qobuz',
'deezer': 'Deezer',
'ytmusic': 'YouTube Music',
'soundcloud': 'SoundCloud',
'apple_music': 'Apple Music',
'amazon_music': 'Amazon Music',
'plex': 'Plex',
'jellyfin': 'Jellyfin',
'emby': 'Emby',
'subsonic': 'Subsonic',
'opensubsonic': 'OpenSubsonic',
'gmusic': 'Google Music',
'bandcamp': 'Bandcamp',
'youtube': 'YouTube',
'itunes': 'iTunes/Apple',
'spotifyfree': 'Spotify Free',
'tunein': 'TuneIn',
'radio_net': 'Radio.net',
'dirble': 'Dirble',
'freq': 'FREQ',
'radionomy': 'Radionomy',
};
return names[domain] ?? domain.split('_').map((s) => s[0].toUpperCase() + s.substring(1)).join(' ');
}
}
+221
View File
@@ -0,0 +1,221 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/database_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/music_assistant_api.dart';
/// Service for managing player queue operations.
///
/// Extracted from MusicAssistantProvider to isolate queue logic including:
/// - Queue fetching from API with player ID translation
/// - Queue caching to database for instant display
/// - Queue restoration from cache on app resume
/// - Track validation for queue items
class QueueManagerService {
final MusicAssistantAPI? _api;
final DatabaseService _database;
final DebugLogger _logger;
final List<Player> Function() getAvailablePlayers;
final Map<String, String> Function() getCastToSendspinMap;
final Future<void> Function(String playerId, List<Track> tracks, {int startIndex}) playTracks;
QueueManagerService({
required MusicAssistantAPI? api,
required DatabaseService database,
required DebugLogger logger,
required this.getAvailablePlayers,
required this.getCastToSendspinMap,
required this.playTracks,
}) : _api = api,
_database = database,
_logger = logger;
/// Fetch queue for a player from the API.
///
/// Handles:
/// - Group child players (fetches leader's queue)
/// - Cast-to-Sendspin ID translation
/// - Automatic caching to database
Future<PlayerQueue?> getQueue(String playerId) async {
final effectivePlayerId = _getEffectivePlayerId(playerId);
final queue = await _api?.getQueue(effectivePlayerId);
// Persist queue to database for instant display on app resume
if (queue != null) {
persistQueueToDatabase(playerId, queue);
}
return queue;
}
/// Get cached queue for instant display (before API refresh).
Future<PlayerQueue?> getCachedQueue(String playerId) async {
try {
if (!_database.isInitialized) return null;
final cachedItems = await _database.getCachedQueue(playerId);
if (cachedItems.isEmpty) return null;
final items = <QueueItem>[];
for (final cached in cachedItems) {
try {
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
items.add(QueueItem.fromJson(itemData));
} catch (e) {
_logger.log('⚠️ Error parsing cached queue item: $e');
}
}
if (items.isEmpty) return null;
return PlayerQueue(
playerId: playerId,
items: items,
currentIndex: 0, // Will be updated from fresh data
);
} catch (e) {
_logger.log('⚠️ Error loading cached queue: $e');
return null;
}
}
/// Persist queue to database for app restart persistence.
void persistQueueToDatabase(String playerId, PlayerQueue queue) {
() async {
try {
if (!_database.isInitialized) return;
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await _database.saveQueue(playerId, itemJsonList);
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
}();
}
/// Attempt to restore the queue from cached data and start playback.
Future<bool> restoreQueueFromCache(String playerId) async {
try {
final cachedQueue = await getCachedQueue(playerId);
if (cachedQueue == null || cachedQueue.items.isEmpty) {
_logger.log('⚠️ No cached queue to restore');
return false;
}
// Extract and validate tracks from queue items
final tracks = _validateQueueTracks(cachedQueue.items);
if (tracks.isEmpty) {
_logger.log('⚠️ Cached queue has no valid tracks');
return false;
}
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
// Re-queue all tracks and start playback
await playTracks(playerId, tracks, startIndex: 0);
return true;
} catch (e) {
_logger.log('❌ Error restoring queue from cache: $e');
return false;
}
}
/// Validate and filter tracks from queue items.
///
/// Returns only tracks that can be played (have available provider mappings
/// or valid provider/itemId combination).
List<Track> _validateQueueTracks(List<QueueItem> queueItems) {
return queueItems
.map((item) => item.track)
.where((track) {
// Check for providerMappings with at least one available entry
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
return track.providerMappings!.any((m) => m.available);
}
// Fallback: provider + itemId can be used to construct URI
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
})
.toList();
}
/// Get the effective player ID for queue operations.
///
/// Handles:
/// - Group child players (returns leader's ID)
/// - Cast-to-Sendspin ID translation
String _getEffectivePlayerId(String playerId) {
String effectivePlayerId = playerId;
final availablePlayers = getAvailablePlayers();
final castToSendspinMap = getCastToSendspinMap();
final player = availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => Player(
playerId: playerId,
name: '',
available: false,
powered: false,
state: 'idle',
),
);
// If this player is a group child, fetch the leader's queue instead
// This ensures grouped players show the same queue as their leader
if (player.isGroupChild && player.syncedTo != null) {
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
effectivePlayerId = player.syncedTo!;
}
// Translate Sendspin ID to Cast UUID for queue fetch
// MA stores queues under the Cast UUID, not the Sendspin ID
effectivePlayerId = _translateSendspinToCastId(effectivePlayerId, availablePlayers, castToSendspinMap);
return effectivePlayerId;
}
/// Translate Sendspin ID to Cast UUID for queue operations.
///
/// MA stores queues under the Cast UUID, not the Sendspin ID.
/// Format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
String _translateSendspinToCastId(
String playerId,
List<Player> availablePlayers,
Map<String, String> castToSendspinMap,
) {
if (!playerId.startsWith('cast-') || playerId.length < 13) {
return playerId;
}
final prefix = playerId.substring(5); // Remove "cast-"
// Reverse lookup in the map
for (final entry in castToSendspinMap.entries) {
if (entry.value == playerId) {
_logger.log('🔗 Translated Sendspin ID $playerId to Cast UUID ${entry.key} for queue fetch');
return entry.key;
}
}
// If not in map, try to find in available players
for (final p in availablePlayers) {
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $playerId via player lookup');
return p.playerId;
}
}
return playerId;
}
/// Clear cached queue for a player.
Future<void> clearCachedQueue(String playerId) async {
await _database.clearCachedQueue(playerId);
}
}
-675
View File
@@ -1,675 +0,0 @@
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();
}
}
@@ -1,174 +0,0 @@
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
@@ -1,272 +0,0 @@
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
@@ -1,506 +0,0 @@
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();
}
}

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