Compare commits

...
48 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 7912c4b2ef fix: Use 'info' command for heartbeat instead of unsupported 'ping'
MA beta 19 doesn't support 'ping' command, causing "Invalid command"
warnings and connection issues. Changed to 'info' which is a valid
lightweight command.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 14:11:12 +00:00
Your NameandClaude Opus 4.5 ed589e45ee fix: Artist albums not loading - add limit to getAlbums() call
The getArtistAlbumsWithCache() method was calling getAlbums() without
a limit parameter, causing the API to return only the server's default
number of albums (often 25-50). If the artist's albums weren't in this
limited first page, they would not be found.

Added LibraryConstants.maxLibraryItems limit to fetch the full library
and properly filter by artist name.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 12:37:39 +00:00
Your NameandClaude Opus 4.5 0767412765 feat: Phase 8 - WebSocket heartbeat and completer guards
- Add WebSocket heartbeat ping every 30s to detect stale connections
- Add completer guards to prevent double-completion errors in:
  - music_assistant_api.dart (connection completers)
  - music_assistant_provider.dart (registration completers)
  - player_provider.dart (registration completers)
- Add heartbeatInterval constant to Timings class
- Update AUDIT_PROGRESS.md with Phase 8 status

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 12:33:37 +00:00
Your NameandClaude Opus 4.5 79cabeeb40 Update AUDIT_PROGRESS.md with Phase 7 status
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:59:07 +00:00
Your NameandClaude Opus 4.5 b713dfd61a Phase 7: Apply design tokens to extracted widgets
Update queue_panel, player_controls, device_selector_bar, debug_console with:
- Spacing tokens (paddingAll12, paddingH8, paddingH16, vGap12, hGap8, hGap12)
- Radii tokens (sm, md)
- IconSizes tokens (xs, sm, md)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:47:10 +00:00
Your NameandClaude Opus 4.5 d3ab3c17a7 feat: Add design_tokens.dart with spacing, radii, and dimension constants
Phase 7 - Design System foundation:
- Spacing scale (xxs to xxxl following 4px grid)
- Pre-built EdgeInsets and SizedBox widgets
- BorderRadius tokens
- Icon size tokens
- Common dimension tokens (thumbnails, avatars, etc.)
- Animation duration tokens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:43:38 +00:00
Your NameandClaude Opus 4.5 20926ddfdd docs: Update AUDIT_PROGRESS.md for Phase 6 completion
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:42:33 +00:00
Your NameandClaude Opus 4.5 929584cdae refactor: Extract DisconnectedState widget for code deduplication
- Create reusable DisconnectedState widget with factory constructors
- Replace 3 inline disconnected view implementations
- Remove 140 lines of duplicate code
- Factory methods: .simple(), .withSettingsAction(), .full()

Phase 6 - Code Deduplication (Part 2: DisconnectedState)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:25:51 +00:00
Your NameandClaude Opus 4.5 503293d0c9 refactor: Extract EmptyState widget for code deduplication
- Create reusable EmptyState widget with factory constructors
- Replace 8 inline empty state implementations across screens
- Remove 227 lines of duplicate code
- Factory methods: .artists(), .albums(), .tracks(), .playlists(), .queue(), .search()

Phase 6 - Code Deduplication (Part 1: EmptyState)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:23:10 +00:00
Your NameandClaude Opus 4.5 4ad71c3cda docs: Update AUDIT_PROGRESS.md for Phase 5 completion
- expandable_player.dart: 1,365 → 1,150 lines (-215)
- login_screen.dart: 968 → 843 lines (-125)
- Created 5 new widget files for better organization

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 11:10:20 +00:00
Your NameandClaude Opus 4.5 beee3d099e refactor: Extract DebugConsole widget from login_screen.dart
- Create reusable DebugConsole widget with log display
- Create DebugToggleButton for show/hide functionality
- Reduce login_screen.dart from 968 to 843 lines

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:55:43 +00:00
Your NameandClaude Opus 4.5 05c18e8535 refactor: Phase 5 - Extract player widgets from expandable_player.dart
- Extract QueuePanel widget (queue display and list)
- Extract DeviceSelectorBar widget (compact player bar)
- Create PlayerControls widget (for potential future use)
- Reduce expandable_player.dart from 1,365 to 1,150 lines

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:53:48 +00:00
Your Name 9c656ec269 docs: Update AUDIT_PROGRESS.md for Phase 4 completion 2025-12-08 10:49:23 +00:00
Your NameandClaude Opus 4.5 53c6d5142e fix: Fix Phase 4 build errors
- Add missing imports for TrackMetadata and audioHandler
- Remove invalid dynamic import() calls (Dart doesn't support this)
- Initialize LocalPlayerService directly in constructor
- Remove _localPlayerInitialized checks (now always initialized)
- Delete unused library_service.dart

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:37:40 +00:00
Your NameandClaude Opus 4.5 d5935fba44 refactor: Phase 4 - Extract CacheService from MusicAssistantProvider
- Create CacheService to centralize all caching logic
- Move home screen caching, detail screen caching, search caching, and player caching to CacheService
- MusicAssistantProvider now delegates to CacheService
- Also created skeleton files for ConnectionProvider, PlayerProvider, LibraryProvider (not yet wired up)

This is an incremental refactor that keeps backward compatibility while
extracting caching concerns into a dedicated service.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:31:18 +00:00
Your NameandClaude Opus 4.5 2396f32c47 chore: Phase 3 dead code cleanup
- Delete unused library_screen.dart (old version replaced by new_library_screen.dart)
- Delete unused player_state_service.dart (not imported anywhere)
- Remove unused Player model import from debug_log_screen.dart

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:10:14 +00:00
Your NameandClaude Opus 4.5 e8195d6522 fix(search_screen): move enum and class outside of State class
Dart doesn't allow enums or classes to be declared inside other classes.
Moved _ListItemType enum and _ListItem class to file scope.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 09:55:46 +00:00
Your NameandClaude Opus 4.5 6c6feb9c21 perf: Phase 2 performance fixes
- expandable_player.dart: Use ValueNotifier for slider seek position
  to eliminate setState spam during dragging. Only the slider rebuilds
  during interaction instead of the entire widget tree.

- expandable_player.dart: Replace Image.network with CachedNetworkImage
  for queue item images to enable automatic image caching.

- volume_control.dart: Remove polling loop that waited for volume
  confirmation. Trust WebSocket events to update the UI.

- main.dart: Move SystemChrome.setSystemUIOverlayStyle out of build()
  into a new SystemUIWrapper widget that only updates when theme
  properties actually change.

- search_screen.dart: Replace spread operators with ListView.builder
  for lazy rendering of search results. Add helper classes for
  unified list item representation.

- search_screen.dart: Remove unused Theme.of(context).textTheme lookup.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 09:47:28 +00:00
Your NameandClaude Opus 4.5 17e03a2598 fix(expandable_player): cast clamp result to double for Slider value
The clamp() method returns num regardless of argument types. Adding
.toDouble() ensures the Slider receives the expected double type.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 09:34:12 +00:00
Your NameandClaude Opus 4.5 4f242e1d8c fix: type error in clamp - use 0.0 instead of 0 for double
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 09:25:38 +00:00
Your NameandClaude Opus 4.5 5d1bb2eb90 refactor: Phase 1 audit cleanup - quick wins
- Remove unused cupertino_icons dependency (~100KB savings)
- Replace debug print() statements with DebugLogger in main.dart
- Fix empty setState() in expandable_player.dart progress timer
  - Converted to ValueNotifier for targeted rebuilds
  - Massive performance improvement during playback
- Fix empty setState() in search_screen.dart onChanged
  - Removed unnecessary rebuild on every keystroke
- Address TODO comments:
  - library_screen.dart: Implemented play track functionality
  - queue_screen.dart: Enhanced comment with API requirements
  - expandable_player.dart: Enhanced comment with API limitations
- Add AUDIT_PROGRESS.md to track refactoring progress

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 09:21:34 +00:00
Your NameandClaude Opus 4.5 1215f9292e Speed up swipe-to-change-player animation
Reduced slide animation duration from 150ms to 75ms per phase
(300ms total → 150ms total) for snappier device switching.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 06:53:56 +00:00
Your NameandClaude Opus 4.5 63dc401e7b Fix precache variable scope error
The finalImageUrl variable was out of scope. Now extract the rewritten
URL from metadata instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 23:53:46 +00:00
Your NameandClaude Opus 4.5 bc2d922859 Precache album art from player_updated events
Adds image precaching when caching track info from player_updated events
to eliminate the brief flash of music note placeholder before album art loads.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 23:48:55 +00:00
Your Name 1d970d64c2 Fix: Don't overwrite cached track with image using queue data without image
The queue preload was overwriting tracks cached from player_updated that
had image metadata with queue data that lacks images. Now we check if
the existing cache has images and skip overwriting if the new data doesn't.
2025-12-07 23:40:13 +00:00
Your Name 44dce05223 Fix: Update _currentTrack from player_updated for immediate album art
When a track starts playing, player_updated event arrives with image_url
but _currentTrack was being set from queue data which lacks proper image
metadata. Now we also update _currentTrack when caching from player_updated
so the UI shows album art immediately.
2025-12-07 23:22:41 +00:00
Your Name 3681fd2b1a Fix: Include image URL in cached tracks from player_updated events
Tracks cached from player_updated events weren't showing album art because
they lacked metadata.images. Now we extract image_url from current_media
and store it in metadata. Also updated getImageUrl to return full URLs
directly instead of re-encoding them through imageproxy.
2025-12-07 23:08:41 +00:00
Your Name f2a85cdb68 Fix: Ensure primary color has minimum lightness for text contrast
When album art is very dark, the extracted primary color could be
too dark for white text to be readable. Now we check luminance and
lighten the primary color if needed (luminance < 0.15).
2025-12-07 22:57:41 +00:00
Your Name 077e8483a0 Fix: Handle Spotify duration as double (not int)
Spotify sends duration as a double (e.g., 115.043) but we were casting
as int? which caused type errors. Now use (num?)?.toInt() to handle both.
2025-12-07 22:54:11 +00:00
Your Name 0dffc4a5e7 Merge fix/playback-controls-clean: Playback controls and idle player fixes
- Volume slider for local device controls system volume
- Progress bar seek works correctly (prefer current_media.elapsed_time)
- Idle players with content show track info (MA uses idle for paused cast players)
- Cache track info from player_updated events for all players
2025-12-07 22:48:02 +00:00
Your Name bdbffd212e Fix: Don't clear track for idle players that are powered
_updatePlayerState was clearing _currentTrack for 'idle' state players,
but MA uses 'idle' for paused cast-based players that still have queue content.
Now we check for powered idle players and fetch their queue too.
2025-12-07 22:40:01 +00:00
Your Name aca420bdbf Fix: Correct types for duration (Duration) and album name (non-nullable) 2025-12-07 22:27:47 +00:00
Your Name d6b4bb10c2 Fix: Add required provider parameter to Track/Artist/Album constructors 2025-12-07 22:18:57 +00:00
Your Name 4eadd3f8d7 Cache track info from player_updated events for all players
MA sends current_media with track info even for idle players.
Extract and cache this data for display in player selector.
2025-12-07 22:11:20 +00:00
Your Name a98d8cccb2 DEBUG: Add logging to track preload for idle players 2025-12-07 22:00:25 +00:00
Your Name 881303edd2 Show track info for idle players with cached content
MA uses 'idle' state for paused cast-based players, but they still
have queue info. Changed to:
- Fetch track info for all powered/available players (not just playing/paused)
- Show track name and album art for idle players that have cached track data
- Use orange status indicator for any player with content (playing, paused, or idle)
2025-12-07 21:52:32 +00:00
Your Name e9c917fa4c DEBUG: Log player state values to diagnose idle vs paused issue 2025-12-07 21:50:44 +00:00
Your Name 81b427611e Fix playback controls: volume slider for local device, seek/progress bar
- Add flutter_volume_controller for local device system volume control
- VolumeControl: detect local device player, use system volume instead of API
- Player.fromJson: prefer current_media.elapsed_time for accurate seek position
- _updatePlayerState: always update _selectedPlayer to propagate elapsed time
- _handlePlayerUpdatedEvent: trigger state update for selected player on WebSocket events
- checkAndReconnect: also update player state after reconnect
2025-12-07 21:22:00 +00:00
Your NameandClaude Opus 4.5 eca7514a71 Fix ImageStream API usage for image precaching
Use ImageStreamListener with proper callbacks instead of
non-existent .stream property. Add 10-second timeout to prevent
hanging on slow/failed image loads.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:22:05 +00:00
Your NameandClaude Opus 4.5 cfb87fcaa3 Improve swipe animation smoothness with better preloading
- Replace Image.network with CachedNetworkImage for better caching
- Disable fade transitions on CachedNetworkImage for instant display
- Preload ALL players' artwork on app start (not just adjacent)
- Make _precacheImage actually wait for image to load into memory
- Add easing curves to slide animation (easeInCubic out, easeOutCubic in)
- Preload at correct image size (512px) for mini player

This should eliminate the lag on first swipe to a playing player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:18:06 +00:00
Your NameandClaude Opus 4.5 093d7aacd1 Add slide animation to playing mini player
Apply the same slide animation to the playing state mini player:
- Album art slides with device switch
- Track title slides with device switch
- Artist name slides with device switch
- Playback controls slide with device switch

Animation only applies when player is collapsed (t < 0.1).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:57:07 +00:00
Your NameandClaude Opus 4.5 71bd6ee599 Fix slide animation using Transform.translate instead of SlideTransition
- Replace complex SlideTransition with simpler Transform.translate
- Use two-phase animation (slide out, switch data, slide in)
- Add proper listener cleanup between phases
- Reduce animation duration from 250ms to 150ms for snappier feel
- Add isSliding guard to prevent overlapping animations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:43:11 +00:00
Your NameandClaude Opus 4.5 3853658f45 Add slide animation to device selector bar swipe
- Added slide animation controller for smooth device transitions
- Device selector bar content slides out/in when swiping
- Animation: 250ms ease-in-out with direction based on swipe
- Content slides out in swipe direction, new content slides in from opposite side

Note: Playing mini player still uses instant switch - the morphing
player layout is more complex and would need additional work.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:13:41 +00:00
Your NameandClaude Opus 4.5 5d46e0ae36 Add player track caching, device selector improvements, and haptic feedback
- Cache adjacent players' track info for smooth swipe transitions
  - Preloads next/prev player queues when player is selected
  - Precaches artwork images into Flutter's image cache
  - Added preloadAllPlayerTracks() for device selector popup

- Device selector now shows currently playing track
  - Shows track name instead of just "Playing" status
  - Shows album art for any playing/paused player (not just selected)
  - Preloads all player tracks when popup opens

- Added haptic feedback (medium impact) on swipe device switch

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:09:16 +00:00
Your NameandClaude Opus 4.5 ad27b28bb9 Remove device indicator dots and fix play-on popup height
- Removed device indicator dots from mini player
  - Not practical for users with 6+ players
  - Keeps interface cleaner

- Fixed play-on popup height to account for safe area
  - Container height now includes bottom padding
  - List scrolls properly without nav bar overlap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:33:50 +00:00
Your NameandClaude Opus 4.5 ddc3e58f4d Fix device indicator dots and play-on popup padding
- Device indicator dots now correctly highlight the selected player
  - Use consistent selectedPlayerId comparison
  - Use shared _getAvailablePlayersSorted() for consistent ordering

- Mini player swipe now uses same alphabetical order as device selector
  - Centralized player list sorting in helper method
  - Fixed edge case when current player not in filtered list

- Play-on popup: Added bottom padding to prevent nav bar overlap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 15:55:29 +00:00
Your NameandClaude Opus 4.5 e8ac45f886 Improve player UI: sorting, indicators, and popup heights
- Device indicator dots now positioned below controls (right side)
  - Removed icon to avoid interference with track/artist info

- Players now sorted alphabetically by name

- Device selector popup: fixed 70% height with scrollable list
  - Replaced DraggableScrollableSheet with fixed height container

- Play-on popups: fixed 45% height with scrollable list
  - Refactored three similar popups into shared _showPlayOnSheet method
  - Consistent behavior across all "Play on..." actions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 15:36:05 +00:00
Your NameandClaude Opus 4.5 d1ba29c44c Add persistent player selection and swipe-to-switch gesture
- Player selection now persists across app restarts
  - Stores last selected player ID in SharedPreferences
  - On launch, prefers last-selected player over local device
  - Falls back to local device only on first launch

- Mini player is now permanent (visible even without track)
  - Shows device selector bar when no track is playing
  - Displays current device name with icon
  - Shows swipe hint when multiple players available

- Added horizontal swipe gesture on mini player
  - Swipe left/right to cycle through available devices
  - Works in both collapsed states (with/without track)
  - Device indicator dots show current position

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 15:09:01 +00:00
39 changed files with 4894 additions and 3320 deletions
+154
View File
@@ -0,0 +1,154 @@
# Ensemble App - Audit Progress Tracker
**Audit Date:** 2025-12-08
**Branch:** `refactor/audit-cleanup`
**Overall Health Score:** 6.5/10 (before fixes)
---
## Phase 1: Quick Wins (1-2 days)
*Low effort, immediate impact*
- [x] Remove unused `cupertino_icons` dependency from `pubspec.yaml`
- [x] Remove 12 debug `print()` statements from `main.dart` (replaced with `_logger.log()`)
- [x] Fix empty `setState(() {})` in progress timer (`expandable_player.dart`) - converted to `ValueNotifier`
- [x] Fix empty `setState(() {})` on text input (`search_screen.dart`) - removed unnecessary setState
- [x] Complete or remove TODO comments:
- [x] `queue_screen.dart` - enhanced comment with API requirements
- [x] `library_screen.dart` - **IMPLEMENTED** play track functionality
- [x] `expandable_player.dart` - enhanced comment with API limitations
---
## Phase 2: Performance Fixes (2-3 days)
*Medium effort, significant performance gains*
- [x] Fix slider setState spam - use ValueNotifier pattern (`expandable_player.dart`)
- [x] Replace volume polling with events (`volume_control.dart`) - removed polling loop
- [x] Cache `Theme.of(context)` lookups - removed unused lookup in `search_screen.dart`
- [x] Move `SystemChrome` out of build() (`main.dart`) - created SystemUIWrapper widget
- [x] Replace `Image.network` with `CachedNetworkImage` (`expandable_player.dart`)
- [x] Use `ListView.builder` instead of spread operators (`search_screen.dart`)
---
## Phase 3: Dead Code Cleanup (1 day)
*Consolidate and remove unused files*
- [x] Delete unused `library_screen.dart` (old version, `new_library_screen.dart` is active)
- [x] Remove unused `PlayerStateService` (`player_state_service.dart`)
- [x] Remove unused `Player` import from `debug_log_screen.dart`
Note: `new_home_screen.dart` and `new_library_screen.dart` are NOT dead code - they are
the active screens used in `home_screen.dart`. The naming suggests they replaced older
versions during a refactor.
---
## Phase 4: State Management Refactor (3-4 days)
*Split monolithic provider (2,106 lines)*
- [x] Extract cache logic → `CacheService` (lib/services/cache_service.dart)
- [x] Update `MusicAssistantProvider` to delegate caching to `CacheService`
- [x] Delete unused `library_service.dart`
- [ ] Extract connection logic → `ConnectionProvider` (skeleton created)
- [ ] Extract library data → `LibraryProvider` (skeleton created)
- [ ] Extract player state → `PlayerProvider` (skeleton created)
- [ ] Remove global singleton (`navigation_provider.dart:40`)
- [ ] Add stream filtering (`.distinct()`, `.where()`)
**Note:** Phase 4 partial completion - extracted CacheService and reduced
`music_assistant_provider.dart` from 2,106 to ~1,650 lines. Full provider
split deferred as lower priority.
---
## Phase 5: Widget Decomposition (3-4 days)
*Split mega-widgets into focused components*
### expandable_player.dart (1,365 → 1,150 lines) - DONE
- [x] Extract `DeviceSelectorBar` → `lib/widgets/player/device_selector_bar.dart`
- [x] Extract `QueuePanel` → `lib/widgets/player/queue_panel.dart`
- [x] Create `PlayerControls` → `lib/widgets/player/player_controls.dart` (available for future use)
### login_screen.dart (968 → 843 lines) - DONE
- [x] Extract `DebugConsole` → `lib/widgets/debug/debug_console.dart`
- [x] Extract `DebugToggleButton` → included in debug_console.dart
### album_details_screen.dart (818 lines)
- [ ] Extract `TrackListSection`
- [ ] Extract `PlayOnBottomSheet` (shared)
### search_screen.dart (516 lines)
- [ ] Extract `MediaItemTile`
- [ ] Extract `SearchFilters`
---
## Phase 6: Code Deduplication (2-3 days)
*Extract reusable widgets*
- [ ] Create `MediaItemTile` (consolidate 3 list tile builders)
- [ ] Create `PlayOnBottomSheet` (consolidate 4 implementations)
- [x] Create `EmptyState` (consolidate 6+ implementations) → `lib/widgets/common/empty_state.dart`
- [ ] Create `FilterChipRow` (consolidate 3 implementations)
- [x] Create `DisconnectedState` (consolidate 3 implementations) → `lib/widgets/common/disconnected_state.dart`
**Note:** Phase 6 partial completion - extracted EmptyState (8 usages, ~227 lines removed)
and DisconnectedState (3 usages, ~140 lines removed). Total ~367 lines of duplicate code removed.
---
## Phase 7: Design System (2 days)
*Centralize hardcoded values*
- [x] Create `lib/theme/design_tokens.dart`:
- [x] Spacing constants (Spacing class with xxs-xxxl scale)
- [x] Border radius constants (Radii class)
- [x] Icon size constants (IconSizes class)
- [x] Dimension constants (Dimensions class)
- [x] Animation duration constants (Durations class)
- [x] Apply tokens to extracted widgets:
- [x] `empty_state.dart` - Spacing tokens
- [x] `disconnected_state.dart` - Spacing, IconSizes, Radii tokens
- [x] `queue_panel.dart` - Spacing, Radii, IconSizes tokens
- [x] `player_controls.dart` - Spacing tokens
- [x] `device_selector_bar.dart` - Spacing, IconSizes tokens
- [x] `debug_console.dart` - Spacing, Radii, IconSizes tokens
- [ ] Continue replacing remaining hardcoded values (~223 remaining)
- [ ] Create `lib/constants/strings.dart` (40+ strings)
---
## Phase 8: Polish (Backlog)
*Nice-to-have improvements*
- [ ] Add Semantics labels for accessibility
- [ ] Implement localization (i18n)
- [ ] Add test coverage
- [x] Implement WebSocket heartbeat (`music_assistant_api.dart` - ping every 30s)
- [ ] Add responsive grid layouts for tablets
- [x] Add completer guards to prevent double-completion (`music_assistant_api.dart`, `music_assistant_provider.dart`, `player_provider.dart`)
---
## Completed Summary
| Phase | Status | Completed Date |
|-------|--------|----------------|
| Phase 1 | **COMPLETED** | 2025-12-08 |
| Phase 2 | **COMPLETED** | 2025-12-08 |
| Phase 3 | **COMPLETED** | 2025-12-08 |
| Phase 4 | **PARTIAL** | 2025-12-08 |
| Phase 5 | **PARTIAL** | 2025-12-08 |
| Phase 6 | **PARTIAL** | 2025-12-08 |
| Phase 7 | **PARTIAL** | 2025-12-08 |
| Phase 8 | **PARTIAL** | 2025-12-08 |
---
## Notes
- Original audit performed by 7 parallel sub-agents analyzing different aspects
- Total LOC: 17,454 across 57 Dart files
- Largest files: `music_assistant_provider.dart` (2,106), `music_assistant_api.dart` (1,948), `expandable_player.dart` (1,335)
+3
View File
@@ -2,6 +2,9 @@
class Timings {
Timings._();
/// WebSocket heartbeat/ping interval to keep connection alive
static const Duration heartbeatInterval = Duration(seconds: 30);
/// Player state polling interval (for selected player updates)
static const Duration playerPollingInterval = Duration(seconds: 5);
+106 -35
View File
@@ -8,6 +8,7 @@ import 'screens/login_screen.dart';
import 'services/settings_service.dart';
import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart';
import 'theme/theme_provider.dart';
import 'theme/app_theme.dart';
import 'theme/system_theme_helper.dart';
@@ -16,6 +17,9 @@ import 'widgets/global_player_overlay.dart';
// Global audio handler instance
late MassivAudioHandler audioHandler;
// Global debug logger instance
final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -34,7 +38,7 @@ Future<void> main() async {
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
),
);
print('🎵 AudioService initialized - background playback and media notifications ENABLED');
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
// Set preferred orientations
SystemChrome.setPreferredOrientations([
@@ -86,12 +90,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
if (state == AppLifecycleState.resumed) {
// App came back to foreground - check connection and reconnect if needed
print('📱 App resumed - checking WebSocket connection...');
_logger.log('📱 App resumed - checking WebSocket connection...');
_musicProvider.checkAndReconnect();
} else if (state == AppLifecycleState.paused) {
print('📱 App paused (backgrounded)');
_logger.log('📱 App paused (backgrounded)');
} else if (state == AppLifecycleState.detached) {
print('📱 App detached (being destroyed)');
_logger.log('📱 App detached (being destroyed)');
}
}
@@ -124,33 +128,22 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
darkColorScheme = brandDarkColorScheme;
}
// Update system UI overlay style based on theme mode
final isDark = themeProvider.themeMode == ThemeMode.dark ||
(themeProvider.themeMode == ThemeMode.system &&
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? darkColorScheme.background
: lightColorScheme.background,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
return MaterialApp(
title: 'Ensemble',
debugShowCheckedModeBanner: false,
return SystemUIWrapper(
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
},
home: const AppStartup(),
lightColorScheme: lightColorScheme,
darkColorScheme: darkColorScheme,
child: MaterialApp(
title: 'Ensemble',
debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
},
home: const AppStartup(),
),
);
},
);
@@ -160,6 +153,84 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
}
/// Widget that manages system UI overlay style updates efficiently
/// Only updates when theme properties actually change
class SystemUIWrapper extends StatefulWidget {
final Widget child;
final ThemeMode themeMode;
final ColorScheme lightColorScheme;
final ColorScheme darkColorScheme;
const SystemUIWrapper({
super.key,
required this.child,
required this.themeMode,
required this.lightColorScheme,
required this.darkColorScheme,
});
@override
State<SystemUIWrapper> createState() => _SystemUIWrapperState();
}
class _SystemUIWrapperState extends State<SystemUIWrapper> {
ThemeMode? _previousThemeMode;
Brightness? _previousPlatformBrightness;
ColorScheme? _previousLightColorScheme;
ColorScheme? _previousDarkColorScheme;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateSystemUIIfNeeded();
}
@override
void didUpdateWidget(SystemUIWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
_updateSystemUIIfNeeded();
}
void _updateSystemUIIfNeeded() {
final platformBrightness = MediaQuery.platformBrightnessOf(context);
// Check if any relevant properties have changed
final themeChanged = _previousThemeMode != widget.themeMode;
final platformBrightnessChanged = _previousPlatformBrightness != platformBrightness;
final lightColorSchemeChanged = _previousLightColorScheme != widget.lightColorScheme;
final darkColorSchemeChanged = _previousDarkColorScheme != widget.darkColorScheme;
if (themeChanged || platformBrightnessChanged || lightColorSchemeChanged || darkColorSchemeChanged) {
// Determine if we should use dark theme
final isDark = widget.themeMode == ThemeMode.dark ||
(widget.themeMode == ThemeMode.system && platformBrightness == Brightness.dark);
// Update system UI overlay style
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? widget.darkColorScheme.background
: widget.lightColorScheme.background,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
// Update cached values
_previousThemeMode = widget.themeMode;
_previousPlatformBrightness = platformBrightness;
_previousLightColorScheme = widget.lightColorScheme;
_previousDarkColorScheme = widget.darkColorScheme;
}
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
/// Startup widget that checks if user is logged in and auto-connects
class AppStartup extends StatefulWidget {
const AppStartup({super.key});
@@ -198,24 +269,24 @@ class _AppStartupState extends State<AppStartup> {
// Only attempt connection if not already connected
if (!provider.isConnected) {
print('🚀 AppStartup: Auto-connecting to saved server: $serverUrl');
_logger.log('🚀 AppStartup: Auto-connecting to saved server: $serverUrl');
try {
// Restore auth credentials before connecting (critical for Authelia)
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
print('🚀 AppStartup: Restoring saved auth credentials');
_logger.log('🚀 AppStartup: Restoring saved auth credentials');
provider.authManager.deserializeCredentials(savedCredentials);
}
await provider.connectToServer(serverUrl);
print('🚀 AppStartup: Auto-connection successful');
_logger.log('🚀 AppStartup: Auto-connection successful');
} catch (e) {
print('🚀 AppStartup: Auto-connection failed: $e');
_logger.log('🚀 AppStartup: Auto-connection failed: $e');
// Connection failed, but still show home screen
// User can manually reconnect from there
}
} else {
print('🚀 AppStartup: Already connected');
_logger.log('🚀 AppStartup: Already connected');
}
if (mounted) {
+14 -10
View File
@@ -60,11 +60,12 @@ class Player {
double? elapsedTime;
double? elapsedTimeLastUpdated;
// IMPORTANT: Check top-level fields FIRST - they have the most current data
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
// Get top-level elapsed time values
final topLevelElapsedTime = (json['elapsed_time'] as num?)?.toDouble();
final topLevelLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
// Extract current_item_id from current_media
// Extract current_item_id and elapsed time from current_media
// current_media often has the more accurate position after seeks
if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
@@ -73,23 +74,26 @@ class Player {
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
// Use current_media elapsed_time if:
// 1. We don't have top-level elapsed_time, OR
// 2. Top-level is 0 but current_media has a real value
if (elapsedTime == null || (elapsedTime == 0 && currentMediaElapsedTime != null && currentMediaElapsedTime > 0)) {
// Prefer current_media elapsed_time when available - it reflects actual playback position
// after seek operations, while top-level elapsed_time may lag behind
if (currentMediaElapsedTime != null) {
elapsedTime = currentMediaElapsedTime;
elapsedTimeLastUpdated = currentMediaLastUpdated;
elapsedTimeLastUpdated = currentMediaLastUpdated ?? topLevelLastUpdated;
}
}
}
// Fall back to top-level values if current_media didn't have elapsed time
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
provider: json['provider'] as String?,
available: json['available'] as bool? ?? false,
powered: json['powered'] as bool? ?? false,
state: json['state'] as String? ?? 'idle',
state: json['playback_state'] as String? ?? json['state'] as String? ?? 'idle',
currentItemId: currentItemId,
volumeLevel: json['volume_level'] as int?,
volumeMuted: json['volume_muted'] as bool?,
+239
View File
@@ -0,0 +1,239 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server
/// Handles WebSocket connection, authentication, and reconnection
class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
String? _error;
// Callbacks for post-connection initialization
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService;
ConnectionProvider() {
_initialize();
}
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
/// Restore auth credentials from persistent storage
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
} else {
_logger.log('🔐 No saved auth credentials found');
}
}
/// Handle Music Assistant native authentication after WebSocket connection
Future<bool> handleMaAuthentication() async {
if (_api == null) return false;
try {
// First, try stored MA token
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
// No valid token - try stored credentials
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
/// Fetch user profile from MA and set owner name
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) {
_logger.log('⚠️ Could not fetch user profile');
return;
}
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
final profileName = (displayName != null && displayName.isNotEmpty)
? displayName
: username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
} else {
_logger.log('⚠️ No valid name in profile');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
}
}
Future<void> connectToServer(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection
await _api?.disconnect();
_api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes
_api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication();
if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required - notify connected
onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_cacheService.clearAll();
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, connection verified');
}
}
@override
void dispose() {
_api?.dispose();
super.dispose();
}
}
+383
View File
@@ -0,0 +1,383 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
class LibraryProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService;
MusicAssistantAPI? _api;
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
bool _isLoading = false;
String? _error;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Getters
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
bool get isLoading => _isLoading;
String? get error => _error;
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
LibraryProvider(this._cacheService);
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api) {
_api = api;
}
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
}
// ============================================================================
// HOME SCREEN DATA
// ============================================================================
/// Get recently played albums with caching
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!;
}
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh recent albums...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
return _cacheService.getCachedRecentAlbums() ?? [];
}
}
/// Get discover artists (random) with caching
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
}
}
/// Get discover albums (random) with caching
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
}
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_cacheService.invalidateHomeCache();
}
// ============================================================================
// LIBRARY DATA
// ============================================================================
Future<void> loadLibrary() async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
_artists = results[0] as List<Artist>;
_albums = results[1] as List<Album>;
_tracks = results[2] as List<Track>;
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
// ============================================================================
// DETAIL SCREEN DATA
// ============================================================================
/// Get album tracks with caching
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
/// Get playlist tracks with caching
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
/// Get artist albums with caching
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
final libraryAlbums = await _api!.getAlbums();
final artistAlbums = libraryAlbums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH
// ============================================================================
/// Search with caching
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
final cacheKey = query.toLowerCase().trim();
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query"');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
try {
_logger.log('🔄 Searching for "$query"...');
final results = await _api!.search(query);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
void clearState() {
_artists = [];
_albums = [];
_tracks = [];
_isLoading = false;
_error = null;
clearSearchState();
notifyListeners();
}
}
File diff suppressed because it is too large Load Diff
+953
View File
@@ -0,0 +1,953 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/local_player_service.dart';
import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
/// Provider for managing player state, selection, and controls
class PlayerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
late final LocalPlayerService _localPlayer;
final CacheService _cacheService;
// Dependencies injected from parent provider
MusicAssistantAPI? _api;
AuthManager? _authManager;
String? _serverUrl;
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Track? _currentTrack;
Timer? _playerStateTimer;
Timer? _adjacentPlayerCacheTimer;
// Local Playback
bool _isLocalPlayerPowered = true;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
Timer? _localPlayerStateReportTimer;
// Pending metadata from player_updated events
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
// Registration guard
Completer<void>? _registrationInProgress;
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
PlayerProvider(this._cacheService, AuthManager authManager) {
_authManager = authManager;
_localPlayer = LocalPlayerService(authManager);
}
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api, String serverUrl) {
_api = api;
_serverUrl = serverUrl;
}
/// Set up event listeners after connection
Future<void> setupEventListeners() async {
if (_api == null) return;
// Listen to built-in player events
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) {
_logger.log('Builtin player event stream error: $error');
},
);
// Listen to player_updated events
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) {
_logger.log('Player updated event stream error: $error');
},
);
}
/// Initialize local playback
Future<void> initializeLocalPlayback() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up skip button callbacks
audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
nextTrackSelectedPlayer();
};
audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
previousTrackSelectedPlayer();
};
if (_api != null) {
await registerLocalPlayer();
}
}
/// Try to adopt an existing ghost player
Future<bool> tryAdoptGhostPlayer() async {
if (_api == null) return false;
try {
final isFresh = await DeviceIdService.isFreshInstallation();
if (!isFresh) {
_logger.log('👻 Not a fresh install, skipping ghost adoption');
return false;
}
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) {
_logger.log('👻 No owner name set, cannot adopt ghost player');
return false;
}
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
if (adoptableId == null) {
_logger.log('👻 No matching ghost player found - will generate new ID');
return false;
}
_logger.log('👻 Found adoptable ghost: $adoptableId');
await DeviceIdService.adoptPlayerId(adoptableId);
_logger.log('✅ Successfully adopted ghost player');
return true;
} catch (e) {
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
return false;
}
}
/// Register local player with MA server
Future<void> registerLocalPlayer() async {
if (_api == null) return;
if (_registrationInProgress != null) {
_logger.log('⏳ Registration already in progress, waiting...');
return _registrationInProgress!.future;
}
_registrationInProgress = Completer<void>();
try {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('🆔 Using player ID: $playerId');
await SettingsService.setBuiltinPlayerId(playerId);
final name = await SettingsService.getLocalPlayerName();
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
if (existingPlayer != null && existingPlayer.available) {
_logger.log('✅ Player already registered and available: $playerId');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
} else if (existingPlayer != null && !existingPlayer.available) {
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
} else {
_logger.log('🆔 Player not found in MA, registering as new');
}
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
await _api!.registerBuiltinPlayer(playerId, name);
_logger.log('✅ Player registration complete');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
} catch (e) {
_logger.log('❌ CRITICAL: Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
_registrationInProgress = null;
rethrow;
}
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
try {
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error reporting local player state (will retry): $e');
}
});
}
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round();
final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState(
playerId,
powered: _isLocalPlayerPowered,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayer.volume == 0.0,
);
}
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
try {
final eventPlayerId = event['player_id'] as String?;
final myPlayerId = await SettingsService.getBuiltinPlayerId();
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
return;
}
final command = (event['type'] as String?) ?? (event['command'] as String?);
switch (command) {
case 'play_media':
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
if (urlPath != null && _serverUrl != null) {
String fullUrl;
if (urlPath.startsWith('http')) {
fullUrl = urlPath;
} else {
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
fullUrl = '$baseUrl$path';
}
TrackMetadata metadata;
if (_pendingTrackMetadata != null) {
metadata = _pendingTrackMetadata!;
} else {
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
final albumName = event['album_name'] as String? ?? event['album'] as String?;
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
final durationSecs = event['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
metadata = TrackMetadata(
title: trackName,
artist: artistName,
album: albumName,
artworkUrl: artworkUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
}
_localPlayer.setCurrentTrackMetadata(metadata);
_currentNotificationMetadata = metadata;
await _localPlayer.playUrl(fullUrl);
}
break;
case 'stop':
await _localPlayer.stop();
break;
case 'pause':
await _localPlayer.pause();
break;
case 'play':
await _localPlayer.play();
break;
case 'seek':
final position = event['position'] as int?;
if (position != null) {
await _localPlayer.seek(Duration(seconds: position));
}
break;
case 'volume_set':
final volume = event['volume_level'] as int?;
if (volume != null) {
await _localPlayer.setVolume(volume / 100.0);
}
break;
case 'power_on':
case 'power_off':
case 'power':
bool? newPowerState;
if (command == 'power_on') {
newPowerState = true;
} else if (command == 'power_off') {
newPowerState = false;
} else {
newPowerState = event['powered'] as bool?;
}
if (newPowerState != null) {
_isLocalPlayerPowered = newPowerState;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
}
break;
}
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error handling local player event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
if (playerId == null) return;
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_updatePlayerState();
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?;
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
final imageUrl = currentMedia['image_url'] as String?;
Map<String, dynamic>? metadata;
if (imageUrl != null && _serverUrl != null) {
var finalImageUrl = imageUrl;
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
finalImageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
// Use original URL
}
metadata = {
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
};
}
final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track',
uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_currentTrack = trackFromEvent;
}
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
if (imageUrl != null) {
final images = metadata?['images'] as List?;
if (images != null && images.isNotEmpty) {
final imagePath = images[0]['path'] as String?;
if (imagePath != null) {
_precacheImage(imagePath);
}
}
}
notifyListeners();
}
}
// Handle notification metadata for local player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
if (currentMedia == null) return;
final title = currentMedia['title'] as String? ?? 'Unknown Track';
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
if (imageUrl != null && _serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
imageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
if (imageUrl != null && imageUrl.startsWith('http://')) {
imageUrl = imageUrl.replaceFirst('http://', 'https://');
}
}
}
final newMetadata = TrackMetadata(
title: title,
artist: artist,
album: album,
artworkUrl: imageUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
final mediaType = currentMedia['media_type'] as String?;
if (mediaType == 'flow_stream') return;
_pendingTrackMetadata = newMetadata;
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
(_currentNotificationMetadata!.title != title ||
_currentNotificationMetadata!.artist != artist);
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
_currentNotificationMetadata = newMetadata;
}
} catch (e) {
_logger.log('Error handling player_updated event: $e');
}
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
if (_api == null) return;
try {
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
return;
}
final allPlayers = await _api!.getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
if (nameLower == 'this device' || player.playerId.startsWith('ma_')) {
filteredCount++;
return false;
}
if (player.playerId.startsWith('ensemble_')) {
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
filteredCount++;
return false;
}
}
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched();
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
if (_selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId && p.available,
);
} catch (e) {}
}
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
} catch (e) {}
}
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
);
} catch (e) {}
}
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
}
}
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
void selectPlayer(Player player, {bool skipNotify = false}) {
_selectedPlayer = player;
SettingsService.setLastSelectedPlayerId(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
if (!skipNotify) {
notifyListeners();
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
if (players.isEmpty) return;
if (preloadAll) {
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
_logger.log('🖼️ Preloading complete');
return;
}
if (_selectedPlayer == null || players.length <= 1) return;
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
if (currentIndex == -1) return;
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
final playersToPreload = <Player>{};
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
}
Future<void> _preloadPlayerTrack(Player player) async {
if (_api == null) return;
try {
if (!player.available || !player.powered) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
final queue = await _api!.getQueue(player.playerId);
if (queue != null && queue.currentItem != null) {
final track = queue.currentItem!.track;
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = track.metadata?['images'] != null;
if (!existingHasImage || newHasImage) {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
}
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
if (artworkUrl512 != null) {
await _precacheImage(artworkUrl512);
}
} else {
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
if (existingTrack?.metadata?['images'] == null) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
}
}
} catch (e) {
_logger.log('Error preloading player track for ${player.name}: $e');
}
}
Future<void> _precacheImage(String url) async {
try {
final imageProvider = NetworkImage(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
_logger.log('Image precache failed for $url: $e');
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
}
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await _updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
_updatePlayerState();
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
bool stateChanged = false;
final allPlayers = await _api!.getPlayers();
final updatedPlayer = allPlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
orElse: () => _selectedPlayer!,
);
_selectedPlayer = updatedPlayer;
stateChanged = true;
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
if (!shouldShowTrack) {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
if (stateChanged) notifyListeners();
return;
}
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
if (_currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name) {
_currentTrack = queue.currentItem!.track;
stateChanged = true;
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
_localPlayer.updateNotification(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artworkUrl: artworkUrl,
duration: track.duration,
);
}
}
} else {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
}
// ============================================================================
// PLAYER CONTROLS
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
if (_selectedPlayer!.isPlaying) {
await _api!.pausePlayer(_selectedPlayer!.playerId);
} else {
await _api!.resumePlayer(_selectedPlayer!.playerId);
}
await refreshPlayers();
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await loadAndSelectPlayers(forceRefresh: true);
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
} catch (e) {
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
}
Future<void> togglePower(String playerId) async {
if (_api == null) return;
try {
final localPlayerId = await SettingsService.getBuiltinPlayerId();
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
if (isLocalPlayer) {
_isLocalPlayerPowered = !_isLocalPlayerPowered;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
await _reportLocalPlayerState();
await refreshPlayers();
} else {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
await _api!.setPower(playerId, !player.powered);
await refreshPlayers();
}
} catch (e) {
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
if (_api == null) return;
try {
await _api!.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
if (_api == null) return;
try {
await _api!.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
if (_api == null) return;
try {
await _api!.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
if (_api == null) return;
try {
await _api!.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
void clearState() {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_availablePlayers = [];
_selectedPlayer = null;
_currentTrack = null;
}
@override
void dispose() {
clearState();
super.dispose();
}
}
+65 -151
View File
@@ -703,67 +703,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'Play on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
// Set this as the active player
maProvider.selectPlayer(player);
// Play album from this track onwards
maProvider.playTracks(
player.playerId,
_tracks,
startIndex: startIndex,
);
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
maProvider.selectPlayer(player);
maProvider.playTracks(
player.playerId,
_tracks,
startIndex: startIndex,
);
},
);
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -771,85 +722,54 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final players = maProvider.availablePlayers;
final track = _tracks[trackIndex];
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'Play on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
// Set this as the active player
maProvider.selectPlayer(player);
// Play radio based on this track
maProvider.playRadio(
player.playerId,
track,
);
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
maProvider.selectPlayer(player);
maProvider.playRadio(player.playerId, track);
},
);
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
maProvider.selectPlayer(player);
maProvider.playTracks(player.playerId, _tracks);
},
);
}
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
void _showPlayOnSheet(
BuildContext context,
List players, {
required void Function(dynamic player) onPlayerSelected,
}) {
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
@@ -857,36 +777,30 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
// Set this as the active player
maProvider.selectPlayer(player);
// Play on this specific player
maProvider.playTracks(player.playerId, _tracks);
Expanded(
child: players.isEmpty
? const Center(
child: Text('No players available'),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
onPlayerSelected(player);
},
);
},
);
},
),
),
const SizedBox(height: 16),
),
),
],
),
),
-1
View File
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../widgets/global_player_overlay.dart';
class DebugLogScreen extends StatefulWidget {
+3 -31
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart';
class LibraryAlbumsScreen extends StatelessWidget {
const LibraryAlbumsScreen({super.key});
@@ -52,37 +53,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
}
if (albums.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.album_outlined,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No albums found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
context.read<MusicAssistantProvider>().loadLibrary();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
return EmptyState.albums(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+3 -31
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart';
import 'artist_details_screen.dart';
class LibraryArtistsScreen extends StatelessWidget {
@@ -54,37 +55,8 @@ class LibraryArtistsScreen extends StatelessWidget {
}
if (artists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_outline_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No artists found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
context.read<MusicAssistantProvider>().loadLibrary();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
return EmptyState.artists(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+2 -30
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import 'playlist_details_screen.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
@@ -80,36 +81,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
}
if (_playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_play_outlined,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No playlists found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadPlaylists,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
return EmptyState.playlists(onRefresh: _loadPlaylists);
}
return RefreshIndicator(
-456
View File
@@ -1,456 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import 'album_details_screen.dart';
import 'settings_screen.dart';
class LibraryScreen extends StatefulWidget {
const LibraryScreen({super.key});
@override
State<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends State<LibraryScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
'Library',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings_rounded),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
);
},
color: colorScheme.onBackground,
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: colorScheme.primary,
labelColor: colorScheme.primary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
tabs: const [
Tab(text: 'Artists'),
Tab(text: 'Albums'),
Tab(text: 'Tracks'),
],
),
),
body: !provider.isConnected
? _buildDisconnectedView(provider)
: TabBarView(
controller: _tabController,
children: [
_buildArtistsList(provider),
_buildAlbumsList(provider),
_buildTracksList(provider),
],
),
);
}
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
);
},
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
),
),
);
}
Widget _buildArtistsList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.artists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_outline_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No artists found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: provider.loadLibrary,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return ListView.builder(
itemCount: provider.artists.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final artist = provider.artists[index];
return _buildArtistTile(artist, provider);
},
);
}
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
radius: 24,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
artist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () async {
// Load albums for this artist
await provider.loadAlbums(artistId: '${artist.provider}/${artist.itemId}');
if (context.mounted) {
_tabController.animateTo(1);
}
},
);
}
Widget _buildAlbumsList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.albums.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.album_outlined,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No albums found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: provider.loadLibrary,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
padding: const EdgeInsets.all(12),
itemCount: provider.albums.length,
itemBuilder: (context, index) {
final album = provider.albums[index];
return _buildAlbumCard(album, provider);
},
);
}
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(album, size: 256);
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
)
: null,
),
),
const SizedBox(height: 8),
Text(
album.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
album.artistsString,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildTracksList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.tracks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note_outlined,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No tracks found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: provider.loadLibrary,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return ListView.builder(
itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final track = provider.tracks[index];
return _buildTrackTile(track, provider);
},
);
}
Widget _buildTrackTile(Track track, MusicAssistantProvider provider) {
final imageUrl = track.album != null
? provider.getImageUrl(track.album!, size: 128)
: null;
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
track.name,
style: TextStyle(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
)
: null,
onTap: () {
// TODO: Play track
},
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
+2 -30
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
class LibraryTracksScreen extends StatelessWidget {
const LibraryTracksScreen({super.key});
@@ -47,36 +48,7 @@ class LibraryTracksScreen extends StatelessWidget {
}
if (provider.tracks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note_outlined,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'No tracks found',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: provider.loadLibrary,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
return EmptyState.tracks(onRefresh: provider.loadLibrary);
}
return ListView.builder(
+6 -131
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
@@ -767,141 +767,16 @@ class _LoginScreenState extends State<LoginScreen> {
),
// Debug toggle button
TextButton.icon(
onPressed: () {
setState(() {
_showDebug = !_showDebug;
});
},
icon: Icon(
_showDebug ? Icons.bug_report : Icons.bug_report_outlined,
size: 16,
),
label: Text(
_showDebug ? 'Hide Debug' : 'Show Debug',
style: const TextStyle(fontSize: 12),
),
DebugToggleButton(
isVisible: _showDebug,
onToggle: () => setState(() => _showDebug = !_showDebug),
),
// Debug panel
if (_showDebug) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.terminal, color: Colors.green, size: 16),
const SizedBox(width: 8),
const Text(
'Debug Console',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
final logs = DebugLogger().getAllLogs();
if (logs.isNotEmpty) {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No logs to copy'),
duration: Duration(seconds: 2),
),
);
}
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(50, 20),
),
icon: const Icon(Icons.copy, size: 10, color: Colors.green),
label: const Text(
'Copy',
style: TextStyle(fontSize: 10, color: Colors.green),
),
),
TextButton(
onPressed: () {
DebugLogger().clear();
setState(() {
_debugLogs.clear();
});
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(40, 20),
),
child: const Text(
'Clear',
style: TextStyle(fontSize: 10, color: Colors.green),
),
),
],
),
const Divider(color: Colors.green, height: 8),
Expanded(
child: SingleChildScrollView(
reverse: true,
child: Builder(
builder: (context) {
// Get all logs from DebugLogger singleton
final allLogs = DebugLogger().logs;
final recentLogs = allLogs.length > 50
? allLogs.sublist(allLogs.length - 50)
: allLogs;
return recentLogs.isEmpty
? const Text(
'No debug logs yet. Try detecting auth.',
style: TextStyle(
color: Colors.green,
fontSize: 10,
fontStyle: FontStyle.italic,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: recentLogs
.map((log) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
log,
style: const TextStyle(
color: Colors.green,
fontSize: 10,
fontFamily: 'monospace',
),
),
))
.toList(),
);
},
),
),
),
],
),
DebugConsole(
onClear: () => setState(() => _debugLogs.clear()),
),
const SizedBox(height: 16),
],
+7 -63
View File
@@ -5,6 +5,7 @@ import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -74,7 +75,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, isConnected, child) {
final maProvider = context.read<MusicAssistantProvider>();
return !isConnected
? _buildDisconnectedView(context, maProvider)
? DisconnectedState.full(
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
)
: RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
@@ -87,68 +93,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
);
}
Widget _buildDisconnectedView(
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: 80,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 24),
Text(
'Not Connected',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w300,
),
),
const SizedBox(height: 12),
Text(
'Connect to your Music Assistant server to start listening',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
);
},
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
),
),
);
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
+11 -94
View File
@@ -9,6 +9,8 @@ import '../widgets/album_card.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
import 'settings_screen.dart';
@@ -108,7 +110,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
centerTitle: true,
actions: const [PlayerSelector()],
),
body: _buildDisconnectedView(context),
body: DisconnectedState.withSettingsAction(
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
);
}
@@ -154,52 +161,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildDisconnectedView(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
);
},
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
),
),
);
}
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
@@ -214,10 +175,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (artists.isEmpty) {
return _buildEmptyState(
context,
icon: Icons.person_outline_rounded,
message: 'No artists found',
return EmptyState.artists(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
@@ -316,10 +274,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (albums.isEmpty) {
return _buildEmptyState(
context,
icon: Icons.album_outlined,
message: 'No albums found',
return EmptyState.albums(
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
@@ -362,12 +317,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (_playlists.isEmpty) {
return _buildEmptyState(
context,
icon: Icons.playlist_play_outlined,
message: 'No playlists found',
onRefresh: _loadPlaylists,
);
return EmptyState.playlists(onRefresh: _loadPlaylists);
}
return RefreshIndicator(
@@ -443,37 +393,4 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// ============ SHARED WIDGETS ============
Widget _buildEmptyState(
BuildContext context, {
required IconData icon,
required String message,
required VoidCallback onRefresh,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: colorScheme.onSurface.withOpacity(0.54)),
const SizedBox(height: 16),
Text(
message,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
+8 -25
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
class QueueScreen extends StatefulWidget {
const QueueScreen({super.key});
@@ -139,19 +140,7 @@ class _QueueScreenState extends State<QueueScreen> {
}
if (_queue == null || _queue!.items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
const SizedBox(height: 16),
Text(
'Queue is empty',
style: TextStyle(color: Colors.grey[600], fontSize: 18),
),
],
),
);
return EmptyState.queue();
}
// Filter to show only current and upcoming items (not history)
@@ -159,18 +148,9 @@ class _QueueScreenState extends State<QueueScreen> {
final upcomingItems = _queue!.items.sublist(currentIndex);
if (upcomingItems.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
const SizedBox(height: 16),
Text(
'No upcoming tracks',
style: TextStyle(color: Colors.grey[600], fontSize: 18),
),
],
),
return const EmptyState(
icon: Icons.queue_music,
message: 'No upcoming tracks',
);
}
@@ -231,6 +211,9 @@ class _QueueScreenState extends State<QueueScreen> {
),
onDismissed: (direction) {
// TODO: Implement queue item removal via API
// Currently only removes locally from UI state, not from actual Music Assistant queue
// Need to investigate if Music Assistant API supports player_queues/remove_item or similar endpoint
// Requires: API method like removeQueueItem(queueId, queueItemId) that calls player_queues/remove_item
setState(() {
_queue!.items.removeAt(index);
});
+104 -70
View File
@@ -7,6 +7,8 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../services/debug_logger.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
@@ -17,6 +19,41 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => SearchScreenState();
}
// Helper enum and class for ListView.builder item types
enum _ListItemType { header, artist, album, track, spacer }
class _ListItem {
final _ListItemType type;
final MediaItem? mediaItem;
final String? headerTitle;
final int? headerCount;
_ListItem.header(this.headerTitle, this.headerCount)
: type = _ListItemType.header,
mediaItem = null;
_ListItem.artist(this.mediaItem)
: type = _ListItemType.artist,
headerTitle = null,
headerCount = null;
_ListItem.album(this.mediaItem)
: type = _ListItemType.album,
headerTitle = null,
headerCount = null;
_ListItem.track(this.mediaItem)
: type = _ListItemType.track,
headerTitle = null,
headerCount = null;
_ListItem.spacer()
: type = _ListItemType.spacer,
mediaItem = null,
headerTitle = null,
headerCount = null;
}
class SearchScreenState extends State<SearchScreen> {
final _logger = DebugLogger();
final TextEditingController _searchController = TextEditingController();
@@ -108,7 +145,6 @@ class SearchScreenState extends State<SearchScreen> {
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: colorScheme.background,
@@ -135,47 +171,17 @@ class SearchScreenState extends State<SearchScreen> {
: null,
),
onChanged: (value) {
setState(() {});
_onSearchChanged(value);
},
onSubmitted: _performSearch,
),
),
body: !maProvider.isConnected
? _buildDisconnectedView()
? DisconnectedState.simple()
: _buildSearchContent(),
);
}
Widget _buildDisconnectedView() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: 64,
color: colorScheme.onBackground.withOpacity(0.54),
),
const SizedBox(height: 16),
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildSearchContent() {
final colorScheme = Theme.of(context).colorScheme;
@@ -244,26 +250,7 @@ class SearchScreenState extends State<SearchScreen> {
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
if (!hasResults) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off_rounded,
size: 64,
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No results found',
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.5),
fontSize: 16,
),
),
],
),
);
return EmptyState.search();
}
return Column(
@@ -293,30 +280,77 @@ class SearchScreenState extends State<SearchScreen> {
// Results
Expanded(
child: ListView(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
children: [
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) ...[
if (_activeFilter == 'all') _buildSectionHeader('Artists', artists.length),
...artists.map((item) => _buildArtistTile(item as Artist)),
const SizedBox(height: 24),
],
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) ...[
if (_activeFilter == 'all') _buildSectionHeader('Albums', albums.length),
...albums.map((item) => _buildAlbumTile(item as Album)),
const SizedBox(height: 24),
],
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) ...[
if (_activeFilter == 'all') _buildSectionHeader('Tracks', tracks.length),
...tracks.map((item) => _buildTrackTile(item as Track)),
],
],
child: Builder(
builder: (context) {
final listItems = _buildListItems(artists, albums, tracks);
return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
itemCount: listItems.length,
itemBuilder: (context, index) {
final item = listItems[index];
switch (item.type) {
case _ListItemType.header:
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
case _ListItemType.artist:
return _buildArtistTile(item.mediaItem! as Artist);
case _ListItemType.album:
return _buildAlbumTile(item.mediaItem! as Album);
case _ListItemType.track:
return _buildTrackTile(item.mediaItem! as Track);
case _ListItemType.spacer:
return const SizedBox(height: 24);
}
},
);
},
),
),
],
);
}
List<_ListItem> _buildListItems(
List<MediaItem> artists,
List<MediaItem> albums,
List<MediaItem> tracks,
) {
final items = <_ListItem>[];
// Add artists section
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Artists', artists.length));
}
for (final artist in artists) {
items.add(_ListItem.artist(artist));
}
items.add(_ListItem.spacer());
}
// Add albums section
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Albums', albums.length));
}
for (final album in albums) {
items.add(_ListItem.album(album));
}
items.add(_ListItem.spacer());
}
// Add tracks section
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Tracks', tracks.length));
}
for (final track in tracks) {
items.add(_ListItem.track(track));
}
}
return items;
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _activeFilter == value;
final colorScheme = Theme.of(context).colorScheme;
+254
View File
@@ -0,0 +1,254 @@
import '../models/media_item.dart';
import '../services/debug_logger.dart';
import '../constants/timings.dart';
/// Centralized cache service for all app data caching
/// This is a non-notifying service - it just stores and retrieves cached data
class CacheService {
final DebugLogger _logger = DebugLogger();
// Home screen row caching
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
// Detail screen caching
final Map<String, List<Track>> _albumTracksCache = {};
final Map<String, DateTime> _albumTracksCacheTime = {};
final Map<String, List<Track>> _playlistTracksCache = {};
final Map<String, DateTime> _playlistTracksCacheTime = {};
final Map<String, List<Album>> _artistAlbumsCache = {};
final Map<String, DateTime> _artistAlbumsCacheTime = {};
// Search results caching
final Map<String, Map<String, List<MediaItem>>> _searchCache = {};
final Map<String, DateTime> _searchCacheTime = {};
// Player list caching
DateTime? _playersLastFetched;
// Player track cache (for smooth swipe transitions)
final Map<String, Track?> _playerTrackCache = {};
// ============================================================================
// HOME SCREEN ROW CACHING
// ============================================================================
/// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedRecentAlbums != null &&
_recentAlbumsLastFetched != null &&
now.difference(_recentAlbumsLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached recent albums
List<Album>? getCachedRecentAlbums() => _cachedRecentAlbums;
/// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) {
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
}
/// Check if discover artists cache is valid
bool isDiscoverArtistsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverArtists != null &&
_discoverArtistsLastFetched != null &&
now.difference(_discoverArtistsLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover artists
List<Artist>? getCachedDiscoverArtists() => _cachedDiscoverArtists;
/// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) {
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
}
/// Check if discover albums cache is valid
bool isDiscoverAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverAlbums != null &&
_discoverAlbumsLastFetched != null &&
now.difference(_discoverAlbumsLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover albums
List<Album>? getCachedDiscoverAlbums() => _cachedDiscoverAlbums;
/// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) {
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_logger.log('🗑️ Home screen cache invalidated');
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
/// Check if album tracks cache is valid
bool isAlbumTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
final cacheTime = _albumTracksCacheTime[cacheKey];
return _albumTracksCache.containsKey(cacheKey) &&
cacheTime != null &&
now.difference(cacheTime) < const Duration(minutes: 5);
}
/// Get cached album tracks
List<Track>? getCachedAlbumTracks(String cacheKey) => _albumTracksCache[cacheKey];
/// Set cached album tracks
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
_albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = DateTime.now();
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_albumTracksCache.remove(albumId);
_albumTracksCacheTime.remove(albumId);
}
/// Check if playlist tracks cache is valid
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
final cacheTime = _playlistTracksCacheTime[cacheKey];
return _playlistTracksCache.containsKey(cacheKey) &&
cacheTime != null &&
now.difference(cacheTime) < const Duration(minutes: 5);
}
/// Get cached playlist tracks
List<Track>? getCachedPlaylistTracks(String cacheKey) => _playlistTracksCache[cacheKey];
/// Set cached playlist tracks
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
_playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = DateTime.now();
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_playlistTracksCache.remove(playlistId);
_playlistTracksCacheTime.remove(playlistId);
}
/// Check if artist albums cache is valid
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
final cacheTime = _artistAlbumsCacheTime[cacheKey];
return _artistAlbumsCache.containsKey(cacheKey) &&
cacheTime != null &&
now.difference(cacheTime) < Timings.homeRowCacheDuration;
}
/// Get cached artist albums
List<Album>? getCachedArtistAlbums(String cacheKey) => _artistAlbumsCache[cacheKey];
/// Set cached artist albums
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
_artistAlbumsCache[cacheKey] = albums;
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
}
// ============================================================================
// SEARCH CACHING
// ============================================================================
/// Check if search cache is valid
bool isSearchCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
final cacheTime = _searchCacheTime[cacheKey];
return _searchCache.containsKey(cacheKey) &&
cacheTime != null &&
now.difference(cacheTime) < Timings.homeRowCacheDuration;
}
/// Get cached search results
Map<String, List<MediaItem>>? getCachedSearchResults(String cacheKey) => _searchCache[cacheKey];
/// Set cached search results
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
_searchCache[cacheKey] = results;
_searchCacheTime[cacheKey] = DateTime.now();
_logger.log('✅ Cached search results for "$cacheKey"');
}
// ============================================================================
// PLAYER CACHING
// ============================================================================
/// Check if players cache is valid
bool isPlayersCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _playersLastFetched != null &&
now.difference(_playersLastFetched!) < Timings.playersCacheDuration;
}
/// Update players cache timestamp
void updatePlayersLastFetched() {
_playersLastFetched = DateTime.now();
}
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
/// Set cached track for a player
void setCachedTrackForPlayer(String playerId, Track? track) {
_playerTrackCache[playerId] = track;
}
// ============================================================================
// CLEAR ALL
// ============================================================================
/// Clear all detail caches (call on disconnect/reconnect)
void clearAllDetailCaches() {
_albumTracksCache.clear();
_albumTracksCacheTime.clear();
_playlistTracksCache.clear();
_playlistTracksCacheTime.clear();
_artistAlbumsCache.clear();
_artistAlbumsCacheTime.clear();
_searchCache.clear();
_searchCacheTime.clear();
_playerTrackCache.clear();
_logger.log('🗑️ All detail caches cleared');
}
/// Clear all caches
void clearAll() {
invalidateHomeCache();
clearAllDetailCaches();
_playersLastFetched = null;
}
}
-221
View File
@@ -1,221 +0,0 @@
import '../constants/timings.dart' show LibraryConstants;
import '../models/media_item.dart';
import 'music_assistant_api.dart';
import 'debug_logger.dart';
import 'error_handler.dart';
/// Service responsible for library data management.
/// Handles loading and caching of artists, albums, tracks, and search.
class LibraryService {
final DebugLogger _logger = DebugLogger();
MusicAssistantAPI? _api;
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
bool _isLoading = false;
String? _error;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Getters
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
bool get isLoading => _isLoading;
String? get error => _error;
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
/// Set the API instance (called when connection established)
void setApi(MusicAssistantAPI? api) {
_api = api;
}
/// Clear all library data (called on disconnect)
void clear() {
_artists = [];
_albums = [];
_tracks = [];
_error = null;
_isLoading = false;
}
/// Save search state for persistence across screen changes
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
/// Clear search state
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
}
/// Load all library data (artists, albums, tracks) in parallel
/// Returns true if successful
Future<bool> loadLibrary() async {
if (_api == null) return false;
try {
_isLoading = true;
_error = null;
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
_artists = results[0] as List<Artist>;
_albums = results[1] as List<Album>;
_tracks = results[2] as List<Track>;
_isLoading = false;
return true;
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
return false;
}
}
/// Load artists with optional pagination and search
Future<bool> loadArtists({int? limit, int? offset, String? search}) async {
if (_api == null) return false;
try {
_isLoading = true;
_error = null;
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
);
_isLoading = false;
return true;
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
return false;
}
}
/// Load albums with optional pagination, search, and artist filter
Future<bool> loadAlbums({
int? limit,
int? offset,
String? search,
String? artistId,
}) async {
if (_api == null) return false;
try {
_isLoading = true;
_error = null;
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
return true;
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
return false;
}
}
/// Get tracks for a specific album
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
/// Search for artists, albums, and tracks
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) {
return {'artists': [], 'albums': [], 'tracks': []};
}
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
/// Get recent albums
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
if (_api == null) return [];
try {
return await _api!.getRecentAlbums(limit: limit);
} catch (e) {
ErrorHandler.logError('Get recent albums', e);
return [];
}
}
/// Get random albums for discovery
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
if (_api == null) return [];
try {
return await _api!.getRandomAlbums(limit: limit);
} catch (e) {
ErrorHandler.logError('Get random albums', e);
return [];
}
}
/// Toggle favorite status for a media item
Future<void> toggleFavorite(String itemId, String mediaType, bool favorite) async {
if (_api == null) return;
try {
await _api!.setFavorite(itemId, mediaType, favorite);
} catch (e) {
ErrorHandler.logError('Toggle favorite', e);
rethrow;
}
}
/// Get image URL for a media item
String getImageUrl(dynamic item, {int? size}) {
return _api?.getImageUrl(item, size: size) ?? '';
}
/// Get stream URL for a track
String getStreamUrl(String provider, String itemId, {String? uri, List<ProviderMapping>? providerMappings}) {
return _api?.getStreamUrl(provider, itemId, uri: uri, providerMappings: providerMappings) ?? '';
}
}
+62 -4
View File
@@ -41,6 +41,9 @@ class MusicAssistantAPI {
Completer<void>? _connectionCompleter;
// Heartbeat timer to keep WebSocket connection alive
Timer? _heartbeatTimer;
// Cached custom port setting
int? _cachedCustomPort;
@@ -195,7 +198,9 @@ class MusicAssistantAPI {
onDone: () {
_logger.log('WebSocket connection closed');
_updateConnectionState(MAConnectionState.disconnected);
_connectionCompleter?.completeError(Exception('Connection closed'));
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.completeError(Exception('Connection closed'));
}
_reconnect();
},
);
@@ -209,12 +214,16 @@ class MusicAssistantAPI {
);
_logger.log('Connection: Connected to server');
_connectionInProgress?.complete();
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_connectionInProgress!.complete();
}
_connectionInProgress = null;
} catch (e) {
_logger.log('Connection: Failed - $e');
_updateConnectionState(MAConnectionState.error);
_connectionInProgress?.completeError(e);
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_connectionInProgress!.completeError(e);
}
_connectionInProgress = null;
rethrow;
}
@@ -247,7 +256,10 @@ class MusicAssistantAPI {
}
_updateConnectionState(MAConnectionState.connected);
_connectionCompleter?.complete();
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete();
}
_startHeartbeat();
return;
}
@@ -1667,6 +1679,11 @@ class MusicAssistantAPI {
final imagePath = selectedImage['path'] as String?;
if (imagePath == null) return null;
// If path is already a full URL (from player_updated event), return it directly
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
// Always use imageproxy endpoint to ensure images route through our server
// This fixes images not loading when connecting via external domain
// (direct URLs may contain internal IPs not reachable from outside the network)
@@ -1914,6 +1931,45 @@ class MusicAssistantAPI {
_connectionStateController.add(state);
}
/// Start heartbeat timer to keep WebSocket connection alive
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(Timings.heartbeatInterval, (_) {
_sendHeartbeat();
});
}
/// Stop heartbeat timer
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
/// Send a ping message to keep the connection alive
Future<void> _sendHeartbeat() async {
if (_currentState != MAConnectionState.connected &&
_currentState != MAConnectionState.authenticated) {
return;
}
try {
// Send a lightweight command to check connection is alive
// Using 'info' as it's a valid MA command
await _sendCommand('info').timeout(
const Duration(seconds: 10),
onTimeout: () {
_logger.log('Heartbeat timeout - connection may be stale');
return <String, dynamic>{};
},
);
} catch (e) {
_logger.log('Heartbeat failed: $e');
// Connection may be dead, trigger reconnect
_updateConnectionState(MAConnectionState.error);
_reconnect();
}
}
Future<void> _reconnect() async {
await Future.delayed(Timings.reconnectDelay);
if (_currentState != MAConnectionState.connected) {
@@ -1926,6 +1982,7 @@ class MusicAssistantAPI {
}
Future<void> disconnect() async {
_stopHeartbeat();
_updateConnectionState(MAConnectionState.disconnected);
await _channel?.sink.close();
_channel = null;
@@ -1933,6 +1990,7 @@ class MusicAssistantAPI {
}
void dispose() {
_stopHeartbeat();
disconnect();
_connectionStateController.close();
for (final stream in _eventStreams.values) {
-391
View File
@@ -1,391 +0,0 @@
import 'dart:async';
import '../constants/timings.dart' show Timings;
import '../models/player.dart';
import '../models/media_item.dart';
import 'music_assistant_api.dart';
import 'settings_service.dart';
import 'debug_logger.dart';
import 'error_handler.dart';
/// Callback type for notifying state changes
typedef StateChangeCallback = void Function();
/// Service responsible for player state management.
/// Handles player selection, playback controls, and state polling.
class PlayerStateService {
final DebugLogger _logger = DebugLogger();
final StateChangeCallback? onStateChanged;
MusicAssistantAPI? _api;
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Track? _currentTrack;
Timer? _playerStateTimer;
// Player list caching
DateTime? _playersLastFetched;
PlayerStateService({this.onStateChanged});
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
MusicAssistantAPI? get api => _api;
/// Set the API instance (called when connection established)
void setApi(MusicAssistantAPI? api) {
_api = api;
}
/// Clear all player state (called on disconnect)
void clear() {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_selectedPlayer = null;
_availablePlayers = [];
_currentTrack = null;
_playersLastFetched = null;
}
/// Get all players from the API
Future<List<Player>> getPlayers() async {
if (_api == null) return [];
try {
return await _api!.getPlayers();
} catch (e) {
ErrorHandler.logError('Get players', e);
return [];
}
}
/// Load available players and auto-select one
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
try {
// Check cache first
final now = DateTime.now();
if (!forceRefresh &&
_playersLastFetched != null &&
_availablePlayers.isNotEmpty &&
now.difference(_playersLastFetched!) < Timings.playersCacheDuration) {
return;
}
final allPlayers = await getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
// Filter out unavailable players and legacy ghosts
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
// Filter out legacy "Music Assistant Mobile" ghosts
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
// Filter out unavailable players (ghost players from old installations)
// Exception: Keep our own player even if temporarily unavailable
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
_playersLastFetched = DateTime.now();
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
// Keep current selection if still valid
if (_selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
// Only auto-select if NO player is currently selected
if (playerToSelect == null) {
// Priority 1: Local player (this device)
if (builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found
}
}
// Priority 2: A currently playing player
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
);
} catch (e) {
// No playing player
}
}
// Priority 3: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
}
}
selectPlayer(playerToSelect);
}
onStateChanged?.call();
} catch (e) {
ErrorHandler.logError('Load players', e);
}
}
/// Select a player and start polling its state
void selectPlayer(Player player) {
_selectedPlayer = player;
_startPlayerPolling();
onStateChanged?.call();
}
/// Start polling player state at configured interval
void _startPlayerPolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
// Also update immediately
updatePlayerState();
}
/// Update the selected player's state from the server
Future<void> updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
final updatedPlayer = await _api!.getPlayer(_selectedPlayer!.playerId);
if (updatedPlayer != null) {
_selectedPlayer = updatedPlayer;
// Get current track info if playing
if (updatedPlayer.currentMedia != null) {
_currentTrack = updatedPlayer.currentMedia;
}
onStateChanged?.call();
}
} catch (e) {
_logger.log('Error updating player state: $e');
}
}
/// Refresh the list of available players
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
await loadAndSelectPlayers(forceRefresh: true);
// If selected player changed state, notify listeners
if (_selectedPlayer?.state != previousState) {
onStateChanged?.call();
}
}
// ============================================================================
// PLAYBACK CONTROLS
// ============================================================================
Future<void> pausePlayer(String playerId) async {
try {
await _api?.pausePlayer(playerId);
} catch (e) {
ErrorHandler.logError('Pause player', e);
rethrow;
}
}
Future<void> resumePlayer(String playerId) async {
try {
await _api?.playPlayer(playerId);
} catch (e) {
ErrorHandler.logError('Resume player', e);
rethrow;
}
}
Future<void> stopPlayer(String playerId) async {
try {
await _api?.stopPlayer(playerId);
} catch (e) {
ErrorHandler.logError('Stop player', e);
rethrow;
}
}
Future<void> nextTrack(String playerId) async {
try {
await _api?.nextTrack(playerId);
} catch (e) {
ErrorHandler.logError('Next track', e);
rethrow;
}
}
Future<void> previousTrack(String playerId) async {
try {
await _api?.previousTrack(playerId);
} catch (e) {
ErrorHandler.logError('Previous track', e);
rethrow;
}
}
Future<void> togglePower(String playerId) async {
try {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => throw Exception('Player not found'),
);
if (player.powered) {
await _api?.powerOffPlayer(playerId);
} else {
await _api?.powerOnPlayer(playerId);
}
await refreshPlayers();
} catch (e) {
_logger.log('ERROR in togglePower: $e');
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
await _api?.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
try {
await _api?.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
try {
await _api?.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId) async {
try {
await _api?.toggleShuffle(queueId);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
try {
await _api?.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
/// Cycle through repeat modes: off -> all -> one -> off
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
// ============================================================================
// CONVENIENCE METHODS (for selected player)
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null) return;
if (_selectedPlayer!.state == 'playing') {
await pausePlayer(_selectedPlayer!.playerId);
} else {
await resumePlayer(_selectedPlayer!.playerId);
}
await Future.delayed(Timings.trackChangeDelay);
await updatePlayerState();
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null) return;
await nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null) return;
await previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await updatePlayerState();
}
}
+16
View File
@@ -20,6 +20,7 @@ class SettingsService {
static const String _keyEnableLocalPlayback = 'enable_local_playback';
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
@@ -283,6 +284,21 @@ class SettingsService {
await prefs.setString(_keyOwnerName, name.trim());
}
// Last selected player ID - persists user's player selection across sessions
static Future<String?> getLastSelectedPlayerId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLastSelectedPlayerId);
}
static Future<void> setLastSelectedPlayerId(String? playerId) async {
final prefs = await SharedPreferences.getInstance();
if (playerId == null || playerId.isEmpty) {
await prefs.remove(_keyLastSelectedPlayerId);
} else {
await prefs.setString(_keyLastSelectedPlayerId, playerId);
}
}
// Helper to create player name with possessive apostrophe
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
/// Design tokens for consistent spacing, dimensions, and styling throughout the app.
///
/// Usage:
/// ```dart
/// Padding(padding: Spacing.paddingAll16)
/// SizedBox(height: Spacing.md)
/// BorderRadius.circular(Radii.md)
/// ```
class Spacing {
Spacing._();
// Base spacing scale (follows 4px grid)
static const double xxs = 2.0;
static const double xs = 4.0;
static const double sm = 8.0;
static const double md = 12.0;
static const double lg = 16.0;
static const double xl = 24.0;
static const double xxl = 32.0;
static const double xxxl = 48.0;
// Pre-built EdgeInsets for common padding patterns
static const EdgeInsets paddingAll4 = EdgeInsets.all(xs);
static const EdgeInsets paddingAll8 = EdgeInsets.all(sm);
static const EdgeInsets paddingAll12 = EdgeInsets.all(md);
static const EdgeInsets paddingAll16 = EdgeInsets.all(lg);
static const EdgeInsets paddingAll24 = EdgeInsets.all(xl);
static const EdgeInsets paddingAll32 = EdgeInsets.all(xxl);
// Horizontal padding
static const EdgeInsets paddingH8 = EdgeInsets.symmetric(horizontal: sm);
static const EdgeInsets paddingH12 = EdgeInsets.symmetric(horizontal: md);
static const EdgeInsets paddingH16 = EdgeInsets.symmetric(horizontal: lg);
static const EdgeInsets paddingH24 = EdgeInsets.symmetric(horizontal: xl);
// Vertical padding
static const EdgeInsets paddingV8 = EdgeInsets.symmetric(vertical: sm);
static const EdgeInsets paddingV12 = EdgeInsets.symmetric(vertical: md);
static const EdgeInsets paddingV16 = EdgeInsets.symmetric(vertical: lg);
static const EdgeInsets paddingV24 = EdgeInsets.symmetric(vertical: xl);
// Pre-built SizedBox widgets for gaps
static const SizedBox vGap4 = SizedBox(height: xs);
static const SizedBox vGap8 = SizedBox(height: sm);
static const SizedBox vGap12 = SizedBox(height: md);
static const SizedBox vGap16 = SizedBox(height: lg);
static const SizedBox vGap24 = SizedBox(height: xl);
static const SizedBox vGap32 = SizedBox(height: xxl);
static const SizedBox hGap4 = SizedBox(width: xs);
static const SizedBox hGap8 = SizedBox(width: sm);
static const SizedBox hGap12 = SizedBox(width: md);
static const SizedBox hGap16 = SizedBox(width: lg);
}
/// Border radius tokens
class Radii {
Radii._();
static const double xs = 2.0;
static const double sm = 4.0;
static const double md = 8.0;
static const double lg = 12.0;
static const double xl = 16.0;
static const double xxl = 24.0;
static const double pill = 999.0;
// Pre-built BorderRadius for common patterns
static final BorderRadius borderRadiusSm = BorderRadius.circular(sm);
static final BorderRadius borderRadiusMd = BorderRadius.circular(md);
static final BorderRadius borderRadiusLg = BorderRadius.circular(lg);
static final BorderRadius borderRadiusXl = BorderRadius.circular(xl);
static final BorderRadius borderRadiusXxl = BorderRadius.circular(xxl);
}
/// Icon size tokens
class IconSizes {
IconSizes._();
static const double xs = 16.0;
static const double sm = 20.0;
static const double md = 24.0;
static const double lg = 32.0;
static const double xl = 48.0;
static const double xxl = 64.0;
static const double xxxl = 80.0;
}
/// Common dimension tokens
class Dimensions {
Dimensions._();
// Album art / thumbnail sizes
static const double thumbnailSm = 48.0;
static const double thumbnailMd = 80.0;
static const double thumbnailLg = 128.0;
static const double thumbnailXl = 200.0;
// Avatar sizes
static const double avatarSm = 32.0;
static const double avatarMd = 48.0;
static const double avatarLg = 64.0;
// List tile heights
static const double listTileHeight = 56.0;
static const double listTileHeightDense = 48.0;
// Mini player height
static const double miniPlayerHeight = 64.0;
// App bar heights
static const double appBarHeight = 56.0;
static const double expandedAppBarHeight = 200.0;
}
/// Duration tokens for animations
class Durations {
Durations._();
static const Duration instant = Duration(milliseconds: 100);
static const Duration fast = Duration(milliseconds: 200);
static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500);
}
+14 -3
View File
@@ -56,10 +56,21 @@ class PaletteHelper {
const Color(0xFF604CEC);
// Ensure primary has enough saturation to be visually distinct
// AND enough lightness for white text to be readable on it
final HSLColor hslPrimary = HSLColor.fromColor(primary);
final Color adjustedPrimary = hslPrimary.saturation < 0.3
? hslPrimary.withSaturation(0.5).toColor()
: primary;
Color adjustedPrimary = primary;
// Adjust saturation if too low
if (hslPrimary.saturation < 0.3) {
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
}
// Ensure minimum lightness for contrast with white text
// If primary is too dark (luminance < 0.15), lighten it
if (adjustedPrimary.computeLuminance() < 0.15) {
final hsl = HSLColor.fromColor(adjustedPrimary);
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
}
if (isDark) {
// Dark mode: use dark muted colors for background
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
/// A reusable widget for displaying disconnected/offline state.
class DisconnectedState extends StatelessWidget {
final String title;
final String? subtitle;
final String? actionLabel;
final VoidCallback? onAction;
final IconData? actionIcon;
final double iconSize;
const DisconnectedState({
super.key,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.actionIcon,
this.iconSize = 64,
});
/// Simple disconnected state without action button
factory DisconnectedState.simple() => const DisconnectedState(
title: 'Not connected to Music Assistant',
);
/// Disconnected state with settings button for navigation
factory DisconnectedState.withSettingsAction({
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not connected to Music Assistant',
actionLabel: 'Configure Server',
actionIcon: Icons.settings_rounded,
onAction: onSettings,
);
/// Full disconnected state with title, subtitle, and action (for home screen)
factory DisconnectedState.full({
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not Connected',
subtitle: 'Connect to your Music Assistant server to start listening',
actionLabel: 'Configure Server',
actionIcon: Icons.settings_rounded,
onAction: onSettings,
iconSize: 80,
);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: Spacing.paddingAll32,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: iconSize,
color: colorScheme.onSurface.withOpacity(0.54),
),
SizedBox(height: iconSize > IconSizes.xxl ? Spacing.xl : Spacing.lg),
Text(
title,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(subtitle != null ? 1.0 : 0.7),
fontSize: subtitle != null ? 24 : 16,
fontWeight: subtitle != null ? FontWeight.w300 : FontWeight.normal,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
Spacing.vGap12,
Text(
subtitle!,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
SizedBox(height: subtitle != null ? Spacing.xxl : Spacing.xl),
ElevatedButton.icon(
onPressed: onAction,
icon: actionIcon != null ? Icon(actionIcon) : const SizedBox.shrink(),
label: Text(actionLabel!),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: EdgeInsets.symmetric(
horizontal: subtitle != null ? Spacing.xxl : Spacing.xl,
vertical: subtitle != null ? Spacing.lg : Spacing.md,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
),
],
],
),
),
);
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
/// A reusable empty state widget for displaying when no data is available.
///
/// Shows an icon, message, and optional action button.
class EmptyState extends StatelessWidget {
final IconData icon;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
final double iconSize;
const EmptyState({
super.key,
required this.icon,
required this.message,
this.actionLabel,
this.onAction,
this.iconSize = 64,
});
/// Creates an empty state for artists
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.person_outline_rounded,
message: 'No artists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
/// Creates an empty state for albums
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.album_outlined,
message: 'No albums found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
/// Creates an empty state for tracks
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.music_note_outlined,
message: 'No tracks found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
/// Creates an empty state for playlists
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.playlist_play_rounded,
message: 'No playlists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
/// Creates an empty state for queue
factory EmptyState.queue() => const EmptyState(
icon: Icons.queue_music,
message: 'Queue is empty',
);
/// Creates an empty state for search results
factory EmptyState.search() => const EmptyState(
icon: Icons.search_off_rounded,
message: 'No results found',
);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: Spacing.paddingAll24,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: iconSize,
color: colorScheme.onSurface.withOpacity(0.38),
),
Spacing.vGap16,
Text(
message,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 16,
),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
Spacing.vGap24,
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh_rounded),
label: Text(actionLabel!),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../services/debug_logger.dart';
import '../../theme/design_tokens.dart';
/// A collapsible debug console for viewing application logs
class DebugConsole extends StatelessWidget {
final VoidCallback onClear;
final int maxLogs;
const DebugConsole({
super.key,
required this.onClear,
this.maxLogs = 50,
});
@override
Widget build(BuildContext context) {
return Container(
padding: Spacing.paddingAll12,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
const Divider(color: Colors.green, height: 8),
Expanded(
child: _buildLogsList(),
),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Row(
children: [
const Icon(Icons.terminal, color: Colors.green, size: IconSizes.xs),
Spacing.hGap8,
const Text(
'Debug Console',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: () => _copyLogs(context),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(50, 20),
),
icon: const Icon(Icons.copy, size: 10, color: Colors.green),
label: const Text(
'Copy',
style: TextStyle(fontSize: 10, color: Colors.green),
),
),
TextButton(
onPressed: () {
DebugLogger().clear();
onClear();
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(40, 20),
),
child: const Text(
'Clear',
style: TextStyle(fontSize: 10, color: Colors.green),
),
),
],
);
}
void _copyLogs(BuildContext context) {
final logs = DebugLogger().getAllLogs();
if (logs.isNotEmpty) {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No logs to copy'),
duration: Duration(seconds: 2),
),
);
}
}
Widget _buildLogsList() {
return SingleChildScrollView(
reverse: true,
child: Builder(
builder: (context) {
final allLogs = DebugLogger().logs;
final recentLogs = allLogs.length > maxLogs
? allLogs.sublist(allLogs.length - maxLogs)
: allLogs;
if (recentLogs.isEmpty) {
return const Text(
'No debug logs yet. Try detecting auth.',
style: TextStyle(
color: Colors.green,
fontSize: 10,
fontStyle: FontStyle.italic,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: recentLogs
.map((log) => Padding(
padding: EdgeInsets.only(bottom: Spacing.xxs),
child: Text(
log,
style: const TextStyle(
color: Colors.green,
fontSize: 10,
fontFamily: 'monospace',
),
),
))
.toList(),
);
},
),
);
}
}
/// A toggle button for showing/hiding the debug console
class DebugToggleButton extends StatelessWidget {
final bool isVisible;
final VoidCallback onToggle;
const DebugToggleButton({
super.key,
required this.isVisible,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: onToggle,
icon: Icon(
isVisible ? Icons.bug_report : Icons.bug_report_outlined,
size: 16,
),
label: Text(
isVisible ? 'Hide Debug' : 'Show Debug',
style: const TextStyle(fontSize: 12),
),
);
}
}
+315 -239
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:ui';
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 '../constants/timings.dart';
@@ -12,6 +13,7 @@ import '../theme/theme_provider.dart';
import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'volume_control.dart';
import 'player/player_widgets.dart';
/// A unified player widget that seamlessly expands from mini to full-screen.
///
@@ -52,6 +54,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Progress timer for elapsed time updates
Timer? _progressTimer;
double? _seekPosition;
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
// Dimensions
static const double _collapsedHeight = 64.0;
@@ -64,6 +68,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Track horizontal drag start position
double? _horizontalDragStartX;
// Slide animation for device switching
late AnimationController _slideController;
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
bool _isSliding = false;
@override
void initState() {
super.initState();
@@ -91,6 +100,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
reverseCurve: Curves.easeInCubic,
);
// Slide animation for device switching
_slideController = AnimationController(
duration: const Duration(milliseconds: 75),
vsync: this,
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.forward) {
_loadQueue();
@@ -133,8 +148,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void dispose() {
_controller.dispose();
_queuePanelController.dispose();
_slideController.dispose();
_progressTimer?.cancel();
_queueRefreshTimer?.cancel();
_progressNotifier.dispose();
_seekPositionNotifier.dispose();
super.dispose();
}
@@ -166,9 +184,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void _startProgressTimer() {
_progressTimer?.cancel();
// Use local player report interval for progress updates (1 second)
// Update only the progress notifier to avoid rebuilding the entire widget tree
_progressTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) {
if (mounted && isExpanded) {
setState(() {});
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
_progressNotifier.value = selectedPlayer.currentElapsedTime.toInt();
}
}
});
}
@@ -252,6 +275,103 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
/// Get available players sorted alphabetically (consistent with device selector)
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
// Use the provider's already-sorted list, filter for available only
return maProvider.availablePlayers.where((p) => p.available).toList();
}
/// Cycle to the next available player (for swipe gesture)
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
final players = _getAvailablePlayersSorted(maProvider);
if (players.length <= 1) return;
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
// If current player not found in list, start from beginning
if (currentIndex == -1) {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(players[0]);
return;
}
int nextIndex;
if (reverse) {
nextIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
}
// Haptic feedback on device switch
HapticFeedback.mediumImpact();
// Animate slide transition
_animateSlide(reverse ? 1 : -1, () {
maProvider.selectPlayer(players[nextIndex]);
});
}
/// Animate the slide transition when switching devices
/// direction: -1 = swipe left (content exits left, new enters from right)
/// 1 = swipe right (content exits right, new enters from left)
void _animateSlide(int direction, VoidCallback onComplete) {
if (_isSliding) return;
_isSliding = true;
final slideOutTarget = direction.toDouble(); // Where current content goes
final slideInStart = -direction.toDouble(); // Where new content comes from
// Use easing curves for smoother feel
const outCurve = Curves.easeInCubic; // Accelerate out
const inCurve = Curves.easeOutCubic; // Decelerate in
// Phase 1: Slide out current content
_slideOffset = 0.0;
_slideController.reset();
void animateOut() {
if (!mounted) return;
final curvedValue = outCurve.transform(_slideController.value);
setState(() {
_slideOffset = slideOutTarget * curvedValue;
});
}
void animateIn() {
if (!mounted) return;
final curvedValue = inCurve.transform(_slideController.value);
setState(() {
_slideOffset = slideInStart * (1.0 - curvedValue);
});
}
_slideController.addListener(animateOut);
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateOut);
// Switch the data
onComplete();
// Phase 2: Slide in new content
_slideOffset = slideInStart;
_slideController.reset();
_slideController.addListener(animateIn);
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateIn);
setState(() {
_slideOffset = 0.0;
_isSliding = false;
});
});
});
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
@@ -261,12 +381,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Don't show if no track or player
if (currentTrack == null || selectedPlayer == null) {
// Don't show if no player selected
if (selectedPlayer == null) {
return const SizedBox.shrink();
}
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
final imageUrl = currentTrack != null
? maProvider.getImageUrl(currentTrack, size: 512)
: null;
// Extract colors for adaptive theme
if (themeProvider.adaptiveTheme && imageUrl != null) {
@@ -276,6 +398,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return AnimatedBuilder(
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
builder: (context, _) {
// If no track is playing, show a compact device selector bar
if (currentTrack == null) {
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
}
return _buildMorphingPlayer(
context,
maProvider,
@@ -290,6 +416,67 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
);
}
/// Build a compact device selector bar when no track is playing
Widget _buildDeviceSelectorBar(
BuildContext context,
MusicAssistantProvider maProvider,
dynamic selectedPlayer,
ThemeProvider themeProvider,
) {
final screenSize = MediaQuery.of(context).size;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get adaptive colors if available
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
final backgroundColor = themeProvider.adaptiveTheme && adaptiveScheme != null
? adaptiveScheme.primaryContainer
: colorScheme.primaryContainer;
final textColor = themeProvider.adaptiveTheme && adaptiveScheme != null
? adaptiveScheme.onPrimaryContainer
: colorScheme.onPrimaryContainer;
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
// Apply slide offset for hiding
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
final adjustedBottomOffset = bottomOffset - slideDownAmount;
final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1;
return Positioned(
left: _collapsedMargin,
right: _collapsedMargin,
bottom: adjustedBottomOffset,
child: DeviceSelectorBar(
selectedPlayer: selectedPlayer,
hasMultiplePlayers: hasMultiplePlayers,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
height: _collapsedHeight,
borderRadius: _collapsedBorderRadius,
slideOffset: _slideOffset,
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -300) {
_cycleToNextPlayer(maProvider);
} else if (details.primaryVelocity! > 300) {
_cycleToNextPlayer(maProvider, reverse: true);
}
}
},
),
);
}
Widget _buildMorphingPlayer(
BuildContext context,
MusicAssistantProvider maProvider,
@@ -446,6 +633,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Queue panel slide amount (0 = hidden, 1 = fully visible)
final queueT = _queuePanelAnimation.value;
// Check if we have multiple players for swipe gesture
final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1;
final selectedPlayerId = selectedPlayer.playerId;
// Calculate slide offset for mini player content (only when collapsed)
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
return Positioned(
left: horizontalMargin,
right: horizontalMargin,
@@ -463,10 +658,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
collapse();
}
},
onHorizontalDragStart: isExpanded ? (details) {
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
} : null,
onHorizontalDragEnd: isExpanded ? (details) {
},
onHorizontalDragEnd: (details) {
// Ignore swipes that started near the right edge (Android back gesture zone)
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
@@ -475,15 +670,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (startedInDeadZone) return;
// Swipe left to open queue, swipe right to close
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
_toggleQueuePanel();
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
_toggleQueuePanel();
if (isExpanded) {
// Expanded mode: swipe to open/close queue
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
_toggleQueuePanel();
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
_toggleQueuePanel();
}
} else if (hasMultiplePlayers) {
// Collapsed mode: swipe to switch devices
if (details.primaryVelocity! < -300) {
_cycleToNextPlayer(maProvider);
} else if (details.primaryVelocity! > 300) {
_cycleToNextPlayer(maProvider, reverse: true);
}
}
}
} : null,
},
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
@@ -496,9 +700,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: Stack(
clipBehavior: Clip.none,
children: [
// Album art
// Album art - with slide animation when collapsed
Positioned(
left: artLeft,
left: artLeft + miniPlayerSlideOffset,
top: artTop,
child: Container(
width: artSize,
@@ -518,21 +722,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: ClipRRect(
borderRadius: BorderRadius.circular(artBorderRadius),
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
cacheWidth: t > 0.5 ? 1024 : 128,
cacheHeight: t > 0.5 ? 1024 : 128,
errorBuilder: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
memCacheWidth: t > 0.5 ? 1024 : 256,
memCacheHeight: t > 0.5 ? 1024 : 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
)
: _buildPlaceholderArt(colorScheme, t),
),
),
),
// Track title
// Track title - with slide animation when collapsed
Positioned(
left: titleLeft,
left: titleLeft + miniPlayerSlideOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
@@ -552,9 +760,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
// Artist name
// Artist name - with slide animation when collapsed
Positioned(
left: titleLeft,
left: titleLeft + miniPlayerSlideOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
@@ -602,74 +810,87 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
top: expandedProgressTop,
child: Opacity(
opacity: expandedElementsOpacity,
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
trackShape: const RoundedRectSliderTrackShape(),
),
child: Slider(
value: (_seekPosition ?? selectedPlayer.currentElapsedTime)
.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => setState(() => _seekPosition = value),
onChangeStart: (value) => setState(() => _seekPosition = value),
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
);
}
} finally {
if (mounted) setState(() => _seekPosition = null);
}
},
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 13, // Increased from 11 to 13
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier,
builder: (context, elapsedTime, child) {
return ValueListenableBuilder<double?>(
valueListenable: _seekPositionNotifier,
builder: (context, seekPosition, child) {
final currentProgress = seekPosition ?? elapsedTime.toDouble();
return Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
trackShape: const RoundedRectSliderTrackShape(),
),
child: Slider(
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => _seekPositionNotifier.value = value,
onChangeStart: (value) => _seekPositionNotifier.value = value,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
);
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
_seekPosition = null;
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.2),
),
),
),
Text(
_formatDuration(currentTrack.duration!.inSeconds),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 13, // Increased from 11 to 13
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(currentProgress.toInt()),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 13, // Increased from 11 to 13
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
Text(
_formatDuration(currentTrack.duration!.inSeconds),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 13, // Increased from 11 to 13
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),
],
),
),
],
],
);
},
);
},
),
),
),
// Playback controls
// Playback controls - with slide animation when collapsed
Positioned(
left: t > 0.5 ? 0 : null,
right: t > 0.5 ? 0 : collapsedControlsRight,
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop,
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -813,13 +1034,16 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
begin: const Offset(1, 0),
end: Offset.zero,
).animate(_queuePanelAnimation),
child: _buildQueuePanel(
maProvider,
selectedPlayer,
textColor,
primaryColor,
topPadding,
expandedBg,
child: QueuePanel(
maProvider: maProvider,
queue: _queue,
isLoading: _isLoadingQueue,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: expandedBg,
topPadding: topPadding,
onClose: _toggleQueuePanel,
onRefresh: _loadQueue,
),
),
),
@@ -832,154 +1056,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
);
}
Widget _buildQueuePanel(
MusicAssistantProvider maProvider,
dynamic selectedPlayer,
Color textColor,
Color primaryColor,
double topPadding,
Color backgroundColor,
) {
return Container(
color: backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: 24),
onPressed: _toggleQueuePanel,
padding: const EdgeInsets.all(12),
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: 22),
onPressed: _loadQueue,
padding: const EdgeInsets.all(12),
),
],
),
),
// Queue content
Expanded(
child: _isLoadingQueue
? Center(child: CircularProgressIndicator(color: primaryColor))
: _queue == null || _queue!.items.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 64, color: textColor.withOpacity(0.3)),
const SizedBox(height: 16),
Text(
'Queue is empty',
style: TextStyle(color: textColor.withOpacity(0.5), fontSize: 16),
),
],
),
)
: _buildQueueList(maProvider, textColor, primaryColor),
),
],
),
);
}
Widget _buildQueueList(
MusicAssistantProvider maProvider,
Color textColor,
Color primaryColor,
) {
final currentIndex = _queue!.currentIndex ?? 0;
final items = _queue!.items;
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
cacheExtent: 500, // Pre-render items off-screen for smoother scrolling
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final isCurrentItem = index == currentIndex;
final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
dense: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 44,
height: 44,
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
),
),
title: Text(
item.track.name,
style: TextStyle(
color: isCurrentItem ? primaryColor : textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: isCurrentItem
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
: null,
onTap: () {
// TODO: Jump to this track in queue
},
),
),
);
},
);
}
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
return Container(
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
+2 -2
View File
@@ -239,9 +239,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, child) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
// Only show player if connected and has a track
// Only show player if connected and has a selected player
// Mini player is now permanent - shows device selector even without a track
if (!maProvider.isConnected ||
maProvider.currentTrack == null ||
maProvider.selectedPlayer == null) {
return const SizedBox.shrink();
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
final dynamic selectedPlayer;
final bool hasMultiplePlayers;
final Color backgroundColor;
final Color textColor;
final double width;
final double height;
final double borderRadius;
final double slideOffset;
final GestureDragEndCallback? onHorizontalDragEnd;
const DeviceSelectorBar({
super.key,
required this.selectedPlayer,
required this.hasMultiplePlayers,
required this.backgroundColor,
required this.textColor,
required this.width,
required this.height,
required this.borderRadius,
required this.slideOffset,
this.onHorizontalDragEnd,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: 4,
shadowColor: Colors.black.withOpacity(0.3),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: width,
height: height,
child: ClipRect(
child: Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
],
),
),
),
),
),
),
);
}
/// Get appropriate icon for player based on name
IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group')) {
return Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
return Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
return Icons.cast_rounded;
} else {
return Icons.speaker_rounded;
}
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../animated_icon_button.dart';
import '../../theme/design_tokens.dart';
/// Playback control buttons (shuffle, previous, play/pause, next, repeat)
class PlayerControls extends StatelessWidget {
final bool isPlaying;
final bool? shuffle;
final String? repeatMode;
final Color textColor;
final Color primaryColor;
final Color backgroundColor;
final double skipButtonSize;
final double playButtonSize;
final double playButtonContainerSize;
final double progress; // Animation progress 0-1
final double expandedElementsOpacity;
final bool isLoadingQueue;
final VoidCallback onPrevious;
final VoidCallback onPlayPause;
final VoidCallback onNext;
final VoidCallback? onStop;
final VoidCallback? onToggleShuffle;
final VoidCallback? onCycleRepeat;
const PlayerControls({
super.key,
required this.isPlaying,
this.shuffle,
this.repeatMode,
required this.textColor,
required this.primaryColor,
required this.backgroundColor,
required this.skipButtonSize,
required this.playButtonSize,
required this.playButtonContainerSize,
required this.progress,
required this.expandedElementsOpacity,
required this.isLoadingQueue,
required this.onPrevious,
required this.onPlayPause,
required this.onNext,
this.onStop,
this.onToggleShuffle,
this.onCycleRepeat,
});
@override
Widget build(BuildContext context) {
final t = progress;
final isExpanded = t > 0.5;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Shuffle (expanded only)
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
// Previous
_buildControlButton(
icon: Icons.skip_previous_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onPrevious,
useAnimation: isExpanded,
),
SizedBox(width: _lerpDouble(0, 20, t)),
// Play/Pause
_buildPlayButton(),
SizedBox(width: _lerpDouble(0, 20, t)),
// Next
_buildControlButton(
icon: Icons.skip_next_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onNext,
useAnimation: isExpanded,
),
// Repeat (expanded only)
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
),
],
);
}
Widget _buildControlButton({
required IconData icon,
required Color color,
required double size,
required VoidCallback onPressed,
required bool useAnimation,
}) {
if (useAnimation) {
return AnimatedIconButton(
icon: icon,
color: color,
iconSize: size,
onPressed: onPressed,
);
}
return IconButton(
icon: Icon(icon),
color: color,
iconSize: size,
onPressed: onPressed,
padding: Spacing.paddingAll4,
constraints: const BoxConstraints(),
);
}
Widget _buildSecondaryButton({
required IconData icon,
required Color color,
VoidCallback? onPressed,
}) {
return SizedBox(
width: 44,
height: 44,
child: IconButton(
icon: Icon(icon),
color: color,
iconSize: 22,
onPressed: onPressed,
padding: EdgeInsets.zero,
),
);
}
Widget _buildPlayButton() {
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
final iconColor = Color.lerp(textColor, backgroundColor, progress);
return GestureDetector(
onLongPress: onStop,
child: Container(
width: playButtonContainerSize,
height: playButtonContainerSize,
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
color: iconColor,
iconSize: playButtonSize,
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
);
}
double _lerpDouble(double a, double b, double t) {
return a + (b - a) * t;
}
}
+6
View File
@@ -0,0 +1,6 @@
/// Player widget components extracted from expandable_player.dart
library player_widgets;
export 'device_selector_bar.dart';
export 'player_controls.dart';
export 'queue_panel.dart';
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart';
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
/// Panel that displays the current playback queue
class QueuePanel extends StatelessWidget {
final MusicAssistantProvider maProvider;
final PlayerQueue? queue;
final bool isLoading;
final Color textColor;
final Color primaryColor;
final Color backgroundColor;
final double topPadding;
final VoidCallback onClose;
final VoidCallback onRefresh;
const QueuePanel({
super.key,
required this.maProvider,
required this.queue,
required this.isLoading,
required this.textColor,
required this.primaryColor,
required this.backgroundColor,
required this.topPadding,
required this.onClose,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
onPressed: onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: onRefresh,
padding: Spacing.paddingAll12,
),
],
),
),
// Queue content
Expanded(
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState()
: _buildQueueList(),
),
],
),
);
}
Widget _buildEmptyState() {
return EmptyState.queue();
}
Widget _buildQueueList() {
final currentIndex = queue!.currentIndex ?? 0;
final items = queue!.items;
return ListView.builder(
padding: Spacing.paddingH8,
cacheExtent: 500,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final isCurrentItem = index == currentIndex;
final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
decoration: BoxDecoration(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: ListTile(
dense: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: SizedBox(
width: 44,
height: 44,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
errorWidget: (context, url, error) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
),
),
title: Text(
item.track.name,
style: TextStyle(
color: isCurrentItem ? primaryColor : textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: isCurrentItem
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
: null,
onTap: () {
// TODO: Jump to this track in queue
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
},
),
),
);
},
);
}
}
+100 -83
View File
@@ -98,10 +98,15 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
@override
void initState() {
super.initState();
// Preload all player track info for display
context.read<MusicAssistantProvider>().preloadAllPlayerTracks();
// Auto-refresh at configured polling interval
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
context.read<MusicAssistantProvider>().refreshPlayers();
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks();
}
});
}
@@ -121,81 +126,92 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
final textTheme = Theme.of(context).textTheme;
final currentTrack = maProvider.currentTrack;
final screenHeight = MediaQuery.of(context).size.height;
final sheetHeight = screenHeight * 0.7; // Fixed 70% height
return GestureDetector(
onTap: () => Navigator.pop(context), // Tap outside to dismiss
behavior: HitTestBehavior.opaque,
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.85,
builder: (context, scrollController) {
return GestureDetector(
onTap: () {}, // Prevent taps on sheet from dismissing
child: Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
child: Align(
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () {}, // Prevent taps on sheet from dismissing
child: Container(
height: sheetHeight,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
const SizedBox(width: 12),
Text(
'Select Player',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Show last refresh indicator
Icon(
Icons.sync,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
size: 16,
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
const SizedBox(width: 12),
Text(
'Select Player',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
)
: ListView.builder(
controller: scrollController,
padding: EdgeInsets.fromLTRB(12, 8, 12, BottomSpacing.navBarOnly),
itemCount: currentPlayers.length,
),
const Spacer(),
// Show last refresh indicator
Icon(
Icons.sync,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
size: 16,
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
),
)
: ListView.builder(
padding: EdgeInsets.fromLTRB(12, 8, 12, BottomSpacing.navBarOnly),
itemCount: currentPlayers.length,
itemBuilder: (context, index) {
final player = currentPlayers[index];
final isSelected = player.playerId == maProvider.selectedPlayer?.playerId;
final isOn = player.available && player.powered;
final isPlaying = player.state == 'playing';
final isPaused = player.state == 'paused';
// MA uses 'idle' for players that were paused (especially cast-based)
// but still have track info - treat as "has content" for display
final isIdle = player.state == 'idle';
// Get album art for selected player if playing
// Get track info - use current track for selected player, cache for others
final playerTrack = isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId);
// Player has content if playing, paused, or idle with cached track
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
// Get album art for any player with content
String? albumArtUrl;
if (isSelected && currentTrack != null && isOn) {
if (playerTrack != null && isOn && hasContent) {
albumArtUrl = maProvider.getImageUrl(
currentTrack.album ?? currentTrack,
playerTrack.album ?? playerTrack,
size: 128,
);
}
@@ -279,27 +295,29 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.onSurface.withOpacity(0.3)
: isPlaying
? Colors.green
: isPaused
? Colors.orange
: hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
const SizedBox(width: 6),
Text(
!player.available
? 'Unavailable'
: !isOn
? 'Off'
: isPlaying
? 'Playing'
: isPaused
? 'Paused'
: 'Idle',
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
: colorScheme.onSurface.withOpacity(0.24),
fontSize: 13,
Expanded(
child: Text(
!player.available
? 'Unavailable'
: !isOn
? 'Off'
: hasContent && playerTrack != null
? playerTrack.name
: 'Idle',
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
: colorScheme.onSurface.withOpacity(0.24),
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isSelected) ...[
@@ -362,12 +380,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
);
},
),
),
],
),
],
),
),
),
);
},
),
);
},
+79 -40
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../services/settings_service.dart';
/// Volume control widget for Music Assistant players
/// For local device: controls system media volume
/// For MA players: controls player volume via API
class VolumeControl extends StatefulWidget {
final bool compact;
@@ -15,7 +20,42 @@ class VolumeControl extends StatefulWidget {
class _VolumeControlState extends State<VolumeControl> {
final _logger = DebugLogger();
double? _pendingVolume; // Track pending volume changes
double? _pendingVolume;
double? _systemVolume;
StreamSubscription? _volumeListener;
String? _localPlayerId;
bool _isLocalPlayer = false;
@override
void initState() {
super.initState();
_initLocalPlayer();
}
Future<void> _initLocalPlayer() async {
_localPlayerId = await SettingsService.getBuiltinPlayerId();
final volume = await FlutterVolumeController.getVolume();
if (mounted) {
setState(() {
_systemVolume = volume;
});
}
_volumeListener = FlutterVolumeController.addListener((volume) {
if (mounted && _isLocalPlayer) {
setState(() {
_systemVolume = volume;
});
}
});
}
@override
void dispose() {
_volumeListener?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -26,11 +66,14 @@ class _VolumeControlState extends State<VolumeControl> {
return const SizedBox.shrink();
}
final currentVolume = (_pendingVolume ?? player.volume.toDouble()) / 100.0;
final isMuted = player.isMuted;
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
final currentVolume = _isLocalPlayer
? (_pendingVolume ?? (_systemVolume ?? 0.5))
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
final isMuted = _isLocalPlayer ? false : player.isMuted;
if (widget.compact) {
// Compact version - just mute button
return IconButton(
icon: Icon(
isMuted ? Icons.volume_off : Icons.volume_up,
@@ -46,10 +89,8 @@ class _VolumeControlState extends State<VolumeControl> {
);
}
// Full volume control with slider
return Row(
children: [
// Mute button
IconButton(
icon: Icon(
isMuted
@@ -67,17 +108,12 @@ class _VolumeControlState extends State<VolumeControl> {
}
},
),
// Volume slider
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 14,
),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
@@ -87,45 +123,48 @@ class _VolumeControlState extends State<VolumeControl> {
value: currentVolume.clamp(0.0, 1.0),
onChanged: (value) {
setState(() {
_pendingVolume = value * 100;
_pendingVolume = _isLocalPlayer ? value : value * 100;
});
},
onChangeEnd: (value) async {
final volumeLevel = (value * 100).round();
_logger.log('Volume: Setting to $volumeLevel%');
try {
// Unmute if changing volume while muted
if (isMuted) {
_logger.log('Volume: Unmuting player first');
await maProvider.setMute(player.playerId, false);
}
await maProvider.setVolume(player.playerId, volumeLevel);
// Wait for the player state to update by polling
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (_isLocalPlayer) {
_logger.log('Volume: Setting system volume to ${(value * 100).round()}%');
try {
await FlutterVolumeController.setVolume(value);
_systemVolume = value;
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
} finally {
if (mounted) {
final updatedPlayer = maProvider.selectedPlayer;
if (updatedPlayer != null && (updatedPlayer.volume - volumeLevel).abs() <= 2) {
_logger.log('Volume: Updated to $volumeLevel%');
break;
}
setState(() {
_pendingVolume = null;
});
}
}
} catch (e) {
_logger.log('Volume: Error - $e');
} finally {
if (mounted) {
setState(() {
_pendingVolume = null;
});
} else {
final volumeLevel = (value * 100).round();
_logger.log('Volume: Setting to $volumeLevel%');
try {
if (isMuted) {
_logger.log('Volume: Unmuting player first');
await maProvider.setMute(player.playerId, false);
}
await maProvider.setVolume(player.playerId, volumeLevel);
_logger.log('Volume: Set to $volumeLevel%');
} catch (e) {
_logger.log('Volume: Error - $e');
} finally {
if (mounted) {
setState(() {
_pendingVolume = null;
});
}
}
}
},
),
),
),
// Volume percentage
SizedBox(
width: 40,
child: Text(
+1 -1
View File
@@ -11,7 +11,6 @@ dependencies:
sdk: flutter
# UI
cupertino_icons: ^1.0.6
material_design_icons_flutter: ^7.0.7296
dynamic_color: 1.6.8
palette_generator: ^0.3.3+3
@@ -34,6 +33,7 @@ dependencies:
just_audio: ^0.9.36
audio_service: ^0.18.12
audio_session: ^0.1.18
flutter_volume_controller: ^1.3.2
dev_dependencies:
flutter_test: