Compare commits

...
317 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 3f9fb5d1e9 Bump version to 2.7.0-beta+30 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:22:40 +00:00
Your NameandClaude Opus 4.5 62e6498b11 Change quick actions to match album view UI pattern
- Use expandable inline row instead of bottom sheet
- Long-press track to expand action buttons
- Actions: Radio (player picker), Add to Queue (player picker), Favorite (toggle)
- Matches existing album details screen behavior

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:55:36 +00:00
Your NameandClaude Opus 4.5 40dd26519d Remove voice search feature
Removed due to speech_to_text Gradle compatibility issues.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:35:06 +00:00
Your NameandClaude Opus 4.5 28da46ad05 fix: Downgrade speech_to_text to 6.6.0 for Gradle compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:33:21 +00:00
Your NameandClaude Opus 4.5 49cdf67c5c fix: Add RECORD_AUDIO permission for voice search
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:28:09 +00:00
Your NameandClaude Opus 4.5 2b6e1d73dc Add quick actions (long-press) for search results
- Add addTrackToQueue method to API and provider
- Add quick action bottom sheet with Play Now, Add to Queue, Play Radio
- Add onLongPress handler to track tiles in search
- Add localization strings for quick actions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:25:13 +00:00
Your NameandClaude Opus 4.5 40debeea06 docs: Update SEARCH-IMPROVEMENTS.md with completed tasks
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:30 +00:00
Your NameandClaude Opus 4.5 60a824d55a fix(search): Fix type errors in relevance scoring
- Use item is Album check before accessing inLibrary
- Compare favorite with == true (nullable bool)
- Use item.album?.name instead of albumName

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:02 +00:00
Your NameandClaude Opus 4.5 2897a4eaa4 feat(search): Add recent searches history
- Add SearchHistory table to database (schema v4)
- Save successful searches to history (deduplicated, max 10)
- Display recent searches as chips in empty state
- Clicking a chip performs that search
- Add localization strings for search history

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:32:06 +00:00
Your NameandClaude Opus 4.5 c36a971125 feat(search): Add smart cross-referencing for artists
When searching for a track or album title along with an artist name
(e.g., "Yesterday Beatles"), the search now includes related artists
from the matched tracks/albums even if they weren't in the direct
artist search results.

- Extract unique artists from matched tracks and albums
- Filter to artists whose name contains query words (min 3 chars)
- Add cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:28:56 +00:00
Your NameandClaude Opus 4.5 f08f737b6f feat(search): Add unified relevance-based sorting for All results
- Implement relevance scoring algorithm for search results
  - Exact match: 100 points
  - Starts with query: 80 points
  - Word boundary match: 60 points
  - Contains query: 40 points
  - Fuzzy match: 20 points
  - Bonus for library items (+10) and favorites (+5)
  - Secondary field matching for albums/tracks (artist name)

- When viewing "All" filter, results are now merged and sorted by
  relevance score instead of grouped by type with headers

- Add type indicators to subtitles in unified view
  - Albums: "Artist • Album"
  - Tracks: "Artist • Track"
  - Playlists: "Owner • Playlist"
  - Audiobooks: "Author • audiobook"

- Add localization strings: albumSingular, trackSingular

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:20:55 +00:00
Your Name 1161dbb129 Fix PlaylistDetailsScreen constructor parameters 2025-12-27 18:53:38 +00:00
Your NameandClaude Opus 4.5 5d4fc5334c Add playlists and audiobooks to search results (#26)
- Parse playlists and audiobooks from MA search API response
- Add playlist and audiobook filter chips to search screen
- Add playlist and audiobook tiles with navigation to detail screens
- Update cache service and provider to include new media types

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:44:04 +00:00
Your Name f93494469d Merge fix/issue-27-external-source: External source detection (#27) 2025-12-27 18:19:26 +00:00
Your NameandClaude Opus 4.5 a94fcff758 Fix DLNA external source display bug (#27)
- Add activeSource and isExternalSource fields to Player model
- Detect external sources (optical, Spotify, AirPlay, etc.) from URI patterns
- Show "External Source" label instead of stale metadata
- Use cyan status indicator dot for external sources
- Hide play/pause button when external source is active (controls don't work)
- Clear cached track data when external source detected
- Add localization strings for "External Source" in EN/DE/ES

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:01:18 +00:00
Your NameandClaude Opus 4.5 1870724875 Fix Add to Queue button and player auto-selection issues
Issue #26.2: Add to Queue on artist pages
- Added null check for API before calling playArtistRadioToQueue()
- Shows "Not connected" error instead of silent failure

Issue #21: Player selection timing and auto-selection
- Added _sortPlayersSync() helper for reusable smart sorting
- Sort cached players immediately on app resume (no 5s delay)
- Added coldStart parameter to apply full priority logic on launch
- Priority: Playing player > Local player > Last selected > First

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 16:30:43 +00:00
Your NameandClaude Opus 4.5 bd3a2f98e8 Bump version to 2.6.1-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:12:39 +00:00
Your NameandClaude Opus 4.5 516de13894 Fix nav bar flash during first-time user onboarding
Add selectedPlayer check to nav bar visibility condition to prevent
it from showing during the ~1.2s gap between connection and player
selection for first-time users.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:35:57 +00:00
Your NameandClaude Opus 4.5 d7188a8784 Fix nav bar flash and backdrop color mismatch
1. Removed Selector for nav bar visibility - Selector only rebuilds when
   isConnected changes, but shouldShowWelcomeBackdrop is local state that
   can change independently. Now using simple if condition.

2. Changed backdrop color from colorScheme.surface to Color(0xFF1a1a1a)
   to match the connection screen exactly, ensuring seamless transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:18:05 +00:00
Your NameandClaude Opus 4.5 c03ea6a14b Hide bottom nav bar during welcome screen
The nav bar was appearing briefly before the welcome backdrop because
it only checked isConnected, not the welcome screen state. Now it also
hides when shouldShowWelcomeBackdrop is true.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:00:13 +00:00
Your NameandClaude Opus 4.5 d418af45f1 Fix LoginScreen to wait for player selection for first-time users
When a user logs in via LoginScreen (fresh install), the navigation to
HomeScreen was happening immediately on connection - before the player
was selected. This caused the home screen to flash before the welcome
overlay could appear.

For first-time users (hasCompletedOnboarding=false), LoginScreen now
waits for provider.selectedPlayer to be set before navigating, matching
the logic in AppStartup.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:44:13 +00:00
Your NameandClaude Opus 4.5 ac05381038 Show welcome backdrop immediately using computed condition
The backdrop was only showing when _isHintModeActive was true, but that
required setState which happens in a post-frame callback - causing a
1-frame gap where the home screen was visible.

Fix: Compute shouldShowWelcomeBackdrop synchronously in build():
- True when first-time user is ready (settings loaded + connected + player)
- True when already in hint mode
- This shows the backdrop IMMEDIATELY in the same frame as HomeScreen
- Welcome content (logo, text) still fades in via animation after

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:26:35 +00:00
Your NameandClaude Opus 4.5 4d29e32651 Fix welcome screen not triggering after connection
The welcome screen check in build() was missing - when the provider state
changes (connection happens), build() runs via context.watch(), but we
weren't checking if conditions were met for the welcome screen.

Added the check in build() with a post-frame callback to trigger the
welcome screen when:
- Settings are loaded
- First-time user (not completed onboarding)
- Connected with player selected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:12:46 +00:00
Your NameandClaude Opus 4.5 91795976ff Fix welcome screen race condition by making AppStartup the gate
The home screen was briefly visible between connection and welcome screens
because AppStartup and GlobalPlayerOverlay were racing - they loaded settings
and detected connection independently with no synchronization.

Solution: Make AppStartup the single source of truth for the transition.
- Load onboarding settings FIRST (before connection check)
- For first-time users: wait for connected + player selected
- For returning users: wait for connected only
- Remove preemptive backdrop from GlobalPlayerOverlay (no longer needed)

This eliminates the race by design - the dark "Connecting..." screen stays
visible until the welcome overlay is ready to appear immediately.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:54:51 +00:00
Your NameandClaude Opus 4.5 484000bc6e Show backdrop during settings load to prevent home screen flash
Add _settingsLoaded flag to track async settings initialization.
Show solid backdrop when connected but settings not yet loaded,
ensuring the grey "Connecting..." background stays visible until
welcome screen is ready.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:23:16 +00:00
Your NameandClaude Opus 4.5 e373053f03 Fix welcome screen flash by using synchronous provider check
Use context.watch() directly in build method instead of nested Selector
to ensure the preemptive backdrop renders in the same frame as home screen.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:05:19 +00:00
Your NameandClaude Opus 4.5 31ef8edaf0 Fix preemptive backdrop not triggering rebuild
The _waitingForConnection flag was set without setState(), so the
preemptive backdrop condition was never re-evaluated. Now it properly
triggers a rebuild so the backdrop shows immediately when connected.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:43:47 +00:00
Your NameandClaude Opus 4.5 ff568ebf56 Add preemptive backdrop for seamless welcome transition
When connection is established, a solid backdrop now appears instantly
in the same render frame, covering the home screen before the animated
welcome screen starts. This prevents any flash of the home screen.

Flow: login → solid backdrop (instant) → blur + welcome content (animated)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:18:53 +00:00
Your NameandClaude Opus 4.5 ee6f00d56b Fix welcome screen to only show after successful login
The welcome screen was incorrectly starting immediately on first use,
even before the user had logged in. Now it correctly:

1. Shows login screen first (with hidden nav bar)
2. Waits for successful connection
3. Then shows welcome screen (covering home screen)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:04:05 +00:00
Your NameandClaude Opus 4.5 fff5c91b5c Improve welcome screen transition and hide nav bar on login
1. Hide bottom nav bar when not connected (fixes login screen issue)
2. Start welcome screen immediately on first use (blur + logo)
3. Show mini player hints only after connection is established

This ensures a smooth transition from login to home - the welcome
blur covers the home screen immediately, preventing any flash.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:47:35 +00:00
Your NameandClaude Opus 4.5 cd27aee7b8 Fix welcome screen showing before server connection
On fresh install, welcome screen was starting immediately even when
user hadn't configured server address/login yet. This meant the hints
were pointing to a mini player that wasn't visible.

Now the welcome screen waits until the user is connected with a
selected player before showing the onboarding experience.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:29:14 +00:00
Your NameandClaude Opus 4.5 8773ffa8c4 Welcome screen first-use only + bump version to 2.6.0-beta
- Welcome screen now only shows on first app launch
- Added hasCompletedOnboarding setting (separate from showHints)
- Onboarding marked complete when user skips or swipes to reveal
- Version bumped to 2.6.0-beta+28

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 07:18:48 +00:00
Your NameandClaude Opus 4.5 973652d970 Remove old dismiss hint and fix choppy player selector animation
- Remove "Swipe down, tap outside..." dismissal hint
- Separate backdrop into two widgets:
  - Reveal mode: static backdrop (no TweenAnimationBuilder overhead)
  - Hint mode: animated fade backdrop
- Fixes choppy expand/collapse by avoiding key changes and rebuilds

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:55:10 +00:00
Your NameandClaude Opus 4.5 054ad6ab0f Use theme surface color for welcome backdrop instead of black
Matches the dark grey background used elsewhere in the app

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:34:50 +00:00
Your NameandClaude Opus 4.5 e9cc8ce014 Update first-time player hint text
"Choose a player, or dismiss by swiping down"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:26:42 +00:00
Your NameandClaude Opus 4.5 0b8984ef20 Fix welcome screen skip and improve player selector hints
- Welcome screen: only skip button dismisses (not tapping anywhere)
- Player selector onboarding: show "Tap a player to start listening"
- Player selector regular use: show "Tap and hold a player to sync"
- Different icons for each context (touch_app vs lightbulb)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:23:38 +00:00
Your NameandClaude Opus 4.5 2d3c40aa67 Show artist on mini player when device selector is open
- Mini player secondary line now always shows artist/author
- Removed "Now Playing" text when device reveal is visible
- Consistent with showing actual track info

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:34:46 +00:00
Your NameandClaude Opus 4.5 d9946ae7da Hold black for 2 seconds, then fade over 1 second
- Welcome backdrop holds at 100% black for 2 seconds
- Then fades to 50% opacity over 1 second
- Uses Interval curve (0.67-1.0) for the delayed fade effect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:32:45 +00:00
Your NameandClaude Opus 4.5 ab394c41a2 Start welcome screen fully black, fade to blur over 2 seconds
- Backdrop starts at 100% black opacity on first launch
- Fades to 50% opacity over 2 seconds (reveals blurred home screen)
- Hides any loading/rendering happening behind the scenes
- Reveal mode (device selector) uses instant 0.5 opacity (no fade)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:18:47 +00:00
Your NameandClaude Opus 4.5 229e47c31c Welcome screen opens immediately with smooth fade-in
- Start welcome overlay immediately on first launch (no delay)
- Add 800ms fade-in animation for welcome content
- Blur backdrop visible from start, hiding home screen load
- Hint text reduced to 16px (was 18px)
- Bounce animation starts after fade-in completes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:05:11 +00:00
Your NameandClaude Opus 4.5 e2218e8036 Fix welcome hint screen layout and typography
- Split welcome screen into two positioned sections:
  - Top: Logo and "Welcome to Ensemble" (anchored from top)
  - Bottom: Hint text and Skip button (anchored from bottom)
- Fix hint text using theme.textTheme.bodyLarge instead of raw TextStyle
- Create big visual gap between welcome title and hint text
- Equal 32px spacing between hint text <-> skip <-> mini player

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:45:09 +00:00
Your NameandClaude Opus 4.5 43ec560983 Adjust welcome screen: larger hint text and move logo up
- Hint text font size: 18px (was 14px)
- Move content up by 224px (1.5 album covers height)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:11:25 +00:00
Your NameandClaude Opus 4.5 ca1a7804a1 Update welcome screen layout with logo and unified backdrop
- Logo first, then "Welcome to Ensemble", then hint text
- Uses same logo as settings screen (50% width)
- Nicely spaced: 24px after logo, 16px after title, 32px before skip

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:44:17 +00:00
Your NameandClaude Opus 4.5 1f6e5128d0 Unify backdrop for seamless hint to player select transition
- GlobalPlayerOverlay now uses consistent 0.5 opacity for both states
- Removed PlayerRevealOverlay's own backdrop (was causing layered darkness)
- Transition from welcome screen to player select is now seamless

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:40:38 +00:00
Your NameandClaude Opus 4.5 cb8de0e253 Add two-stage onboarding with welcome screen and player hints
Stage 1 - Welcome screen:
- Darker blur backdrop (0.4 opacity) for better focus
- Welcome message above mini player explaining phone as default player
- Skip button to bypass onboarding
- Bouncing mini player continues until user acts

Stage 2 - Player selector hints:
- Shows dismissal hint when coming from onboarding
- "Swipe down, tap outside, or press back to return"

All new text strings are localized.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:19:51 +00:00
Your NameandClaude Opus 4.5 b3ab6db623 Fix hint not triggering when player has no track
The hint now triggers when a player is available, regardless of whether
there's a track playing. The 1500ms delay still gives time for any track
to load for a stable state.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:06:03 +00:00
Your NameandClaude Opus 4.5 8f2d947f4f Add favorite tracks cache and interactive hint mode
Favorite tracks:
- Cache favorite tracks to database for instant display on startup
- Load cached favorites when _tracks list is empty

Interactive hint mode:
- Blur/darken backdrop when hint triggers (like player selector)
- Mini player bounces every 2 seconds until user acts
- User can swipe down (reveals player selector) or tap/back to dismiss
- Single bounce animation (600ms) with text overlay

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:46:16 +00:00
Your NameandClaude Opus 4.5 f760d1ff29 Add instant home row loading from database cache
- Pre-load library from SyncService for instant favorites display
  - Favorites now filter from cached albums/artists immediately
  - No need to wait for server connection

- Add HomeRowCache table for discover rows persistence
  - Schema version 3 with migration
  - Stores recent albums, discover artists, discover albums

- Update CacheService to persist and restore home rows
  - loadHomeRowsFromDatabase() loads cached data on startup
  - setCached* methods now persist to database in background

- Update provider initialization order:
  1. Load players from database
  2. Load library from cache (for favorites)
  3. Load home rows from database (for discover)
  4. Then connect to server

This ensures all home screen rows load instantly on app launch,
including favorites and discover rows, without waiting for server.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:11:18 +00:00
Your NameandClaude Opus 4.5 440a369a67 Persist playback state for instant track display on app launch
- Fix hint timing to only fire when track is loaded (stable state)
  - Previously hint could fire before track loaded, getting lost in the UI
  - Now waits for currentTrack to be available before showing hint
  - Reduced delay from 3500ms to 500ms since we're waiting for track

- Wire up PlaybackState persistence for currentTrack
  - Load saved track from database on startup (_loadPlaybackStateFromDatabase)
  - Track displays immediately without waiting for server response
  - Persist track changes to database for next app launch
  - Clear persisted state when playback stops

This ensures the mini player shows the correct track info instantly when
the app opens, instead of going through multiple state transitions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:49:41 +00:00
Your NameandClaude Opus 4.5 aa9fd3628a Center hint text and make both bounces 20px
- Both bounces now 20px (was 20px + 12px)
- Hint text vertically centered in mini player
- Separate Positioned widget for hint vs track title

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:19:32 +00:00
Your NameandClaude Opus 4.5 a812d5c3bf Increase hint double bounce amplitude for visibility
- First bounce: 10px -> 20px
- Second bounce: 6px -> 12px
- Duration: 900ms -> 1000ms
- Single bounce for device selector unchanged at 10px

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:41:00 +00:00
Your NameandClaude Opus 4.5 0af5b3d981 Replace toast with inline hint on mini player
- Add isHintVisible prop to ExpandablePlayer
- When hint visible: show lightbulb icon + "Pull to select players" as primary text
- When hint visible: hide secondary text line (blank)
- Remove separate toast pill widget
- Hint text now appears directly in mini player, bouncing with it

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:37:44 +00:00
Your NameandClaude Opus 4.5 8a5186ac9d Fix hint system: restore bounces, fix toast styling
- Single bounce on device selector expand (when pulling down)
- Single bounce on device selector collapse (cards landing)
- Double bounce on mini player when hint appears
- Toast pill: no bounce, positioned over nav bar, proper font
- Added decoration: TextDecoration.none to prevent underlines

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:14:56 +00:00
Your NameandClaude Opus 4.5 0fbbf498e6 Fix: use onInverseSurface instead of inverseOnSurface
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:55:25 +00:00
Your NameandClaude Opus 4.5 9963f2f376 Refactor hint system: toast pill, hint-only bounce, fix hint text
- Remove double bounce from device selector expand/collapse
- Create separate hint-only bounce animation (doesn't affect player)
- Change pull-to-select hint to toast pill box at bottom of screen
- Update "hold to sync" text to "Tap and hold a player to sync"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:46:50 +00:00
Your NameandClaude Opus 4.5 1448da03b3 Adjust pull hint: move up 5px, delay 2s longer
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:18 +00:00
Your NameandClaude Opus 4.5 f2ee2c2c5c Fix pull-to-select hint position to half-overlap mini player
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:41:21 +00:00
Your NameandClaude Opus 4.5 1648910153 Add lightbulb icon to swipe-to-change-player hint
- Add isHint parameter to MiniPlayerContent
- Show lightbulb icon when secondary text is a hint
- Apply to both main and peek player content in DeviceSelectorBar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:17:28 +00:00
Your NameandClaude Opus 4.5 c1fe9e2148 Add lightbulb icon to hold-to-sync hint
Consistent with pull-to-select hint styling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:16:05 +00:00
Your NameandClaude Opus 4.5 d40941bf13 Improve pull-to-select hint with double bounce and lightbulb icon
- Reposition hint to half-overlap mini player top edge
- Add double bounce animation (first 10px, second 6px)
- Add lightbulb icon before hint text
- Extend animation duration to 900ms for better visibility

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:12:59 +00:00
Your NameandClaude Opus 4.5 01349f8336 Fix hint text styling and adjust timing
- Wrap hint text in Material widget to fix typewriter font/yellow underline
- Increase delay before showing hint: 500ms → 1500ms
- Increase linger time: 800ms → 2500ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:07:26 +00:00
Your NameandClaude Opus 4.5 439889fc3e Improve hint text styling and simplify swipe text
- Match hold-to-sync hint font size (16) with player card primary text
- Change text to "Hold a player to sync"
- Remove pill container from pull-to-select hint
- Make pull hint bounce with the mini player
- Simplify swipe text to just "Swipe to change player"
- Update German and Spanish translations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:52:53 +00:00
Your NameandClaude Opus 4.5 5f2ef72910 Power on Cast/Sendspin players before syncing
When long-pressing a powered-off Cast player that has a Sendspin
counterpart, automatically power it on first and wait for it to
become available before syncing. This provides a seamless experience
for grouping Cast devices that are currently off.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:42:19 +00:00
Your NameandClaude Opus 4.5 0f83bb842b Persist Cast-to-Sendspin ID mapping for offline grouping
Don't clear the _castToSendspinIdMap on each player refresh. This allows
syncing to work with powered-off Cast devices by remembering their
Sendspin IDs from when they were previously online.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:35:48 +00:00
Your NameandClaude Opus 4.5 5fc8623bcb Improve hint system and allow syncing powered-off players
- Fix hint text decoration to prevent underlines
- Make hold-to-sync hint slide with player cards instead of fading
- Show pull-down hint on every app launch (not just first time)
- Replace arrow symbols (← ↓ →) with text (left, down, or right)
- Allow long-press to sync players even when powered off
- Update translations for German and Spanish

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:28:30 +00:00
Your NameandClaude Opus 4.5 e3403c5daf Add hint system with settings toggle
- Add show hints toggle in new Hints & Tips settings section
- Add pull-to-select-players hint with bounce animation on first connection
- Add hold-to-sync hint in player reveal overlay
- Track when user has discovered player reveal feature
- Update swipe hint to show directional arrows: ← ↓ →
- Support English, German, and Spanish translations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:00:10 +00:00
Your Name 65e8e87e01 Bump version to 2.5.0-beta+27 2025-12-26 09:11:22 +00:00
Your NameandClaude Opus 4.5 2658be1a3f Add nowPlaying localization for device selector UI
Adds translations:
- English: "Now playing"
- German: "Wird gespielt"
- Spanish: "Reproduciendo ahora"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:57 +00:00
Your NameandClaude Opus 4.5 2a9a4b2494 Show player name in mini player when device selector is open
When the device reveal overlay is visible:
- Mini player shows player name instead of track name
- Secondary line shows "Now Playing" instead of artist
- Adds blur backdrop behind device selector for focus

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:15:47 +00:00
Your NameandClaude Opus 4.5 6ff1b8b95f Fix grouping: translate Cast player ID to Sendspin ID when syncing
When showing the regular Cast player (ungrouped state), sync operations
need to use the corresponding Sendspin player ID since only Sendspin
players can group with other Sendspin players.

Added _castToSendspinIdMap to track the mapping between regular Cast
and Sendspin player IDs, populated during player filtering.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:51:12 +00:00
Your NameandClaude Opus 4.5 06700f54c8 Smart Sendspin/Cast player switching based on group status
- When grouped: show Sendspin version (for sync), hide original Cast
- When ungrouped: show original Cast (for power control + queue)

This preserves power control and proper queue behavior when not syncing,
while still providing seamless Sendspin grouping when needed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:02:27 +00:00
Your NameandClaude Opus 4.5 489e2c3f3a Prefer Sendspin version of Cast players in player list
When a Cast device has Sendspin mode enabled, both the regular Cast
player and the "(Sendspin)" version appear in Music Assistant. This
change:
- Automatically prefers the Sendspin version when both exist
- Removes the "(Sendspin)" suffix for a cleaner display
- Provides seamless transitions when Sendspin becomes available

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:06:54 +00:00
Your NameandClaude Opus 4.5 60fb374d0f Fix Sendspin: always report synchronized state, rename timestamp field
- Change playerState from conditional 'synchronized'/'idle' to always 'synchronized'
  (MA only accepts 'synchronized', 'error', 'external_source')
- Rename 'timestamp' to 'client_transmitted' in time messages per protocol spec

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:40:58 +00:00
Your NameandClaude Opus 4.5 8946373a28 Reduce grouped border to 1.5px everywhere
Thinner border for a more subtle grouped indicator on:
- Mini player
- Player cards in reveal overlay
- Player selector sheet

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 10:00:55 +00:00
Your NameandClaude Opus 4.5 00560ed1ec Show leader's queue and track for grouped child players
When a player is synced/grouped as a child:
- getQueue() now returns the leader's queue
- getCachedTrackForPlayer() returns the leader's track
- selectPlayer() gets the leader's track from cache

This ensures grouped players show the same queue and track info
as their leader, reflecting the actual audio being played.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:40:56 +00:00
Your NameandClaude Opus 4.5 6b13e6e8f7 Add yellow border to mini player when grouped
Shows the same pastel yellow border (#FFF59D) on the mini player
that is shown on grouped player cards in the reveal overlay.
Only visible when collapsed (t < 0.5) to indicate group membership.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:38:28 +00:00
Your NameandClaude Opus 4.5 957cc170a8 Fix isGrouped to include child players with syncedTo
Child players (like Dining Room) have empty group_members but have
syncedTo set to the leader. Updated isGrouped to check for both:
- Leader: group_members.length > 1
- Child: syncedTo != null

Both should show the yellow border now.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:10:50 +00:00
Your NameandClaude Opus 4.5 91a00c62b0 Add long-press sync to PlayerCard in reveal overlay
The device list uses PlayerCard widget (not _PlayerSelectorSheet).
Added:
- onLongPress callback to PlayerCard
- isGrouped property for pastel yellow border
- Long-press handler in player_reveal_overlay.dart
- SnackBar feedback when long-press detected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:41:39 +00:00
Your NameandClaude Opus 4.5 cb1122277a Add snackbar feedback for long-press sync detection
- Show visual snackbar "Syncing {player}..." when long-press detected
- Remove !isSelected condition to allow sync on any available player
- Helps debug if long-press gesture is being recognized

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:39:46 +00:00
Your NameandClaude Opus 4.5 190fa4f61c Add detailed logging for player sync debugging
Logs added at:
- UI: Long-press trigger with player details
- Provider: togglePlayerSync, syncPlayerToSelected
- API: syncPlayerToLeader with request/response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:29:35 +00:00
Your NameandClaude Opus 4.5 2c02473f32 Fix sync API command: use players/cmd/group
The correct MA API command is:
- players/cmd/group (not group_many)
- player_id: the player joining the group
- target_player: the leader player to sync to

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:05:52 +00:00
Your NameandClaude Opus 4.5 79962517ae Add temporary player grouping via long-press
Feature: Long-press on a player in the device list to sync it with the
currently selected/playing player, creating a temporary group.

Changes:
- API: Add syncPlayers() and unsyncPlayer() methods using MA commands
  players/cmd/group_many and players/cmd/ungroup
- Model: Add groupMembers and syncedTo fields to Player, with derived
  properties isGrouped, isGroupLeader, isGroupChild
- Provider: Add syncPlayerToSelected(), unsyncPlayer(), togglePlayerSync()
- UI: Long-press gesture on player cards (with haptic feedback)
- UI: Pastel yellow border (#FFF59D) around grouped players
- UI: "Synced" badge with link icon for grouped players

UX Flow:
1. Play music on a speaker (e.g., Kitchen HiFi)
2. Open device selector
3. Long-press another speaker (e.g., Bedroom)
4. Bedroom syncs to Kitchen and both show yellow border
5. Long-press again to unsync

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:25:39 +00:00
Your NameandClaude Opus 4.5 8641c89b9b Fix badge count and auto-selection priority logic
- Badge now excludes selected player from count (shows "other" activity)
- Auto-selection now switches to playing player when preferLocalPlayer is OFF
  and current selection is not playing

Addresses feedback from issue #21

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:21:27 +00:00
Your Name 8f1a1cd3bd Bump version to 2.4.1-beta+26 2025-12-24 21:50:59 +00:00
Your NameandClaude Opus 4.5 c841898d8c Add image pre-caching to album and artist rows
The root cause: second tap is smooth because image is cached,
but first tap shows grey flash because image isn't cached yet.

Recent Albums works perfectly because those images are already
cached from when the user played those albums.

Fix: Pre-cache the first 10 album/artist images when the row
loads, so they're ready in cache before the user taps.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:33:47 +00:00
Your NameandClaude Opus 4.5 bd2c2be54e Fix hero animation grey icon flash - root cause fixes
Artist card layout:
- Replace Expanded > Center > LayoutBuilder > Hero with AspectRatio > Hero
- LayoutBuilder inside Hero caused dynamic sizing during animation
- AspectRatio provides fixed geometry BEFORE hero animation starts

Album details screen:
- Defer _loadFreshAlbumData until 350ms after transition (same as colors)
- Previously called immediately in initState, causing setState during animation
- If fresh album had different image URL, caused grey icon flash

These are the actual root causes identified by comparing "Recent Albums"
(perfect animation) with other album/artist taps (janky animation).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:19:35 +00:00
Your NameandClaude Opus 4.5 1225255558 Comprehensive animation performance optimizations
Critical fixes:
- Replace Opacity widgets with color alpha to avoid saveLayer in player_controls
- Fix AnimatedBuilder not using child parameter in global_player_overlay
- Replace setState with ValueNotifier for bounce animation to prevent rebuilds
- Add flightShuttleBuilder to series Hero to simplify animation flight
- Remove Opacity+ClipRRect combo in expandable_player progress bar

Image loading optimizations:
- Add fadeInDuration: Duration.zero to audiobook_row, track_row, queue_panel
- Add memCacheWidth/Height to queue_panel (176x176 for 44x44 display)
- Move _loadQueue() from AnimationStatus.forward to .completed

Animation polish:
- Add curve: Curves.easeInOut to AnimatedContainer in settings_screen
- Use FadeSlidePageRoute for series navigation for consistent hero timing
- Add clipBehavior: Clip.hardEdge to progress bar ClipRRect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 19:12:04 +00:00
Your NameandClaude Opus 4.5 4589d26039 Fix hero animation flicker in card widgets
Set fadeInDuration: Duration.zero on source card widgets to prevent
end-of-animation flicker:
- album_card.dart: was 150ms, now zero
- artist_card.dart: was 150ms, now zero
- artist_avatar.dart: added zero fade
- series_row.dart: added zero fade

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:44:43 +00:00
Your NameandClaude Opus 4.5 aa8a3413f2 Complete hero animation audit: add fadeInDuration and initialImageUrl
- audiobook_detail_screen: Add fadeInDuration: Duration.zero to hero image
- audiobook_series_screen: Add fadeInDuration to all 3 CachedNetworkImage usages
- audiobook_author_screen: Add initialAuthorImageUrl parameter for smooth hero,
  add fadeInDuration to all 3 CachedNetworkImage usages
- new_library_screen: Pass initialAuthorImageUrl to author screen navigation,
  pass initialImageUrl to audiobook grid cards, add fadeInDuration to author images

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:18:30 +00:00
Your NameandClaude Opus 4.5 f0c26cd96d Fix artist hero animation jank by using CachedNetworkImage
ArtistDetailsScreen was using Image.network which re-fetches images
from the network, ignoring the cache. This caused jank during hero
animations because the image wasn't ready instantly.

Changed to CachedNetworkImage with zero fade duration to match
AlbumDetailsScreen behavior - image loads instantly from cache
for smooth hero transitions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:48:52 +00:00
Your NameandClaude Opus 4.5 591923b43b Pass initialImageUrl to all detail screens for smooth hero animations
Apply the same pattern that fixed album card hero animations to all
navigation paths:
- AudiobookDetailScreen now accepts initialImageUrl parameter
- Library screen passes imageUrl when navigating to audiobooks/albums
- Audiobook series and author screens pass imageUrl
- Artist details screen passes imageUrl to album navigations
- Search screen passes imageUrl to album details

This ensures hero animations show the correct image throughout the
transition instead of showing a placeholder icon.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:52:41 +00:00
Your NameandClaude Opus 4.5 83b46ef916 Fix queue restore: properly serialize providerMappings and check for them
Two issues prevented queue restoration from working:

1. MediaItem.toJson() was passing providerMappings directly instead of
   converting each ProviderMapping to JSON. This meant jsonEncode would
   fail to serialize them, losing the provider mapping data.

2. _restoreQueueFromCache was filtering for track.uri, but queue items
   from MA API don't include URIs. playTracks uses providerMappings to
   build URIs via _buildTrackUri(), so we should check for those instead.

Now providerMappings are properly serialized/deserialized, and queue
restore checks for available providerMappings (or provider+itemId as
fallback) to match what playTracks expects.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:18:08 +00:00
Your NameandClaude Opus 4.5 c9300ec4d4 Pass imageUrl to AlbumDetailsScreen for seamless hero animation
When navigating from AlbumCard to AlbumDetailsScreen, the detail screen's
first build may not have access to image metadata immediately. This caused
the hero animation to flash to a record icon even though both screens
eventually show the artwork.

Fix: AlbumCard now passes its imageUrl to AlbumDetailsScreen via a new
initialImageUrl parameter. The detail screen uses this as a fallback,
ensuring the hero animation has the same image on both ends.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:51:15 +00:00
Your NameandClaude Opus 4.5 cd7012258f Always fetch fresh recent albums from API for complete image data
getRecentAlbumsWithCache was short-circuiting to local database data
which has OLD entries without image metadata. This prevented fresh
albums with images from ever being fetched.

Now the function always fetches from API when connected. Local/cached
data is only used:
- By getCachedRecentAlbums() for instant display in stale-while-revalidate
- As fallback when API is unavailable or errors

This ensures albums passed to detail screen have complete metadata
including images for proper hero animation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:47:34 +00:00
Your NameandClaude Opus 4.5 041258cfd9 Always update rows with fresh data to ensure images are available
The stale-while-revalidate pattern had a flawed hasChanged check that
only compared item IDs and counts. If cached albums had the same items
but without image metadata, fresh albums (with images) would never
replace them.

This caused the hero animation to show a record icon because the user
would tap on a cached album without images before fresh data loaded.

Now AlbumRow and ArtistRow always update with fresh data when it arrives,
ensuring albums have complete metadata including images for proper
artwork display.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:26:58 +00:00
Your NameandClaude Opus 4.5 7783899e49 Fetch full album details in getRecentAlbums for artwork
The getRecentAlbums() was extracting album data from track responses,
which only contains simplified/nested album objects without the full
metadata (including images array). This caused missing artwork in the
recent albums row and album details screen.

Now the function:
1. First fetches tracks to collect unique album URIs
2. Then fetches full album details for each URI via music/item_by_uri

This ensures albums have complete metadata with images for proper
artwork display and hero animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:09:23 +00:00
Your NameandClaude Opus 4.5 6724b1814e Store album images metadata in recently played for instant artwork
When recording album plays, now stores the images array from album
metadata. When reconstructing albums from recently played data, this
image metadata is included, enabling getImageUrl() to work without
needing to fetch fresh album data from the API.

This fixes the artwork "pop-in" issue where album art would flash into
place at the end of the hero animation when navigating from the recent
albums row to album details screen.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 12:53:25 +00:00
Your NameandClaude Opus 4.5 228b281fc8 Fix album artwork in detail screen for recently played albums
When tapping an album from recently played, the detail screen now
fetches fresh album data from the API to get full metadata including
the images array needed for artwork display.

- Added _freshAlbum state variable to store full album data
- Added _displayAlbum getter that returns fresh data if available
- _loadFreshAlbumData() fetches album by URI and triggers color extraction
- Build method now uses _displayAlbum for image URL

This fixes the placeholder icon showing in album details when navigating
from the recently played row.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:51:09 +00:00
Your Name a59ca9ecd9 Revert "Fix album artwork missing for recently played albums"
This reverts commit 85e1f6142f.
2025-12-24 11:49:38 +00:00
Your NameandClaude Opus 4.5 85e1f6142f Fix album artwork missing for recently played albums
The recently played albums were being stored with minimal metadata,
missing the 'images' array needed for getImageUrl().

Now recordAlbumPlayed stores the full album JSON including metadata,
and getRecentAlbums reconstructs full Album objects from that JSON.

Note: This only fixes newly played albums. Existing entries in the
database will still show placeholder icons until played again.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:33:28 +00:00
Your NameandClaude Opus 4.5 04b8f4f505 Fix resume playback when server queue is empty
When the app returns after a while, Music Assistant server may have
cleared its queue. The app would fail with "No playable item found".

Now when this error occurs, the app automatically:
1. Detects the "No playable item" error
2. Loads the cached queue from the database
3. Extracts tracks and re-queues them on the server
4. Starts playback from the beginning

This fixes the issue where Chris' phone couldn't play after
returning to the app - the 35 cached tracks are now restored.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:01:54 +00:00
Your NameandClaude Opus 4.5 f2754b6f2a Add stale-while-revalidate pattern to home screen rows
- AlbumRow and ArtistRow now show cached data instantly (no loading spinner)
- Fresh data is fetched in background and UI updates silently if changed
- Added getCachedAlbums/getCachedArtists optional params for sync cache access
- Added cache getter methods to MusicAssistantProvider

This fixes the issue where home screen rows showed loading spinners
when returning to the app after the 10-minute cache expired.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:49:47 +00:00
Your NameandClaude Opus 4.5 bdfce8c8cb Add offline action queuing for favorites
- Created OfflineActionQueue service to persist actions when offline
- Queue is stored in SharedPreferences and processed on reconnect
- Updated all favorite toggle methods in screens to use new provider methods
- Provider methods check connection state and queue if offline
- Optimistic UI updates with "Will sync when online" feedback
- Added localization strings for offline sync messages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:45:39 +00:00
Your NameandClaude Opus 4.5 431b498156 Add queue persistence to database
- Queue is now persisted to database when fetched from API
- Expandable player shows cached queue immediately on load
- Added getCachedQueue() method to MusicAssistantProvider
- Background refresh updates queue silently

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:31:02 +00:00
Your NameandClaude Opus 4.5 a9570c6106 Add silent background refresh pattern to detail screens
Album, playlist, and artist detail screens now:
- Show cached data immediately (if available)
- Fetch fresh data in background (silent refresh)
- Update UI only if data actually changed

Added cache accessor methods to MusicAssistantProvider:
- getCachedAlbumTracks()
- getCachedPlaylistTracks()
- getCachedArtistAlbums()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:29:42 +00:00
Your NameandClaude Opus 4.5 337ca775c8 Fix variable name conflict in search_screen.dart
Renamed hasResults to hasCachedResults to avoid conflict with
existing hasResults variable later in the same function.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:10:41 +00:00
Your NameandClaude Opus 4.5 183ec8024c Fix generated type names and eliminate loading spinners
Type name fixes:
- CachedPlayers table generates CachedPlayer data class (singular)
- Fixed type references in database.dart and database_service.dart

Loading spinner elimination:
- Show cached data immediately if available, even while loading
- Only show spinner if no cached data AND loading
- Applied to library_albums, library_artists, library_tracks, library_playlists
- Updated search_screen to show cached results while fetching

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:01:49 +00:00
Your NameandClaude Opus 4.5 e32f4fa591 Stop aggressive cache clearing and persist players to database
Cache clearing changes:
- Remove cache clearing on disconnect events in both providers
- Keep cached players and data for instant UI display on reconnect
- Add clearAllOnLogout() method for explicit logout clearing
- Add clearCachesOnLogout() to ConnectionProvider

Player persistence:
- Add player caching convenience methods to DatabaseService
- Load cached players from database on app startup (instant display)
- Persist players to database when loaded from API
- Restore selected player from database on startup

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:56:40 +00:00
Your NameandClaude Opus 4.5 e175c14f69 Add persistent caching tables for playback state, players, and queue
- Add PlaybackState table: persists current track, position, playing state
- Add CachedPlayers table: stores player list with current track info
- Add CachedQueue table: persists queue items per player
- Increment schema version to 2 with migration logic
- Add CRUD operations for all new tables

Part of aggressive caching overhaul for instant app resume.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:51:07 +00:00
Your NameandClaude Opus 4.5 88a4cf333b refactor: Extract shared MiniPlayerContent widget for uniformity
Create a unified MiniPlayerContent widget used by all mini player
components to ensure pixel-perfect consistency:

- New MiniPlayerLayout class with shared constants:
  - height: 64px, artSize: 64px
  - textLeft: 74px, primaryTop: 13px, secondaryTop: 33px
  - iconSize: 28px, iconOpacity: 0.4
  - primaryFontSize: 16px, secondaryFontSize: 14px

- DeviceSelectorBar now uses MiniPlayerContent
- ExpandablePlayer uses MiniPlayerLayout constants for collapsed state
- Peek content uses MiniPlayerContent via Transform.translate

This eliminates the drift between components that was causing visual
jumps when swiping between playing and non-playing players.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 07:02:27 +00:00
Your NameandClaude Opus 4.5 878c473096 fix: Badge text styling and peek icon opacity
- Add explicit fontSize (11) and color to badge text for visibility
- Fix peek icon color: use textColor.withOpacity(0.4) to match
  DeviceSelectorBar (was using full opacity onSurfaceVariant)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:53:24 +00:00
Your NameandClaude Opus 4.5 80ac93c867 fix: Match peek content to DeviceSelectorBar exactly
- Remove height: 1.2 from peek content text (DeviceSelectorBar doesn't have it)
- Make height: 1.2 conditional on expanded state for main title
- Fix icon size in peek content: 28px to match DeviceSelectorBar

These mismatches caused visible text movement and icon size
changes when swiping from playing to non-playing player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:36:46 +00:00
Your NameandClaude Opus 4.5 23567c6c7a fix: Use Badge widget and match mini player layouts
- Replace manual badge implementation with Flutter's Badge widget
  for proper centering and styling
- Standardize mini player text positioning between playing and
  non-playing states:
  - Title: left=74px (art+10), top=13px
  - Artist: left=74px, top=33px (gap of 20)
- Convert DeviceSelectorBar to absolute positioning to match
  expandable player structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:19:59 +00:00
Your Name bccf069379 Revert "fix: Match playing mini player layout to non-playing"
This reverts commit 62c300090d.
2025-12-23 22:16:48 +00:00
Your NameandClaude Opus 4.5 62c300090d fix: Match playing mini player layout to non-playing
- Left padding: 8px → 12px
- Title-artist gap: 22px → 18px
- Vertical centering adjusted
- Remove height: 1.2 from collapsed title (was causing offset)
- Badge: larger (22px), bolder (w900), better centered

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:56:32 +00:00
Your NameandClaude Opus 4.5 6b3e2e8cb6 fix: Improve badge vertical centering and make bolder
- Adjust padding to shift number up (less top padding)
- Increase font weight to w800 (extra bold)
- Set line height to 1.0 for tighter baseline

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:20:23 +00:00
Your NameandClaude Opus 4.5 daae42fbdc fix: Prefer Local Player now takes priority over current selection
When "Prefer Local Player" is enabled, the local player is now selected
even if another player was already selected. This ensures that when the
local player registers (via Sendspin), it becomes selected automatically.

Previously, the "keep current selection" logic ran before checking the
prefer local setting, which meant the local player was never selected
when it registered after initial connection.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:04:15 +00:00
Your NameandClaude Opus 4.5 23cd2df5ca feat: Add player UX improvements (Issue #21)
1. Smart Player Auto-Selection:
   - Priority 1: Single playing player (skip if multiple playing)
   - Priority 2: Local player (this device)
   - Priority 3: Last manually selected player
   - Priority 4: First available player
   - New "Prefer Local Player" toggle skips to local player first

2. Active Player Count Badge:
   - Shows number of currently playing devices
   - Round badge with tertiary color in top-right of device selector

3. Dynamic Player List Sorting:
   - New "Smart Sort" toggle in settings
   - When enabled: Local player → Playing → On → Off
   - Applies to device list and mini player swipe
   - Default: alphabetical (unchanged)

4. Settings reorganization:
   - Moved Language selector to its own section
   - Added new Players section with toggles

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:48:06 +00:00
Your NameandClaude Opus 4.5 2c7f578fd1 feat: Add accent color selection in settings
Add 6 predefined accent colors (purple, blue, teal, deep orange, pink,
green) that users can choose when Material You is disabled. The colors
are displayed as circular swatches above the Material You toggle and
are greyed out when Material You is enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:15:35 +00:00
Your NameandClaude Opus 4.5 d7bfb66fab fix: Reduce play button nudge to add space from skip
Play: +3px, Skip: +6px (more space between play and skip)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:56:19 +00:00
Your NameandClaude Opus 4.5 222a66db61 fix: Nudge play and skip buttons toward power
Move play and skip 6px right instead of moving power left

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:38:55 +00:00
Your NameandClaude Opus 4.5 8eec9599f3 fix: Revert to Material Icons, use Transform to close gap
- Reverted MDI icons back to Material Icons
- Used Transform.translate(-6, 0) to nudge power button left
- This closes the gap between skip and power without changing icon pack

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:37:41 +00:00
Your NameandClaude Opus 4.5 fe45a34c35 feat: Switch to MDI icons for tighter spacing
Replace Material Icons with Material Design Icons (MDI) in:
- Device list: play/pause, skip next, power
- Mini player: skip previous, play/pause, skip next
- Expanded player: shuffle, repeat, skip controls, audiobook controls

MDI icons have less built-in whitespace for more compact layouts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:21:54 +00:00
Your NameandClaude Opus 4.5 232862b164 fix: Remove gap between skip and power buttons
Set padding to zero on both buttons for minimal spacing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:07:37 +00:00
Your NameandClaude Opus 4.5 dc6004ac00 feat: Asymmetric device list animation timing
- Expand: 200ms (slower, more deliberate reveal)
- Collapse: 150ms (snappier dismiss)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:00:34 +00:00
Your NameandClaude Opus 4.5 62fc5ab2f0 fix: Reduce gap between skip and power buttons
Reduced power button horizontal padding from 4 to 2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:51:39 +00:00
Your NameandClaude Opus 4.5 807bebbab9 fix: Restore skip button to original size
Keep skip next at 28, only reduce play (28) and power (20)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:05:29 +00:00
Your NameandClaude Opus 4.5 7edf7bbfc8 fix: Reduce device selector button sizes for better alignment
- Play/Pause: 34→28 icon size, smaller container
- Skip next: 28→24 icon, reduced horizontal padding
- Power button: 28→20 (smallest of the three)
- Reduced gaps between buttons for more compact layout

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:03:26 +00:00
Your NameandClaude Opus 4.5 c4ad0a6279 fix: Match play button sizing exactly to mini player
Use SizedBox wrapper with zero padding for play button, matching
the mini player's Container approach. Also restore right margin to 4px.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:39:12 +00:00
Your NameandClaude Opus 4.5 6ebf2ed15b feat: Update device list buttons to play/pause, next, power
- Change button order from prev/play/next to play/pause/next/power
- Match mini player button sizes (play=34, skip/power=28)
- Power button always visible when player available (dimmed when off)
- Play/pause and next only shown when powered with content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:23:16 +00:00
Your NameandClaude Opus 4.5 91b713aab8 fix: Back gesture priority for device list over navigation
Add device list (player reveal) to didPopRoute() priority chain.
Previously back gesture would pop navigation routes before dismissing
the device list when on screens other than home.

Priority order is now: device list > expanded player > navigation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:55:26 +00:00
Your NameandClaude Opus 4.5 8ce567a1cf perf: Revert TweenSequence to simple CurvedAnimation for smoother FPS
TweenSequence with chained curves was causing low FPS on the mini player
to expanded player animation. Reverted to simple CurvedAnimation which
is more performant:

- Replace 3-phase TweenSequence with single CurvedAnimation (easeOutCubic)
- Reduce expand duration from 350ms to 280ms
- Simplify bounce animation back to sine-wave calculation
- Keep gesture-driven expand/collapse functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:41:23 +00:00
Your NameandClaude Opus 4.5 2081cf8552 fix: Device list swipe-down and back gesture handling
- Fix swipe-down gesture on active mini player to reveal device list
  (was blocked by gesture tracking starting immediately)
- Fix back gesture priority: device list now closes before underlying
  album/artist screens by handling PopScope at GlobalPlayerOverlay level

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:37:10 +00:00
Your NameandClaude Opus 4.5 8dae062663 fix: Correct swipe direction and faster collapse
- Fix inverted drag: swipe down now moves cards down (toward dismiss)
- Faster collapse animations:
  - Mini player: 250ms → 200ms
  - Player list: 200ms → 150ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:33:32 +00:00
Your NameandClaude Opus 4.5 59eb80c11b fix: Animation refinements based on testing feedback
- Faster collapse (250ms vs 350ms expand) for snappier feel
- Simplified bounce to single clean motion (down 10px → back)
  - Removed overshoot phases that looked like multiple bounces
  - Duration reduced to 200ms
- Fixed swipe dismiss on player list vanishing halfway
  - Reset dragOffset before dismiss() so animation starts correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:13:55 +00:00
Your NameandClaude Opus 4.5 d118dd8fe1 feat: Gesture-driven expand/collapse with keyframe animations
- Replace threshold-based expand/collapse with finger-tracking
  - Mini player now follows finger during vertical swipe
  - Snap decision based on velocity (800px/s) and position (30%)

- Add TweenSequence for phased animations
  - Phase 1 (0-40%): Quick acceleration with easeOut
  - Phase 2 (40-75%): Consistent cruise with linear
  - Phase 3 (75-100%): Settle with slight overshoot (easeOutBack)

- Fix position calculation discontinuity
  - Removed abrupt switch at t=0.1
  - Now smoothly fades out slide/bounce offsets over first 15%

- Add settle bounce when cards collapse into mini player
  - TweenSequence bounce: push down 12px → overshoot -4px → settle
  - Triggered when cards are 70% collapsed for natural "landing" feel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
Your NameandClaude Opus 4.5 3708b24d55 feat(i18n): add Spanish language support with improved picker UI
- Add complete Spanish translations (app_es.arb)
- Replace cramped SegmentedButton with ListTile + Dialog picker
- Localize player states (Off, Idle, Unavailable, Selected)
- Localize Material You description
- Scales better for multiple languages
- Bump version to 2.3.2-beta+24

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:23 +00:00
Your NameandClaude Opus 4.5 e0b4a0a184 chore: bump version to 2.3.1-beta+23
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 08:05:19 +00:00
Your NameandClaude Opus 4.5 407ac9fa7c Complete German translations for remaining hardcoded strings
- Add 14 new localization keys: search, metadataApisDescription,
  lastFmApiKey, lastFmApiKeyHint, swipeToSwitchDevice, chapterNumber,
  pcmAudio, playOn, addAlbumToQueueOn, addToQueueOn, startRadioOn,
  book, audiobookSingular, trackCount
- Update settings_screen.dart: metadata API description, Last.fm fields
- Update global_player_overlay.dart: bottom navigation bar labels
- Update expandable_player.dart: unknown author, PCM audio, swipe hint
- Update album_details_screen.dart: play on/add to queue sheet titles
- Update audiobook_detail_screen.dart: play on sheet title
- Update artist_details_screen.dart: start radio on sheet title
- Update new_library_screen.dart: singular book/audiobook forms
- Update device_selector_bar.dart: swipe to switch device hint

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:36:14 +00:00
Your NameandClaude Opus 4.5 493520ec1e Complete German translations for all remaining hardcoded strings
- Add 20+ new translation keys to app_en.arb and app_de.arb
- Update playlist_details_screen.dart: Tracks header, empty state
- Update audiobook_detail_screen.dart: Resume, About, Chapters, narrator, progress
- Update audiobook_series_screen.dart: No books in series, author comparison
- Update audiobook_author_screen.dart: Narrator display strings
- Update album_details_screen.dart: Tracks header, empty tracks state
- Update settings_screen.dart: API key labels, audiobook libraries section
- Update search_screen.dart: Artist label, no player selected
- Update queue_screen.dart: No upcoming tracks message
- Update new_library_screen.dart: Series empty state, playlist fallback
- Update library_playlists_screen.dart: Track count, playlist fallback
- Update debug_log_screen.dart: Bug report subject

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:04:10 +00:00
Your NameandClaude Opus 4.5 1ea2fa2cb6 fix(i18n): pass l10n parameter to _buildPodcastsComingSoonTab
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:34:05 +00:00
Your NameandClaude Opus 4.5 734ef3f8ed feat(i18n): add more German translations and fix picker styles
- Updated theme/language pickers to match library picker style (border radius 8, opacity 0.5)
- Added 35+ new translation keys for tooltips, settings, and UI elements
- Replaced hardcoded strings in settings, artist, album, audiobook screens
- Fixed sort/view mode tooltips across all detail screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:22:42 +00:00
Your NameandClaude Opus 4.5 2ffa0e1754 fix(i18n): add context to remaining DisconnectedState calls
Fixed DisconnectedState.full and DisconnectedState.withSettingsAction
calls in new_home_screen.dart and new_library_screen.dart.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:58:08 +00:00
Your NameandClaude Opus 4.5 c2e313dfb2 fix(i18n): add missing context parameter to EmptyState/DisconnectedState calls
Fixed all call sites that were missing the required context parameter
after the localization refactor.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:49:59 +00:00
Your NameandClaude Opus 4.5 cecea7c263 feat(i18n): localize all remaining screens and widgets
Localized files:
- screens: album_details, artist_details, audiobook_*, debug_log,
  home, library_*, new_library, playlist_details, queue
- widgets: disconnected_state, empty_state, expandable_player,
  chapters_panel

All hardcoded English strings replaced with S.of(context)! calls.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:36:51 +00:00
Your NameandClaude Opus 4.5 94085e8ed4 fix(settings): restore full-width segmented buttons for theme/language pickers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:18:14 +00:00
Your NameandClaude Opus 4.5 94101898d1 feat(i18n): add language picker and replace hardcoded strings
- Add LocaleProvider for managing app locale
- Add language picker in settings (System/EN/DE) matching library picker style
- Update theme picker to use same compact icon-bar style
- Replace hardcoded strings in login, settings, home, and search screens
- Persist language preference in SharedPreferences

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:04:31 +00:00
Your NameandClaude Opus 4.5 48bca80157 feat(i18n): add German language support infrastructure
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration for code generation
- Add English (app_en.arb) and German (app_de.arb) translation files
- Configure MaterialApp with localization delegates
- Support 150+ translatable strings across the app

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:41:26 +00:00
Your NameandClaude Opus 4.5 682cba577e fix: lazy load cached players for instant UI on app resume
- Make availablePlayers getter load from cache when empty
- Make selectedPlayer getter load from cache when null
- Update connecting banner to check hasCachedPlayers (cache service)
  instead of availablePlayers.isEmpty (timing-dependent)

This ensures mini player and device selector appear instantly when
resuming the app, without waiting for checkAndReconnect to run.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:13:05 +00:00
Your NameandClaude Opus 4.5 f38d2cba4f feat: instant player display on app resume with caching
Cache players in memory so mini player and device selector appear
instantly when resuming the app, without waiting for reconnection.

- Add player caching to CacheService (players list + selected player)
- Load cached players immediately in checkAndReconnect before connecting
- Hide "Connecting..." banner when cached players are available
- Background reconnection happens silently while UI is already usable

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:06:50 +00:00
Your NameandClaude Opus 4.5 20edbc2804 perf(player): skip builtin_player for MA 2.7.0b20+ servers
Instead of attempting 3 retries of builtin_player/register (which fail
on MA 2.7.0b20+) before falling back to Sendspin, detect the server
version upfront and go directly to Sendspin.

This saves ~2 seconds on app resume by avoiding:
- 3 failed builtin_player/register attempts
- Exponential backoff delays (500ms, 1s)
- Error handling overhead

The version check parses server_version (e.g., "2.8.0b2") and compares
against the 2.7.0b20 threshold where Sendspin was introduced.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:38:59 +00:00
Your NameandClaude Opus 4.5 e48b94e9c8 perf(player): optimize player loading on app resume
- Remove duplicate _preloadAdjacentPlayers call in checkAndReconnect
  (already called via refreshPlayers -> _loadAndSelectPlayers)
- Don't await preload in _loadAndSelectPlayers - UI should show
  immediately with cached data, preload happens in background

This fixes slow device button and mini player loading when returning
to the app, especially noticeable on slower network connections.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:34:35 +00:00
Your NameandClaude Opus 4.5 73ca6f230e chore: bump version to 2.3.0-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:19:31 +00:00
Your NameandClaude Opus 4.5 d0ab9ff618 feat(sendspin): add proxy authentication support for MA 2.7.1+
- Add auth token field and setAuthToken() method to SendspinService
- Send auth message before client/hello when connecting via HTTPS proxy
- Wait for auth_ok response before proceeding with handshake
- Provider now sets auth token from SettingsService before connecting
- Allows Sendspin to work through reverse proxies without exposing port 8927

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:02:31 +00:00
Your NameandClaude Opus 4.5 0989b322a2 fix(player): preload all tracks on app resume + preserve cached images
- Preload all player tracks in checkAndReconnect() after app resumes
- Preserve cached tracks that have images even if new event lacks image
- This ensures album art is available when swiping between players

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:06:09 +00:00
Your NameandClaude Opus 4.5 433eacfc43 fix(player): correct text width and artist parsing
Text overlap fix:
- Use collapsedWidth (with margins) instead of screenSize.width
- collapsedWidth is 24px narrower, fixing the overlap

Stale track data fix:
- Parse artist from title when title contains " - " and artist is unknown
- Don't overwrite cache if existing track has proper artist info
- Only set _currentTrack from event if not already set

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:02:41 +00:00
Your NameandClaude Opus 4.5 e24df29239 fix(player): proper text truncation in mini player
- Fix width calculation to use titleLeft position for accurate constraint
- Add softWrap: false in collapsed state to ensure ellipsis truncation
- Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (margin) = 114px
- Reserve 122px from right edge for 8px gap to controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 21:38:42 +00:00
Your NameandClaude Opus 4.5 2736b4478a fix(player): await fresh track data on player select
When resuming app after background, cached track data from player_updated
events may have incomplete artist info (e.g., "Artist - Title" in name field
with null artist). Now awaits _updatePlayerState() before notifying UI to
ensure fresh queue data is displayed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:59:11 +00:00
Your NameandClaude Opus 4.5 020a440f11 fix(player): consistent 14px secondary text and 8px control gap
- Set secondary text to 14px in both mini player and device selector bar
- Adjust text width for consistent 8px gap before player controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:45:55 +00:00
Your NameandClaude Opus 4.5 2e7682facd fix(player): match mini player artist font size to device selector bar
Change artist font size from 14px to 12px in collapsed mini player to match
the DeviceSelectorBar styling for visual consistency.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:06 +00:00
Your NameandClaude Opus 4.5 f5221b3160 chore: bump version to 2.2.1-beta+21 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:45:21 +00:00
Your NameandClaude Opus 4.5 4265b8a544 fix: Clear audiobook context when switching to music playback
When switching from audiobook to music track playback, the audiobook
context (_currentAudiobook) was not being cleared. This caused the
player UI to remain in audiobook mode (30-second skip buttons, author
instead of artist, chapters in queue) even when playing regular music.

The fix detects when media_type changes to 'track' in player_updated
events and clears the audiobook context, allowing the UI to correctly
switch to music mode.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:20:25 +00:00
Your NameandClaude Opus 4.5 014f0a4903 Fix PCM audio race condition with auto-recovery
When audio frames arrive before the stream/start message (or after
pause/release), the native PCM player isn't setup yet, causing
"must call setup first" errors and silent playback.

This fix adds auto-recovery in two places:
1. In _onAudioData: If audio arrives while paused, automatically
   re-initialize and resume playback
2. In _feedNextChunk: If feed() fails with setup error, re-initialize
   the native player and retry

This ensures audio playback starts even when binary frames race
ahead of JSON control messages on the WebSocket.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:55:13 +00:00
Your NameandClaude Opus 4.5 2f0fc03dc9 chore: bump version to 2.2.1-beta+20
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:41:21 +00:00
Your NameandClaude Opus 4.5 9ae8462c2e fix(home): use overlay for connecting banner to prevent layout shift
Changed from Column to Stack so the "Connecting..." banner overlays
content instead of pushing rows down when it appears/disappears.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:40:39 +00:00
Your NameandClaude Opus 4.5 290f302da6 chore: bump version to 2.2.0-beta+19
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 15:27:01 +00:00
Your NameandClaude Opus 4.5 c359e5551e feat(series): add Hero animation from home row to detail screen
Wrap cover in Hero widget with unique tag and pass to detail screen

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:48:45 +00:00
Your NameandClaude Opus 4.5 abf7e11a23 fix(series): square covers and colored empty cells for home row
- Use AspectRatio instead of Expanded to guarantee square covers
- Add color extraction from book covers for empty cell backgrounds
- Add nested grid patterns (solid, 2x2, 3x3) in empty cells
- Pass initialCovers to series detail for smooth Hero animation
- Removes grey bars that appeared due to non-square container

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:33:05 +00:00
Your NameandClaude Opus 4.5 d312f37fdb style(series): match book count text to album card styling
Use 0.7 opacity to match artist text in album cards

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:23:03 +00:00
Your NameandClaude Opus 4.5 9614f8e4d4 fix(series): remove grid lines from cover and show book count
- Remove margins between grid cells in series detail cover for seamless appearance
- Add book count caching when loading series covers
- Display book count under series titles in all layouts (list, grid, home row)
- Book count now shows when covers load, even if not provided by API

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:21:38 +00:00
Your NameandClaude Opus 4.5 2a01eb2344 feat(home): add drag-and-drop row ordering in settings
- Add homeRowOrder setting to persist row display order
- Replace separate toggle sections with ReorderableListView
- Add drag handles to each row for reordering
- Home screen now respects the saved row order
- Remove legacy random favorites order logic

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:11:45 +00:00
Your NameandClaude Opus 4.5 aeb3428115 fix(series): ensure square aspect ratio for series row covers
- Wrap cover grid in AspectRatio(1) to force square
- Use Column/Row instead of GridView for consistent sizing
- Eliminates grey bar at bottom of covers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:04:33 +00:00
Your NameandClaude Opus 4.5 cc948c3c06 feat(series): add Hero animation for series list layout
- Add Hero wrapper to series list tile leading image
- Pass heroTag and initialCovers to series detail screen
- First cover in list becomes first cover in detail grid

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 13:45:39 +00:00
Your NameandClaude Opus 4.5 c1fd25f359 fix(series): use dynamic grid size in series detail header
- 1 cover = 1x1 grid
- 2-4 covers = 2x2 grid
- 5+ covers = 3x3 grid

Matches the library screen grid sizing logic for consistency.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:03:29 +00:00
Your NameandClaude Opus 4.5 115b2729d8 fix(series): smooth Hero animation when opening series detail
- Pass pre-loaded cover URLs from library to series detail screen
- Use initial covers immediately instead of showing loading placeholder
- Extract colors from initial covers for consistent empty cell appearance
- This ensures the Hero animation works smoothly in both directions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:55:48 +00:00
Your NameandClaude Opus 4.5 3e48066030 refactor(series): tone down grid colors and simplify patterns
- Remove grid lines between nested cells (seamless appearance)
- Limit nested grids to 1x1, 2x2, 3x3 only (no 4x4)
- Adjust distribution: 50% solid, 30% 2x2, 20% 3x3
- Reduce color saturation (50%) and darken (70%) for subtler look

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:55:42 +00:00
Your NameandClaude Opus 4.5 7ed02232e6 feat(series): extract colors from book covers for empty grid cells
- Extract dark muted colors from first 4 book covers per series
- Use extracted colors for empty cells instead of static palette
- Colors extracted asynchronously to avoid blocking UI
- Falls back to static dark pastel palette when colors not yet loaded

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:39:09 +00:00
Your NameandClaude Opus 4.5 de72736146 feat(series): add nested grid patterns to empty cover cells
- Add dark pastel color palette to series detail screen header
- Implement random nested grids (2x2, 3x3, 4x4) inside empty squares
- Use deterministic pattern based on series ID for consistency
- Distribution: ~40% solid, ~25% 2x2, ~20% 3x3, ~15% 4x4

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:25:10 +00:00
Your Name f03ae2f75b Bump version to 2.1.2-beta 2025-12-19 06:10:19 +00:00
Your NameandClaude Opus 4.5 23fe4a20c7 Fix auth detection for Tailscale/reverse proxy URLs
When checking for MA auth on HTTP URLs without a port, the code was
defaulting to port 8095 (MA's direct port). This caused auth detection
to fail for Tailscale URLs behind a reverse proxy on port 80.

Now uses default HTTP/HTTPS ports (80/443) when no port is specified.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 06:10:00 +00:00
Your Name 90ad14de28 Add cache-busting to screenshot URLs 2025-12-19 05:57:33 +00:00
Your NameandClaude Opus 4.5 b3562fee0c Update screenshots and make them more compact in README
- Updated screenshots 3, 4, 5 with new captures
- Added new screenshots 7 (Audiobooks) and 8 (Audiobook Player)
- Reduced screenshot width from 250px to 150px for compact display
- Arranged all 8 screenshots in a single row that wraps naturally

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 05:52:38 +00:00
Your NameandClaude Opus 4.5 baa6299130 Bump version to 2.1.1-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 05:07:22 +00:00
Your NameandClaude Opus 4.5 e6a02c191e Improve Tailscale and HTTP connection handling
- Add Tailscale URL detection (*.ts.net) to default to HTTP instead of HTTPS,
  since the VPN tunnel already provides encryption
- Implement HTTPS to HTTP automatic fallback when HTTPS connection fails
- Add HTTP connection info banner on login screen:
  - Shows "Tailscale VPN Connection" with VPN lock icon for *.ts.net URLs
  - Shows "Unencrypted Connection" warning for other HTTP connections
- Track resolved server URL from fallback and use it for actual connection

Fixes user-reported issue where connecting via Tailscale URL required
manually adding "http://" prefix and specifying port 80.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 04:48:59 +00:00
Your NameandClaude Opus 4.5 7d7e5266c7 Update README with audiobooks and new features
- Add Audiobooks section with full feature list
- Add Instant App Restore and Offline Browsing to Smart Features
- Update requirements for MA 2.7.0 beta 20+
- Add Audiobookshelf requirement for audiobook features
- Remove outdated Sendspin compatibility note

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 22:14:42 +00:00
Your NameandClaude Opus 4.5 4a2fe2266d Bump version to 2.1.0-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:52:27 +00:00
Your NameandClaude Opus 4.5 8bb927af9b Fix series ordering with browse order fallback
- Add browseOrder field to Audiobook class
- Inject browse order from API response when no position is set
- Use browse order as last-resort fallback in seriesSequence
- This preserves the order returned by MA/Audiobookshelf browse API
  when no explicit series sequence metadata is available

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:38:47 +00:00
Your NameandClaude Opus 4.5 348168b111 Add debug logging for series sequence detection
Logs raw audiobook data and sequence values to diagnose
sorting issues with different series (Dark Tower, Dan Carlin).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:36:23 +00:00
Your NameandClaude Opus 4.5 4007622e48 Fix series book ordering and enhance sequence detection
- Move _sortAudiobooks() call inside setState so UI updates correctly
- Default sort order is now 'series' (by series number ascending)
- Sort button toggles between series number and alphabetical order
- Enhanced seriesSequence getter to extract from multiple sources:
  - Parent's position field
  - sortName patterns (leading numbers, "Book N")
  - name patterns (leading numbers, "Book N")
  - Metadata fields (sequence, series_sequence, series_number, etc.)
  - Series object with sequence/number/order

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:19:08 +00:00
Your NameandClaude Opus 4.5 130166bbd5 Fix series cover grid to always fill container with 3x3 layout
- Replace GridView.builder with Column/Row layout
- Always use 3x3 grid regardless of cover count
- Empty cells show placeholder color
- Fixes cropping issue where grid wasn't filling container

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:16:34 +00:00
Your NameandClaude Opus 4.5 50c1fe3945 Fix textTheme error and increase series cover size
- Fix: Use Theme.of(context).textTheme instead of undefined textTheme
- Increase series detail cover to 280x280 (matches audiobook detail)
- Increase expandedHeight to 350 (matches audiobook detail)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:00:40 +00:00
Your NameandClaude Opus 4.5 bf7cbbb7a8 Implement instant app restore with cached data
- Show cached library data immediately while connecting
- Add audiobook and playlist caching to SyncService
- Add connecting banner when showing cached data
- Fix series book ordering: default to series number, toggle series/alpha
- Add seriesSequence getter to Audiobook model for sorting

This enables the app to show the last known state instantly on startup,
with a subtle "Connecting..." indicator while reconnecting in background.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:50:50 +00:00
Your NameandClaude Opus 4.5 114e5074eb Fix: Restore flutter/painting.dart import for image precaching
The import was incorrectly removed but ImageConfiguration,
ImageStreamListener, and ImageInfo are used by _precacheImage().

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:34:49 +00:00
Your NameandClaude Opus 4.5 9f1a1d293a Add Play On button to audiobook detail screen
- Add Play On button (speaker icon) to audiobook detail action buttons
- Remove play button row from series screen (was added in error)
- Keep Hero animation for series cards

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:26:21 +00:00
Your NameandClaude Opus 4.5 5c8625840c Add Hero animations and play controls for series
- Add Hero animation to series cards in library grid view
- Add play button row to series detail screen (Play, Play On, Add to Queue)
- Fix audiobook detail expandedHeight from 320 to 350
- Remove unused flutter/painting.dart imports

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:20:52 +00:00
Your NameandClaude Opus 4.5 ee6e6ec488 Add dark pastel colors for empty series cover cells
- 8 muted book-like colors: slate, blue-grey, brown, midnight blue, etc.
- Series ID used as seed for consistent color selection per series
- No grid lines between cells for seamless appearance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:46:12 +00:00
Your NameandClaude Opus 4.5 5e3cea7d30 Remove grid lines from series cover grid
- Remove 1px margins between cover cells for seamless appearance
- Empty cells now transparent (show parent background)
- Simplify loading state to solid color container

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:43:52 +00:00
Your NameandClaude Opus 4.5 a451eda82c Improve author image fetching with multiple fixes
- Use first author name for image lookup instead of concatenated string
  (searching "Author A, Author B" never matches)
- Add Wikipedia API as third fallback source for author photos
- Image sources now: Audnexus → Open Library → Wikipedia

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:41:08 +00:00
Your NameandClaude Opus 4.5 bfd0c88ea1 Replace GridView with Column/Row for series cover grid
GridView.builder inside AspectRatio was causing layout animations
when scrolling. Using simple Column/Row layout eliminates this.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:26:00 +00:00
Your NameandClaude Opus 4.5 126c587266 Fix series cover animation and show chapter timestamps
- Remove fade animation from series cover grid images in 2-column view
- Change chapter time display from duration to cumulative time from start
- Clean up redundant position time in audiobook detail chapter subtitle

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:00:20 +00:00
Your NameandClaude Opus 4.5 e7efc571c4 Add audiobook and series rows to home screen
- Create AudiobookRow widget for displaying audiobooks in horizontal row
- Create SeriesRow widget for displaying series in horizontal row
- Add provider methods: getInProgressAudiobooks, getDiscoverAudiobooks, getDiscoverSeries
- Integrate audiobook settings into home screen (Continue Listening, Discover Audiobooks, Discover Series)
- Home screen now reads audiobook row settings from SettingsService

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:54:29 +00:00
Your NameandClaude Opus 4.5 0fd66bce17 Improve series tab: add list view, remove animations, consistent detail screen
- Add list view option to series tab (cycling: grid2 → grid3 → list)
- Remove Hero animations from series grid cards to fix cover loading issues
- Replace animated loading spinners with static placeholders in series grid
- Redesign series detail screen to match author/album detail screens:
  - Centered cover collage in header
  - Title and book count below header
  - Section header with sort/view mode controls
  - List/grid view with progress indicators
- Add series audiobook view preferences to SettingsService

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:49:21 +00:00
Your NameandClaude Opus 4.5 0a14605ced Add series view mode toggle and audiobook home row settings
Library screen:
- Fix series cover grid: 2-4 books use 2x2 grid, 5+ use 3x3
- Add series view mode toggle (grid2/grid3)
- Respect maxGridSize param for smaller displays (3-col uses 2x2)
- Fix bottom spacing for Authors, Books, Series tabs (withMiniPlayer)

Settings screen:
- Add audiobook home row settings section
- Continue Listening (audiobooks in progress)
- Discover Audiobooks (random audiobooks)
- Discover Series (random series)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:05:19 +00:00
Your NameandClaude Opus 4.5 b64b2daa04 Fix ChaptersPanel: use correct Chapter class and positionMs property
- Use Chapter class instead of AudiobookChapter
- Use positionMs (milliseconds) instead of position for seeking
- Convert milliseconds to seconds when seeking

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 16:39:21 +00:00
Your NameandClaude Opus 4.5 ae7e1f1129 Add audiobook improvements: author display and chapters panel
- Show author name instead of 'unknown artist' for audiobooks in player
- Add ChaptersPanel widget for audiobook chapter navigation
- Replace queue panel with chapters panel when playing audiobooks
- Chapters panel shows chapter numbers, titles, durations
- Tap chapter to seek directly to that position
- Current chapter highlighted with accent color

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 16:26:44 +00:00
Your NameandClaude Opus 4.5 7f66ae09ed Fix audiobooks tabs UI to match music tabs layout
- Authors tab: Use LayoutBuilder for proper circular images (not ovals)
- All Books tab: Remove header row with extra sort/view buttons
- All Books tab: Change to direct GridView.builder like music albums
- Series tab: Update aspect ratio from 0.68 to 0.75 to match music albums
- Match spacing and padding with music tabs across all audiobook tabs
- Remove unused _buildAudiobooksSliver method

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 16:06:47 +00:00
Your NameandClaude Opus 4.5 fc44060762 Fix audiobooks tab grid layouts for consistent appearance
Books tab:
- Use AspectRatio(1.0) for square artwork, consistent in 2 and 3 column
- Progress bar always inside artwork

Authors tab:
- Reduce circle size with padding to leave room for text
- Adjust aspect ratio for better text visibility

Series tab:
- Remove Card wrapper and shadow for cleaner look
- Match books tab style: square cover grid with text below
- Use consistent spacing and aspect ratios

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 15:20:40 +00:00
Your NameandClaude Opus 4.5 078d19e775 Fix: Use _fullAudiobook setter instead of _audiobook getter
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:38:24 +00:00
Your NameandClaude Opus 4.5 5ae378b4a8 Add Phase 9 polish: Mark as Finished/Unplayed, long duration format, context clearing
- Add Mark as Finished and Mark as Unplayed options in audiobook detail menu
- Fix duration format for long audiobooks (20+ hours) to show hh:mm:ss
- Clear audiobook context when switching to non-audiobook media
- Clear audiobook context when playback stops

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:28:48 +00:00
Your NameandClaude Opus 4.5 e268f88995 Add audiobooks to recently played tracking
- Add recordAudiobookPlayed method to RecentlyPlayedService
- Add getRecentAudiobooks method to retrieve recent audiobooks
- Record audiobook plays in audiobook detail screen

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:04:10 +00:00
Your NameandClaude Opus 4.5 aec7266ecd Add audiobook-specific player controls and chapter display
- Add 30-second skip forward/back buttons for audiobooks
- Add chapter navigation (previous/next chapter) when chapters available
- Show current chapter name and progress in expanded player
- Add audiobook context tracking in provider with chapter methods
- Store audiobook context when playback starts for player controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:02:41 +00:00
Your Name 8e78da6390 Add library filtering support for audiobooks
When specific ABS libraries are enabled in settings, audiobooks will now
be fetched using the browse API (same approach as series) to filter by
library.

If no specific libraries are enabled (all libraries shown), uses the
faster MA API endpoint.

This allows users to select which ABS libraries show audiobooks in the
Library > Books tab.
2025-12-18 13:14:09 +00:00
Your Name 521e813e99 Fix snapcast players being incorrectly filtered out
The player filter was using 'player.playerId.startsWith("ma_")' which
incorrectly matched snapcast players that have IDs starting with 'ma_'.

Fix: Now requires BOTH 'provider == "builtin_player"' AND the 'ma_' prefix
to filter out MA Web UI's built-in player. This prevents legitimate players
like snapcast from being filtered.
2025-12-18 13:08:32 +00:00
Your Name c2a939b474 Add hero animations for series and book transitions
- Series card cover grid animates to series screen header
- Book list items animate to audiobook detail screen cover
- Uses unique hero tags based on series/book IDs
2025-12-18 13:04:11 +00:00
Your Name 47acecfb69 Add 3x3 book cover grid for series cards
Series cards now show a grid of book covers instead of a placeholder:
- Fetches first 9 book covers from each series
- Caches covers for performance
- Shows loading indicator while fetching
- Dynamically adjusts grid size (1x1, 2x2, or 3x3) based on available covers
- Falls back to placeholder icon if no covers available
2025-12-18 13:02:30 +00:00
Your Name 1fe8f696d8 Add bottom padding to series grid for nav bar + mini player 2025-12-18 12:46:20 +00:00
Your Name aea4931286 Fix series screen freeze - remove duplicate GlobalPlayerOverlay wrapper
The freeze was caused by nesting GlobalPlayerOverlay widgets, which use
a GlobalKey. Having two widgets with the same GlobalKey causes Flutter
to crash/freeze.

Fix: Remove the GlobalPlayerOverlay wrapper from the series screen since
it's already applied at the app level in MaterialApp.builder.

Also added bottom padding for mini player.
2025-12-18 12:44:35 +00:00
Your Name a73f468303 Fix unclosed comment - DEBUG minimal series screen 2025-12-18 12:26:16 +00:00
Your NameandClaude Opus 4.5 b08bec82f5 DEBUG: Absolute minimal series screen
Just a Scaffold with AppBar and text - no:
- GlobalPlayerOverlay
- CustomScrollView
- Images
- Provider watching
- Data loading

If this still freezes, the issue is in navigation or widget creation itself.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:56:40 +00:00
Your NameandClaude Opus 4.5 9365e4327a DEBUG: Remove header image to test if that causes freeze
Previous test still froze with no data loading, so the issue
is in the UI. Removed CachedNetworkImage from header - now
just shows a plain colored container with icon.

If this works: CachedNetworkImage is the problem
If still freezes: Issue is elsewhere in the screen

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:40:27 +00:00
Your NameandClaude Opus 4.5 932e0dffa9 DEBUG: Skip data loading to test if screen renders
Test build - the series screen will show "Loading..." forever
but should NOT freeze. If this doesn't freeze, the issue is
confirmed to be in the data loading code.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:27:09 +00:00
Your NameandClaude Opus 4.5 16fdef1c13 Defer series loading to after first frame
The series tap freeze might be caused by something blocking during
initState. By deferring the load to addPostFrameCallback, we allow
the UI to render first (showing the loading spinner), then start
the data fetch.

This helps isolate whether the freeze is in:
1. The navigation/initial build (screen would show briefly)
2. The data loading (screen shows then freezes)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:20:47 +00:00
Your NameandClaude Opus 4.5 e4e563f822 Add debug logging for series tap freeze investigation
Detailed logging added to:
1. AudiobookSeriesScreen - track initState, provider access, API call
2. getSeriesAudiobooks - track browse call, item parsing

This will help identify where the freeze occurs when tapping on a series.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:11:44 +00:00
Your NameandClaude Opus 4.5 8ffee71023 Fix Color.withValues -> withOpacity for older Flutter
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:36:11 +00:00
Your NameandClaude Opus 4.5 f321e0c2ba Add series detail screen and remove broken audiobook filtering
Changes:
1. Created AudiobookSeriesScreen - shows books in a series with navigation
   to audiobook detail screen
2. Hooked up series card tap to navigate to series screen
3. Removed audiobook library filtering code - it can't work because
   the audiobook URI (library://audiobook/123) doesn't contain library info

Note: Series filtering DOES work via the browse API since series are
loaded per-library. Library toggle affects series when user pulls to
refresh on the Series tab.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:26:16 +00:00
Your NameandClaude Opus 4.5 1a03d0688c Fix audiobook library filtering to use URI instead of providerMappings
The providerMappings.itemId is just a UUID without library info.
The URI field contains the full path with library ID (lb xxx format).

If URI doesn't contain library info, default to showing the audiobook.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:06:47 +00:00
Your NameandClaude Opus 4.5 ee258f0c04 Add more debug logging for audiobook URI and itemId
Need to see the actual URI format to understand how to filter by library.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:03:10 +00:00
Your NameandClaude Opus 4.5 3b868681d1 Add debug logging for audiobook library filtering
Added logging to debug why all audiobooks are being filtered out:
- Log enabled libraries list
- Log first audiobook's providerMappings to see actual item_id format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:30:09 +00:00
Your NameandClaude Opus 4.5 f9f2d4ffce Fix library toggle filtering for audiobooks and series
- Fixed series filtering that was incorrectly skipping all series items
  (was checking for any media_type, now only skips audiobook/track)
- Added library filtering for audiobooks based on enabled libraries
- Added snackbar notification to remind users to refresh after toggling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:10:07 +00:00
Your NameandClaude Opus 4.5 2c23939226 Add library toggle setting for audiobook series
- Add settings to store discovered and enabled ABS libraries
- API saves discovered libraries when browsing for series
- API respects enabled libraries when searching for series
- Settings screen shows library toggles when libraries discovered
- Libraries all enabled by default, can be disabled individually

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:50:00 +00:00
Your NameandClaude Opus 4.5 6aae8e4597 Browse into ABS libraries to find series folders
The audiobookshelf provider shows libraries first, not categories.
Now we browse:
  Root -> Audiobookshelf -> Each Library -> Look for Series folder

This discovers series across all ABS libraries.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:47:35 +00:00
Your NameandClaude Opus 4.5 4865a4f247 Fix description HTML and improve series browse discovery
- Add _stripHtml helper to clean HTML tags from descriptions
- Update series fetching to browse root first to discover structure
- Add detailed logging for browse path discovery
- Log all items at each level to debug series path finding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:37:22 +00:00
Your NameandClaude Opus 4.5 7ecbe7a285 Add series support via MA browse API
- Add BrowseFolder and AudiobookSeries models
- Implement browse() method to navigate MA content
- Add getAudiobookSeries() to fetch series from provider
- Add getSeriesAudiobooks() to fetch books in a series
- Update library screen Series tab to display actual series
- Replace discovery-only approach with real data display

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:21:48 +00:00
Your NameandClaude Opus 4.5 6923dc7f39 Fix audiobook chapter parsing - read from metadata
- Chapter.fromJson now handles MA API format (position, name, start, end)
- Audiobook.fromJson now checks metadata['chapters'] if top-level missing
- Also parse description from metadata as fallback
- Properly convert start/end seconds to milliseconds

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:15:33 +00:00
Your NameandClaude Opus 4.5 176b085659 Fix audiobook API: try multiple patterns, log metadata for chapters
- Try multiple API patterns for getAudiobookDetails since
  'music/audiobooks/get_item' is invalid
- Fall back to list data if all API patterns fail
- Add detailed metadata logging in getAudiobooks to discover
  where chapters and series info is stored
- Chapters/series are likely in the metadata field

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 07:56:28 +00:00
Your NameandClaude Opus 4.5 36947045fc Remove direct Audiobookshelf integration, use MA API only
- Remove audiobookshelf_service.dart and all ABS integration code
- Remove ABS settings from settings screen
- Add verbose logging to discover MA audiobook/chapter/series fields
- Add exploreSeries() method for API discovery
- Update Series tab to show discovery UI while we learn MA's API
- Make author/audiobook art sizes consistent with artist/album screens:
  - Author: 200x200 circular (matches Artist)
  - Audiobook: 280x280 square (matches Album)

Chapters and series are available in MA web UI, so we should be able
to get them from MA's API without needing direct ABS calls. The added
logging will help discover the correct API endpoints and field names.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 07:42:16 +00:00
Your NameandClaude Opus 4.5 d0ac0811ca Implement Series tab and fix audiobook UI issues
- Add Series tab that fetches and displays series from Audiobookshelf
- Fix layout buttons for books tabs (Authors, All Books, Series)
- Add chapter start time display in audiobook detail screen
- Improve chapter duration formatting and display

Series tab features:
- Shows loading state while fetching
- Prompts to configure ABS if not connected
- Displays series in a 2-column grid with name and book count
- Supports pull-to-refresh
- Sorts series alphabetically

Chapter display improvements:
- Shows start position time (h:mm:ss format) for each chapter
- Better duration formatting in trailing position
- "In progress" indicator with position time

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 22:21:25 +00:00
Your NameandClaude Opus 4.5 fc4fc3dfdf Add Audiobookshelf direct integration for enhanced audiobook features
- Add Audiobookshelf service for direct API communication
- Add ABS settings UI in settings screen (server URL, username, password)
- Implement chapter fetching from Audiobookshelf when MA doesn't provide chapters
- Add author image fetching priority: Audiobookshelf > Audnexus > Open Library
- Add book search by title in ABS libraries
- Add settings persistence for ABS configuration

This enables enhanced audiobook features:
- Author images from ABS (more reliable than external APIs)
- Chapter information for audiobooks
- Series data support (foundation for series tab)
- Better metadata from ABS

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 22:00:22 +00:00
Your NameandClaude Opus 4.5 876c1502a3 feat: Add chapter support and improve audiobook detail screen
- Add getAudiobookDetails API method to fetch full audiobook with chapters
- Load chapters when opening audiobook detail screen
- Show loading state while fetching chapters
- Display chapters in album-track style with:
  - Square rounded number badges (checkmark when completed)
  - Duration in trailing position
  - "In progress" subtitle for current chapter
  - Long-press to expand action button
- Show "No chapter information available" if none exist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 21:29:13 +00:00
Your NameandClaude Opus 4.5 482052d1cf fix: Increase audiobook limit and improve author images
- Increase audiobook fetch limit from 500 to 10000 to show all books
- Add Audnexus API as primary source for author images (better coverage for audiobook authors)
- Keep Open Library as fallback with image existence verification
- Audnexus uses Audible data which has more audiobook author photos

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 21:20:54 +00:00
Your NameandClaude Opus 4.5 8fc656a9d2 feat: Add hero animations for audiobooks and authors
- Add hero tag constants for audiobooks and authors in hero_tags.dart
- Add Hero widgets to AudiobookDetailScreen (cover image and title)
- Add Hero widgets to AudiobookAuthorScreen (author image)
- Add Hero widgets to audiobook cards/list tiles in library screen
- Add Hero widgets to author cards/list tiles in library screen
- Add Hero widgets to audiobook cards/list tiles in author screen
- Pass heroTagSuffix through navigation to match animation tags

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:59:32 +00:00
Your NameandClaude Opus 4.5 0d317c4c36 feat: Add author images from Open Library API
- Add getAuthorImageUrl() to MetadataService using Open Library API
- Update AudiobookAuthorScreen to fetch and display author photo
- Update library screen Authors tab with author images in list/grid
- Images fetched in background, cached locally
- Falls back to initials if no image found

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:49:10 +00:00
Your NameandClaude Opus 4.5 628281a29f fix: Add playAudiobook API method and fix detail screen
- Add playAudiobook() method to MusicAssistantAPI
- Fix audiobook_detail_screen to use correct API methods:
  - playAudiobook() instead of non-existent playMedia()
  - seek() instead of non-existent queueCommandSeek()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:00:31 +00:00
Your NameandClaude Opus 4.5 38d96829e1 feat: Add audiobook author and detail screens with UI improvements
- Create AudiobookAuthorScreen for viewing author's audiobooks
  - Sort by alpha/year, view modes (list/grid2/grid3)
  - Progress indicators on audiobook covers
- Create AudiobookDetailScreen for audiobook details
  - Shows metadata, chapters, progress
  - Play/resume buttons, chapter navigation
  - Adaptive theming with color extraction
- Update library Authors tab
  - Change from expandable sections to tappable list/grid
  - Add view mode toggle (list/grid)
  - Navigation to AudiobookAuthorScreen
- Update library All Books tab
  - Add sort order toggle (alpha/year)
  - Add view mode toggle (list/grid2/grid3)
  - Navigation to AudiobookDetailScreen
- Add settings persistence for audiobook view preferences

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:42:26 +00:00
Your NameandClaude Opus 4.5 3b4d3d4a0a Fix audiobook parsing: handle authors/narrators as strings
MA returns authors and narrators as string arrays like ["Author Name"]
instead of Artist objects. Added parseArtistList helper that handles
both formats - strings get converted to Artist objects with just the name.

Fixes: type 'String' is not a subtype of type 'Map<String, dynamic>'

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:18:00 +00:00
Your NameandClaude Opus 4.5 b0451eab4f Reduce audiobook logging verbosity
- Remove full response dump that was overwhelming logs
- Only log first item's keys and name/media_type
- Summarize parse errors instead of logging each one

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:15:17 +00:00
Your NameandClaude Opus 4.5 b512543cd2 Add detailed audiobook logging for debugging
- Add verbose logging to getAudiobooks API call
- Log response structure, item count, and parsing results
- Add logging to _loadAudiobooks and _changeMediaType in library screen
- Track when Books tab is selected and API calls are made

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 19:12:52 +00:00
Your NameandClaude Opus 4.5 0d79b716fe debug: Add logging to audiobooks API call
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:31:10 +00:00
Your NameandClaude Opus 4.5 bacfc31b7b fix: Use onRefresh instead of actionLabel in EmptyState.custom
The EmptyState.custom factory constructor uses onRefresh parameter,
not actionLabel/onAction. Fixed both occurrences in the Books tab.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:07:56 +00:00
Your NameandClaude Opus 4.5 3a69e4b1de feat(audiobooks): Implement Phase 6 - Books tab with audiobook support
- Add Audiobook and Chapter models with progress tracking
- Add MediaType enum values for audiobook, chapter, podcast, podcastEpisode
- Add getAudiobooks() API method using music/audiobooks/library_items endpoint
- Implement Authors tab with expandable sections grouped by author
- Implement All Books tab with grid/list views and progress indicators
- Keep Series tab as placeholder for future implementation
- Support favorites filtering for audiobooks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:57:13 +00:00
Your NameandClaude Opus 4.5 3334e81ea5 feat(library): Redesign header with media type icons and inline buttons
- Top left: Music/Books/Podcasts icons for media type selection
- Tab row: Scrollable tabs on left, favorites+layout buttons on right
- Remove centered pill tabs for cleaner layout
- Reduce vertical space usage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:37:43 +00:00
Your Name c95a9212cf Revert "feat(library): Replace media type pills with nav bar gesture menu"
This reverts commit ebf60c40ac.
2025-12-17 15:51:53 +00:00
Your NameandClaude Opus 4.5 ebf60c40ac feat(library): Replace media type pills with nav bar gesture menu
- Long-press or swipe-up on Library icon shows Music/Books/Podcasts menu
- Remove pill tabs from library screen for cleaner layout
- Move LibraryMediaType to NavigationProvider for global state
- Custom bottom nav bar with gesture detection on Library item

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:33:12 +00:00
Your Name 9b15a26d0c feat(library): Add favorites heart + layout buttons (top left), PlayerSelector (top right)
- Favorites toggle: filled heart when active, outline when inactive
- Layout button: cycles through grid/list views
- Both buttons only show for Music media type
- PlayerSelector restored to top right position
2025-12-17 12:23:40 +00:00
Your NameandClaude Opus 4.5 8de26620cf fix(library): Remove PlayerSelector, favorites toggle, view mode button
Clean up Library screen - will add these back later

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:59:45 +00:00
Your NameandClaude Opus 4.5 8f2bb2cc36 feat(library): Add media type pills (Music/Books/Podcasts)
- Add pill selector at top of Library for media type
- Music: Artists, Albums, (Tracks), Playlists tabs
- Books: Authors, All Books, Series tabs (placeholders)
- Podcasts: Coming soon placeholder
- Pills use accent color (filled when selected, muted when not)
- Favorites toggle only shows for Music type
- View mode toggle only shows for Music type

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:28:56 +00:00
Your NameandClaude Opus 4.5 a96cc024d5 feat(sync): Add library caching with background sync (Phase 4)
- Add SyncService for background library synchronization
- Load library from database cache on startup for instant UI
- Sync from MA API in background without blocking
- Add toJson methods to Album, Artist, Track for serialization
- Update MusicAssistantProvider to use cache-first approach
- Add sync indicator to home screen app bar
- Pull-to-refresh triggers full library sync

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:44:10 +00:00
Your NameandClaude Opus 4.5 a74ba33807 fix(recently-played): Remove imageUrl references (not on models)
Images are fetched via MusicAssistantProvider.getImageUrl(), not stored
on the model objects. The Album/Artist/Track/Playlist models don't have
an imageUrl property.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:22:36 +00:00
Your NameandClaude Opus 4.5 9774f7d18d feat(recently-played): Add local per-profile recently played tracking
Phase 2 of audiobooks integration - local recently played:

- Add RecentlyPlayedService for tracking plays in local database
- Hook album plays into recording (album_details_screen.dart)
- Hook playlist plays into recording (playlist_details_screen.dart)
- Update getRecentAlbumsWithCache to prioritize local data
- Local data provides instant results on app startup
- Falls back to MA data if no local history yet

Benefits:
- Instant recently played display (no API wait)
- Per-profile isolation (each user sees their own history)
- Foundation for audiobook "Continue Listening"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 10:13:45 +00:00
Your NameandClaude Opus 4.5 021cc1feab fix(deps): Pin drift_flutter to 0.2.0 for compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:55:18 +00:00
Your NameandClaude Opus 4.5 c0cadd83a6 fix(deps): Upgrade share_plus to resolve dependency conflict with drift
share_plus ^9.0.0 depends on web ^0.5.0 which conflicts with
drift ^2.22.1 which depends on web ^1.0.0

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:47:33 +00:00
Your NameandClaude Opus 4.5 376f076bc1 feat(database): Add local database with Drift for user profiles and caching
Phase 1 of audiobooks integration - establishes local storage foundation:

- Add Drift (SQLite) database with tables for profiles, recently played,
  library cache, and sync metadata
- Add DatabaseService singleton for database access
- Add ProfileService for automatic user profile management
- Profiles auto-created from MA authentication (username) or manual entry
- Migration support for existing ownerName to profile
- Hook profile creation into MA login flow and login screen
- Update CI workflow to generate Drift code with build_runner
- Add comprehensive implementation plan document

This enables:
- Per-user recently played isolation (no cross-contamination)
- Fast app startup with cached data
- Foundation for audiobook progress tracking

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:46:30 +00:00
Your NameandClaude Opus 4.5 f34f194566 fix(ui): Distribute library tabs evenly with full-width indicator line
- Remove isScrollable to distribute tabs evenly across width
- Add full-width bottom line that extends under view button

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:51:24 +00:00
Your NameandClaude Opus 4.5 124aab0d1f fix(ui): Improve view toggle placement and fix artist grid images
- Artist view: Move sort/view buttons inline with section headers
- Library: Move view toggle to tab row (right side of tabs)
- Artist grid: Use ArtistAvatar widget for proper fallback image loading

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:37:38 +00:00
Your NameandClaude Opus 4.5 76e936dfde fix(ui): Improve view toggle UX and fix artist grid images
- Artist view: Right-align sort/view buttons, reduce gap from play buttons
- Artist view: Change year sort to ascending (oldest album first)
- Library: Move view toggle to app bar (next to player selector)
- Library: Fix artist grid - use AspectRatio for proper circles
- Library: Adjust grid aspect ratios for better spacing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:05:27 +00:00
Your NameandClaude Opus 4.5 0b48cb48f5 feat(ui): Add view mode toggles to artist details and library screens
- Artist details: Sort toggle (A-Z/year) and view mode (2-col, 3-col, list)
- Library Artists tab: View mode toggle (list, 2-col grid, 3-col grid)
- Library Albums tab: View mode toggle (2-col grid, 3-col grid, list)
- Library Playlists tab: View mode toggle (list, 2-col grid, 3-col grid)
- Persist all view preferences to SharedPreferences
- Add settings service methods for storing view preferences

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:45:37 +00:00
Your NameandClaude Opus 4.5 daece9370d fix(album): Parse year from top-level JSON field, not metadata
MA API returns year as a top-level field on Album, not inside metadata.
Now correctly parsing json['year'] in Album.fromJson.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:21:08 +00:00
Your NameandClaude Opus 4.5 655f939c26 fix(ui): Square album art, library/provider separation, year in names
- Fix album aspect ratio in artist view to be square (using AspectRatio)
- Restore "In Library" vs "From Providers" album sections
- Add year to album names throughout the app (Album Name (2023))
- Add year, nameWithYear, and inLibrary getters to Album model

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:05:18 +00:00
Your NameandClaude Opus 4.5 985b55a814 chore: Bump version to 2.0.0-beta for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:35:33 +00:00
Your NameandClaude Opus 4.5 222d0a1312 fix(switch): Always include builtin player in notification switch
When playing on SendSpin or another player, the builtin/local player
was filtered out of the switch list because it wasn't in playing/paused
state. Now the builtin player (ensemble_*) is always included so users
can switch back to local device from the notification.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:19:38 +00:00
Your NameandClaude Opus 4.5 8f30341d2e refactor(pcm): Improve state machine and reduce buffer latency
Key improvements:
- Add proper state machine with transitional states (pausing, resuming, stopping)
- Reduce feed threshold from 8000 to 5000 frames (~104ms vs ~166ms)
- Reduce max chunks per feed from 12 to 8 (~200ms vs ~300ms)
- Add error callbacks (PcmErrorCallback) for operation failures
- Fix silent stream errors - now pause and emit error on stream failure
- Add release() timeout to prevent hangs
- play()/pause()/stop() now return bool for success/failure

Bug fixes:
- Fix sendspin_service.dart ternary always returning 'synchronized'
- Replace fragile _isPausePending flag with proper state machine

UI enhancements:
- Add public getters for Sendspin status in provider
- Add audio format info section in expanded player (48kHz • Stereo • 16-bit PCM)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:26:19 +00:00
Your NameandClaude Opus 4.5 a9376ad807 fix(pcm): Reduce native buffer for instant pause (300ms vs 5-8s)
Key changes:
- Limit feeding to 12 chunks (~300ms) per feed cycle instead of entire buffer
- Re-enable release() on pause with Future.delayed to avoid UI block
- Re-initialize PCM player on resume since we release on pause
- Make next/previous track non-blocking with unawaited pause

This should fix the 5-8 second delay when pressing pause/skip.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:04:32 +00:00
Your NameandClaude Opus 4.5 efc94b2ecf Fix resume: Call play() on stream/start to reset paused state
The pause fix (keeping _isPausePending=true) worked but broke resume.
When MA sends stream/start after resume, _onAudioData was ignoring
all audio because _isPausePending was still true.

Fix: Call _pcmAudioPlayer.play() in _handleSendspinStreamStart to:
- Clear _isPausePending flag
- Set state from paused to playing
- Allow _onAudioData to process incoming audio

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:42:17 +00:00
Your NameandClaude Opus 4.5 b43e026cfa Fix pause freeze: Yield to event loop in feed loop
Attempt #8: The feed loop while() was starving the event loop.
Even with await, if native calls return quickly, the loop runs
continuously without letting UI events (like pause) be processed.

Added: await Future.delayed(Duration.zero) every 5 chunks to force
the event loop to process pending events like button taps.

Previous attempts focused on pause() but the real issue was the
feed loop monopolizing the isolate during playback.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:29:20 +00:00
Your NameandClaude Opus 4.5 4e238e3475 Make pause truly non-blocking with optimistic UI update
Previous fix didn't work - the issue was multiple blocking await calls
in the pause chain, not just the PCM player.

Changes:
- pausePlayer(): Use unawaited() for PCM pause and API calls
- pausePlayer(): Don't pause just_audio when in Sendspin mode
- pausePlayer(): Optimistically update _selectedPlayer state and notify
- playPauseSelectedPlayer(): Don't call refreshPlayers() after pause
- Player model: Add copyWith() method for optimistic updates

The key insight is that we don't need to wait for pause operations
to complete before updating the UI. The UI should update immediately
(optimistic) and the actual operations run in the background.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:24:19 +00:00
Your NameandClaude Opus 4.5 c9d44a147a Fix pause freeze: Keep _isPausePending true during pause, wait for feed
Root cause analysis (deep dive with 4 agents) revealed:
1. _isPausePending was cleared immediately in pause(), allowing
   new feeds to start before pause was complete
2. Auto-resume in _onAudioData could trigger right after pause
3. _onFeedRequested could start new feeds during pause transition
4. No waiting for in-flight feed operations

Changes:
- Keep _isPausePending = true until play() is called
- Wait for in-flight feed operations with 100ms timeout
- Remove auto-resume from _onAudioData (provider calls play())
- Add explicit state check in _onFeedRequested callback
- Ignore all audio data when paused (don't even buffer)

This prevents the race condition where the feed loop could be
active during pause, causing FFI calls to block the UI thread.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:04:06 +00:00
Your NameandClaude Opus 4.5 58c2d3c8a2 Don't call release() on pause - just stop feeding
The native flutter_pcm_sound release() was causing deadlocks.
New approach:
- pause() only sets Dart flags and clears buffer (no native calls)
- Native buffer is small (~170ms at 8000 frame threshold)
- Audio stops naturally as buffer drains
- Player stays initialized for instant resume
- play() just changes state and resumes feeding

This eliminates all native call race conditions on pause.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:22:37 +00:00
Your NameandClaude Opus 4.5 944257cadb Move all native calls to Future.microtask to fully detach from UI
pause() now sets only Dart state and returns immediately.
All native calls (setFeedCallback, release) run in a microtask
that executes after pause() returns, ensuring UI never blocks.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:21:37 +00:00
Your NameandClaude Opus 4.5 d7153e53ad Make pause non-blocking to prevent UI freeze
Instead of awaiting release() which can deadlock at native level,
now pause() returns immediately and schedules release asynchronously:

- pause() sets flags and clears buffer, returns instantly
- _releasePlayerAsync() handles cleanup in background
- 50ms delay before release to let feed loop exit
- UI thread is never blocked by native code

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:07:45 +00:00
Your NameandClaude Opus 4.5 94c4724e54 Fix pause freeze with proper Completer-based synchronization
The issue was that pause() used a polling loop that could time out
while feed() was still executing, causing a freeze/deadlock. Now:

- Added Completer to track when feed operations complete
- pause/stop/dispose await the Completer with 500ms timeout
- Clear feed callback before release() to prevent native triggers
- Added more _isPausePending checks in feed loop

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:54:22 +00:00
Your NameandClaude Opus 4.5 2dc317f4d4 fix(sendspin): Fix pause crash by preventing race condition
- Added _isPausePending flag to stop feed operations during pause/stop
- Feed loop now checks pause flag and exits early when set
- Pause/stop wait for in-progress feed operations before releasing player
- Prevents FlutterPcmSound.release() being called while feed() is executing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 10:21:36 +00:00
Your NameandClaude Opus 4.5 8be1935eb3 fix(sendspin): Instant pause/skip for builtin player
- PCM pause now releases flutter_pcm_sound to stop audio immediately
  instead of letting buffered audio drain (was causing 5-8 second delays)
- Play/resume re-initializes PCM player after release
- Added optimistic local pause/stop for builtin player - don't wait for
  MA WebSocket round-trip
- Next/Previous also do optimistic local stop for immediate feedback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:47:56 +00:00
Your NameandClaude Opus 4.5 6ee37310c0 fix(sendspin): Resume PCM playback when new audio arrives in paused state
When playing in radio mode, after the first track ends the PCM player
would be in paused state. When the next track started streaming,
audio data was received but never played because:
- _isStarted was true (from previous track)
- _state was paused (not ready or playing)

The fix automatically resumes playback when new audio data arrives
while in paused state, which handles track transitions in radio mode.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:21:18 +00:00
Your NameandClaude Opus 4.5 bc118cdd74 fix(playback): Improve handling of unavailable tracks
- Add pre-flight check for tracks with available provider mappings
- Skip tracks that lack available providers instead of sending them to MA
- Log detailed info about which tracks are playable vs unavailable
- Add smart retry logic that doesn't retry non-transient errors
- Improve user-facing error messages for unavailable content
- Reduce unnecessary retry delays for data-related failures

Fixes "No playable items found" errors when albums contain tracks
that don't have available streaming providers.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:02:20 +00:00
Your NameandClaude Opus 4.5 09fcbceb99 feat(sendspin): Add notification progress sync for PCM playback
- Add elapsed time tracking to PcmAudioPlayer based on bytes played
- Fix notification timer to detect Sendspin PCM players (not just ensemble_ prefix)
- Update stream handlers to manage position correctly:
  - Reset position on stream/start (new track)
  - Preserve position on stream/end (pause)
- Wire up 500ms notification updates using PCM elapsed time

This fixes the issue where notification showed 0:00 for entire track duration
during Sendspin PCM playback.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 08:01:57 +00:00
Your NameandClaude Opus 4.5 168b7d0fbd fix: Use _currentNotificationMetadata instead of non-existent currentTrack
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 07:25:52 +00:00
Your NameandClaude Opus 4.5 f5db2b1e9a fix: Add foreground service for Sendspin PCM streaming
- Add onStreamStart and onStreamEnd callbacks to SendspinService
- Activate audioHandler foreground service when PCM streaming starts
  to prevent Android from throttling background audio playback
- Stop PCM player and update notification when stream ends
- Reinitialize PCM player on stream start if needed for reliability

This fixes the "robotic slowed down" audio when screen turns off
by keeping the app in foreground service mode during PCM playback.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 07:15:33 +00:00
Your NameandClaude Opus 4.5 3f4856a4a6 fix: parse Sendspin binary audio frame header
Sendspin binary frames contain a 9-byte header before the actual PCM data:
- Byte 0: message type (4 = audio data)
- Bytes 1-8: timestamp (int64 microseconds for sync)
- Bytes 9+: actual PCM audio samples

Previously we were feeding the entire frame including the header to the
audio player, causing hissing/static instead of music.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:34:36 +00:00
Your Name 4e0f37a901 Fix LogLevel name conflict with pcm prefix 2025-12-15 22:18:17 +00:00
Your NameandClaude Opus 4.5 33921ada43 Switch from raw_sound to flutter_pcm_sound for PCM playback
The raw_sound package has an outdated Android build configuration
missing the namespace declaration required by newer AGP versions.

Switched to flutter_pcm_sound (^3.3.3) which is actively maintained
(last updated Oct 2025) and properly configured for modern Android.

Changes:
- Update pubspec.yaml to use flutter_pcm_sound
- Rewrite PcmAudioPlayer to use flutter_pcm_sound API:
  - FlutterPcmSound.setup() for initialization
  - FlutterPcmSound.feed(PcmArrayInt16) for audio data
  - Callback-based feeding with setFeedCallback
  - Convert Uint8List bytes to Int16 samples

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:12:36 +00:00
Your NameandClaude Opus 4.5 a3b024fa6a Add raw PCM audio playback support for Sendspin streaming
Implements raw PCM audio playback using the raw_sound package to
handle binary audio frames sent by Sendspin over WebSocket.

Changes:
- Add raw_sound dependency (^0.3.0+1) to pubspec.yaml
- Create PcmAudioPlayer service wrapper (lib/services/pcm_audio_player.dart)
  - Configurable audio format (defaults to Sendspin: 48kHz, stereo, 16-bit)
  - Stream-based audio feeding with buffering
  - Play/pause/stop controls
  - Periodic logging of playback stats
- Integrate PcmAudioPlayer into MusicAssistantProvider
  - Initialize PCM player when Sendspin connects
  - Connect audioDataStream to PCM player
  - Control PCM player on pause/stop commands
  - Clean up in disconnect/dispose

This completes the Sendspin audio streaming pipeline:
1. SendspinService receives binary WebSocket frames
2. Frames are emitted via audioDataStream
3. PcmAudioPlayer consumes and plays the raw PCM data

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:03:32 +00:00
Your NameandClaude Opus 4.5 b4a2d2df3b Fix binary audio frame handling in Sendspin WebSocket
The server sends binary WebSocket frames for PCM audio data, but
_handleMessage was trying to cast all messages to String for JSON
parsing. This caused hundreds of type cast errors:
"type '_Uint8ArrayView' is not a subtype of type 'String' in type cast"

Changes:
- Add dart:typed_data import for Uint8List
- Add SendspinAudioDataCallback typedef and onAudioData callback
- Add _audioDataController stream for raw PCM frames
- Add _isStreamingAudio and _audioFramesReceived state tracking
- Update _handleMessage to detect binary (List<int>) vs text (String)
- Add _handleBinaryAudioData method with periodic logging
- Add stream/start and stream/end message handlers
- Close _audioDataController in dispose()

This fixes the type cast errors and sets up infrastructure for PCM
audio playback (actual audio player integration is a separate task).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:52:16 +00:00
Your NameandClaude Opus 4.5 7f8a18929d feat: Refresh player list when new players are added
Added player_added event listener that refreshes the player list when
the Sendspin player gets registered. This fixes the issue where the
local phone player wasn't appearing in the device list.

The problem was timing: the player list was fetched BEFORE the Sendspin
player finished registering in Music Assistant. Now when the player_added
event is received, the list is automatically refreshed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:29:17 +00:00
Your NameandClaude Opus 4.5 86dd6dfede fix: Allow initial state messages during handshake phase
The initial client/state and client/time messages were being silently
dropped because they were sent while the connection state was still
'connecting' (before _waitForAck completed).

The _sendMessage method has a guard that blocks messages unless
state == connected OR allowDuringHandshake is true. The initial
messages need allowDuringHandshake since they must be sent immediately
after receiving server/hello, before the handshake completes.

Without this fix, the server times out after 5 seconds waiting for
the required initial state message.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 21:08:53 +00:00
Your NameandClaude Opus 4.5 62c63d691a fix: Use correct Sendspin protocol message types
Root cause: Server logs showed "ClientMessage has no subtype with
attribute 'type' equal to 'state'" - the client was using incorrect
message types that the aiosendspin library couldn't parse.

Changes:
- Fix state reporting: 'state' → 'client/state' with proper payload
- Fix heartbeat: 'ping' → 'client/time' with microsecond timestamp
- Fix disconnect: 'disconnect' → 'client/goodbye' with reason
- Send initial client/state immediately after server/hello (required)
- Handle server/time and group/update message types
- Reduce state report interval from 1s to 5s (was flooding server)

The server was disconnecting after 5 seconds because it couldn't parse
the malformed 'state' messages, triggering a "spec violation" timeout.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:55:10 +00:00
Your NameandClaude Opus 4.5 4df99ae8f1 fix: Allow sending hello message during handshake phase
The _sendMessage method was checking for connected state before sending,
but during handshake the state is still 'connecting'. This caused the
hello message to be silently dropped, preventing registration.

Added allowDuringHandshake flag to bypass the state check for the
initial hello message.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:07:34 +00:00
Your NameandClaude Opus 4.5 a3cd6911b8 fix: Use correct Sendspin protocol format for client/hello
The Sendspin protocol requires:
- Nested 'payload' object with client_id, name, version
- version as integer (not string)
- supported_roles array with versioned roles like 'player@v1'
- player_support object with supported_formats, buffer_capacity, supported_commands
- Handle 'server/hello' response type

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:54:15 +00:00
Your NameandClaude Opus 4.5 848c41f13d fix: Send client/hello message to register with Sendspin server
The Sendspin protocol requires the client to send a client/hello message
immediately after connecting. The server waits for this message before
registering the player. Previously we were waiting for the server to
speak first, causing a deadlock and connection timeout.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:33:43 +00:00
Your NameandClaude Opus 4.5 2efc6b5fe8 feat: Add Sendspin support for MA 2.7.0b20+
Implements Sendspin protocol support as a replacement for the deprecated
builtin_player API. This allows local playback on devices running Music
Assistant 2.7.0b20 or newer.

Key features:
- SendspinService for WebSocket connection management
- Smart connection fallback: external wss:// → local ws:// → WebRTC
- API methods for Sendspin connection info and WebRTC signaling
- Automatic detection when builtin_player API is unavailable
- Username-based player naming (same as builtin player)
- Persistent device ID to prevent ghost players

Connection strategy for external access:
1. If server is HTTPS, try wss://{server}/sendspin first
2. Fall back to local_ws_url from API
3. WebRTC fallback noted but not yet implemented

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:01:31 +00:00
Your Name 5109f8ce8c chore: bump version to 1.9.3+14 for release 2025-12-15 13:39:57 +00:00
Your NameandClaude Opus 4.5 71938034b6 Revert clip behavior and remove peek progress bar
- Reverted Stack clipBehavior back to Clip.none
- Removed peek progress bar (was causing app to freeze/go white)
- Main progress bar still slides with content

The peek progress bar and Clip.hardEdge were causing the app to become
unresponsive with a white overlay after swiping.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:23:59 +00:00
Your NameandClaude Opus 4.5 aaf2c7c91f Fix progress bar rendering during mini player swipe
- Refactored peek progress bar into helper method _buildPeekProgressBar
- Added null safety checks for peek player elapsed time
- Changed Stack clipBehavior to hardEdge to prevent overflow issues
- This should fix the white rendering issue during swipe

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:09:59 +00:00
Your NameandClaude Opus 4.5 15c634290e Make mini player progress bar slide with swipe gesture
- Progress bar now slides with album art, title, and artist during swipe
- Added peek player progress bar that slides in from opposite direction
- Peek progress shows the incoming player's playback position
- Non-playing peek players show no progress bar (as expected)
- Progress bar hidden during transition when peek content is available

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:55:39 +00:00
Your NameandClaude Opus 4.5 a4790ad054 Clarify Ensemble is an unofficial Music Assistant client
- Removed direct links to music-assistant.io
- Added clear disclaimer that Ensemble is unofficial and not affiliated
  with, endorsed by, or supported by the Music Assistant project
- Removed the "About Music Assistant" promotion section

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:46:46 +00:00
Your NameandClaude Opus 4.5 cff1c92ce2 Fix mini player assets not showing during swipe transitions
Bug: When swiping between players (especially between playing and
non-playing states), the album art, track title, and artist name
would disappear, leaving only the two-tone progress bar visible.

Root cause:
1. `_inTransition` flag was set without `setState`, causing rebuild
   timing issues with provider state changes
2. Main content visibility condition `!(_inTransition && t < 0.1)`
   would hide content during transitions, but if `_peekPlayer` was
   null or cleared prematurely, peek content wouldn't render either

Fix:
1. Wrap `_inTransition = true` in `setState` for proper rebuild timing
2. Add `_peekPlayer != null` to main content visibility condition as
   fallback - if peek content is unavailable during transition, main
   content will show instead of showing nothing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:31:35 +00:00
Your NameandClaude Opus 4.5 26356514ca fix: Remove provider icons and fix fullscreen art tap
- Remove provider icons feature from expanded player (caused overlay issues)
- Remove provider icons setting from settings screen
- Remove provider icon methods from settings service
- Fix fullscreen album art tap in expanded player by using onTapUp
  instead of onTap (resolves gesture arena conflict with vertical drag)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 07:40:29 +00:00
Your NameandClaude Opus 4.5 31801d261d fix: Fullscreen art tap handling and album view fullscreen
- Fix expanded player parent GestureDetector to not intercept taps when expanded
- Add fullscreen album art to album details screen
- Both support pinch-to-zoom and tap/swipe to dismiss

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:17:37 +00:00
Your NameandClaude Opus 4.5 ed4d876dee fix: UI fixes for mini player, fullscreen art, and provider icons
- Mini player progress bar now starts after album art (not hidden)
- Fullscreen album art tap detection fixed with opaque hit behavior
- Provider icons positioning simplified and error handling improved
- Album view padding increased to 164px for better clearance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 21:52:29 +00:00
Your NameandClaude Opus 4.5 575b7520d6 feat: UI improvements for expanded and mini player
- Album view: Add 8px more bottom padding for mini player clearance
- Expanded player: Fix favourite button to properly handle provider mappings
- Expanded player: Add fullscreen album art on tap (pinch to zoom supported)
- Expanded player: Add optional provider icons on album art (Spotify, Subsonic, etc.)
- Settings: Add "Provider Icons" toggle under new Player section
- Mini player: Add two-tone progress bar background showing playback position

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 21:13:33 +00:00
77 changed files with 20338 additions and 1687 deletions
+9
View File
@@ -4,10 +4,13 @@ on:
push:
branches:
- main
- master
- 'claude/**'
- 'feature/**'
pull_request:
branches:
- main
- master
workflow_dispatch:
jobs:
@@ -35,6 +38,12 @@ jobs:
- name: Get dependencies
run: flutter pub get
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
+24 -24
View File
@@ -3,7 +3,7 @@
---
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
<p>Stream your music library directly to your phone, or control playback on any connected speaker.</p>
</div>
@@ -11,6 +11,8 @@
## Disclaimer
**Ensemble is an unofficial, community-built mobile client for Music Assistant. It is not affiliated with, endorsed by, or supported by the Music Assistant project or its developers.**
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
---
@@ -42,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
- **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library
### Audiobooks
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
- **Series Support** - View audiobooks organized by series with collage cover art
- **Author Browsing** - Browse audiobooks by author
- **Chapter Navigation** - Jump between chapters with timestamp display
- **Progress Tracking** - Track your listening progress across sessions
- **Continue Listening** - Pick up where you left off
- **Mark as Finished/Unplayed** - Manage your reading progress
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
- **Caching** - Fast navigation with cached content
- **Offline Browsing** - Browse your cached library even when disconnected
- **Hero Animations** - Smooth transitions between screens
### Theming
@@ -55,15 +67,14 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots
<div align="center">
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
</div>
<div align="center">
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
<img src="assets/screenshots/6.png" alt="Settings" width="250">
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
</div>
## Download
@@ -92,21 +103,10 @@ Ensemble supports multiple authentication methods:
## Requirements
- Music Assistant server (v2.7.0 or later recommended)
- Music Assistant server (v2.7.0 beta 20 or later)
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
## About Music Assistant
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
With Ensemble, you can:
- **Stream locally** - Use your phone as a Music Assistant player
- **Control remotely** - Manage playback on smart speakers, Chromecast, Sonos, and more
Learn more: [music-assistant.io](https://music-assistant.io/)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
## License
+52
View File
@@ -0,0 +1,52 @@
# Search Improvements
Branch: `feature/search-improvements`
## Completed
- [x] Add playlists and audiobooks to search results
- Parse from MA API response
- Filter chips for each type
- Tile builders with navigation to detail screens
- [x] Unified Relevance-Based "All" Results
- Scoring algorithm implemented:
- Exact match: 100 points
- Starts with query: 80 points
- Contains query (word boundary): 60 points
- Contains query (anywhere): 40 points
- Fuzzy match: 20 points
- Bonus for library items (albums): +10 points
- Bonus for favorites: +5 points
- Secondary field matching (artist name in albums/tracks)
- Merged all results into single sorted list
- Type indicators in subtitle (e.g., "Artist • Album")
- [x] Smart Cross-Referencing
- Extracts unique artists from matched tracks/albums
- Filters to artists whose name contains query words (min 3 chars)
- Adds cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results
- [x] Past Searches Row
- Added SearchHistory table to database (schema v4)
- Saves successful searches (deduplicated, max 10)
- Displays as ActionChips in empty state
- Clicking chip performs that search
## Remaining Ideas (Lower Priority)
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
- **Voice search:** Add microphone button if platform supports speech recognition
- **Search suggestions:** Typeahead from library as user types
- **Quick actions:** Long-press result for play/queue options without navigating
- **Clear history in settings:** Add option to clear search history
## Files Modified
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
| `lib/services/database_service.dart` | Added search history methods |
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
| `lib/l10n/app_en.arb` | Added localization strings |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+255
View File
@@ -0,0 +1,255 @@
# Audiobooks Integration - Implementation Plan
## Overview
Add audiobook support to Ensemble with automatic user profile separation based on Music Assistant authentication. Includes local database for performance caching and per-user data isolation.
---
## Phase 1: Database Foundation
**Goal:** Add persistent local storage layer using Drift (SQLite)
### Tasks
- [ ] Add Drift package dependencies (`drift`, `drift_flutter`, `path_provider`)
- [ ] Create database class with schema version management
- [ ] Create `profiles` table:
```dart
class Profiles extends Table {
TextColumn get username => text()(); // MA username (primary key)
TextColumn get displayName => text().nullable()();
TextColumn get source => text()(); // 'ma_auth' or 'manual'
DateTimeColumn get createdAt => dateTime()();
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
}
```
- [ ] Create `recently_played` table:
```dart
class RecentlyPlayed extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get profileUsername => text().references(Profiles, #username)();
TextColumn get mediaId => text()();
TextColumn get mediaType => text()(); // 'track', 'album', 'artist', 'audiobook'
TextColumn get name => text()();
TextColumn get artist => text().nullable()();
TextColumn get imageUrl => text().nullable()();
DateTimeColumn get playedAt => dateTime()();
}
```
- [ ] Create `library_cache` table for albums/artists/audiobooks
- [ ] Generate Drift code (`dart run build_runner build`)
- [ ] Create `DatabaseService` singleton with initialization
### Acceptance Criteria
- Database initializes on app startup
- Schema migrations work correctly
- CRUD operations functional for all tables
---
## Phase 2: Profile System
**Goal:** Auto-create and manage user profiles based on MA authentication
### Tasks
- [ ] Create `ProfileService` class
- [ ] On MA login success: check if profile exists for username
- If not, create new profile with `displayName` from MA user info
- Set as active profile
- [ ] On manual name entry (no-auth): create/select profile by entered name
- [ ] Add `getCurrentProfile()` method
- [ ] Migrate existing `ownerName` to profile on first launch post-update
- [ ] Add profile indicator to settings screen (show current profile name)
### Acceptance Criteria
- Profile auto-created on first login
- Correct profile selected on subsequent logins
- Existing users migrated seamlessly
---
## Phase 3: Recently Played Migration
**Goal:** Move recently played from memory cache to database, scoped by profile
### Tasks
- [ ] Update `CacheService` to use database for recently played
- [ ] Scope all recently played queries by `profileUsername`
- [ ] Update home screen to load recently played from database
- [ ] Add `addRecentlyPlayed()` method that records profile + media info
- [ ] Limit recently played to last 50 items per profile
- [ ] Remove old memory-based recently played logic
### Acceptance Criteria
- Recently played persists across app restarts
- Each profile sees only their own recently played
- No cross-contamination between profiles
---
## Phase 4: Library Caching
**Goal:** Cache library data locally for instant UI and reduced API calls
### Tasks
- [ ] Create `SyncService` for background library synchronization
- [ ] On app launch: load from cache immediately, sync in background
- [ ] Store albums, artists in `library_cache` table
- [ ] Add `lastSynced` timestamp to track freshness
- [ ] Implement delta sync (only fetch changed items if MA supports it)
- [ ] Add pull-to-refresh that forces full sync
- [ ] Show subtle sync indicator when background sync in progress
### Acceptance Criteria
- Home screen loads instantly from cache
- Library tabs load instantly
- Background sync updates data without blocking UI
- Stale data refreshed within reasonable timeframe
---
## Phase 5: Scrollable Library Tabs
**Goal:** Make library tab row scrollable to accommodate more tabs
### Tasks
- [ ] Convert `TabBar` to scrollable variant (`isScrollable: true`)
- [ ] Adjust tab styling for scrollable layout
- [ ] Ensure indicator line spans correctly
- [ ] Test with varying number of tabs
- [ ] Add "Books" tab placeholder (disabled until Phase 6)
### Acceptance Criteria
- Tabs scroll horizontally when they exceed screen width
- Current tab indicator works correctly
- Smooth scrolling experience
---
## Phase 6: Audiobooks Tab - Browse by Author
**Goal:** Add Books tab with author → books hierarchy
### Tasks
- [ ] Add MA API method to fetch audiobooks: `getAudiobooks()`
- [ ] Add MA API method to fetch audiobook authors
- [ ] Create `AudiobookAuthor` model
- [ ] Create `Audiobook` model with fields:
- `id`, `name`, `authors`, `narrators`, `description`
- `duration`, `imageUrl`, `chapters`, `resumePosition`, `fullyPlayed`
- [ ] Create `BooksScreen` with author list view
- [ ] Tapping author expands to show their books
- [ ] Add to library tab bar
- [ ] Cache audiobooks in local database
### Acceptance Criteria
- Books tab shows list of authors
- Authors expandable to show their audiobooks
- Audiobook covers and titles display correctly
- Data cached locally for fast subsequent loads
---
## Phase 7: Audiobook Detail Screen
**Goal:** Full audiobook detail view with metadata and chapters
### Tasks
- [ ] Create `AudiobookDetailScreen`
- [ ] Display: cover art, title, author(s), narrator(s)
- [ ] Display: description (expandable if long)
- [ ] Display: total duration, progress indicator
- [ ] Chapter list with:
- Chapter title
- Duration
- Played indicator (based on resume position)
- [ ] Tapping chapter starts playback from that chapter
- [ ] "Resume" button that continues from `resume_position_ms`
- [ ] "Start Over" option
### Acceptance Criteria
- All metadata displays correctly
- Chapters show with accurate durations
- Resume position reflected in UI
- Can start playback from any chapter
---
## Phase 8: Audiobook Playback Integration
**Goal:** Proper audiobook playback with seek and resume
### Tasks
- [ ] Implement `playAudiobook(bookId, startPosition?)` method
- [ ] Use `queueCommandSeek()` to resume at correct position
- [ ] Update now playing UI to show:
- Book title instead of track
- Current chapter name
- Chapter progress (not just total progress)
- [ ] Add skip forward/back buttons (30 sec for audiobooks?)
- [ ] Add chapter skip buttons (next/previous chapter)
- [ ] Record to recently played when audiobook starts (with 'audiobook' type)
### Acceptance Criteria
- Audiobooks resume from correct position
- Chapter information shows in now playing
- Skip controls work correctly
- Recently played records audiobook plays
---
## Phase 9: Polish & Edge Cases
**Goal:** Handle edge cases and improve UX
### Tasks
- [ ] Handle audiobooks with no chapters gracefully
- [ ] Handle very long audiobooks (20+ hours)
- [ ] Add "Mark as Finished" option
- [ ] Add "Mark as Unplayed" option
- [ ] Ensure offline browsing works for cached audiobooks
- [ ] Test with various audiobook lengths and chapter counts
- [ ] Performance testing with large audiobook libraries
### Acceptance Criteria
- No crashes on edge cases
- Smooth performance
- All user actions have appropriate feedback
---
## Future Enhancements (Out of Scope)
- Sleep timer
- Playback speed control
- Series support (requires MA provider update)
- Bookmarks
- Listening statistics
- Download for offline playback
---
## Technical Notes
### Database Choice: Drift
- SQLite wrapper with type-safe queries
- Good for relational data (profiles → recently_played)
- Mature, well-documented
- Supports migrations
### MA API Endpoints Used
- `music/audiobooks` - List audiobooks
- `music/audiobook/{id}` - Get audiobook details
- `player_queues/play_media` - Start playback
- `player_queues/seek` - Seek to position
- `music/in_progress_items` - Get items with resume positions
### Profile Data Flow
```
MA Login → Get user_info → Check/Create Profile → Set Active → Load Profile Data
```
### Recently Played Flow
```
Play Media → Check Active Profile → Insert RecentlyPlayed Row → Update UI
```
+5
View File
@@ -0,0 +1,5 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false
output-class: S
+12
View File
@@ -13,4 +13,16 @@ class HeroTags {
/// Artist image hero tag prefix
static const String artistImage = 'artist_image_';
/// Audiobook cover hero tag prefix
static const String audiobookCover = 'audiobook_cover_';
/// Audiobook title hero tag prefix
static const String audiobookTitle = 'audiobook_title_';
/// Audiobook author name hero tag prefix (for navigating to author screen)
static const String audiobookAuthorName = 'audiobook_author_name_';
/// Audiobook author image hero tag prefix
static const String authorImage = 'author_image_';
}
+4 -2
View File
@@ -8,8 +8,10 @@ class Timings {
/// Player state polling interval (for selected player updates)
static const Duration playerPollingInterval = Duration(seconds: 5);
/// Local player state report interval (for seek bar smoothness)
static const Duration localPlayerReportInterval = Duration(seconds: 1);
/// Local player state report interval
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
static const Duration localPlayerReportInterval = Duration(seconds: 5);
/// Player list cache duration before refresh
static const Duration playersCacheDuration = Duration(minutes: 5);
+635
View File
@@ -0,0 +1,635 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'database.g.dart';
/// User profiles - auto-created from MA login or manual entry
class Profiles extends Table {
/// MA username or manually entered name (primary key)
TextColumn get username => text()();
/// Display name from MA or same as username
TextColumn get displayName => text().nullable()();
/// How the profile was created: 'ma_auth' or 'manual'
TextColumn get source => text().withDefault(const Constant('ma_auth'))();
/// When the profile was created
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
/// Whether this is the currently active profile
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {username};
}
/// Recently played items - scoped by profile
class RecentlyPlayed extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
/// Profile this belongs to
TextColumn get profileUsername => text().references(Profiles, #username)();
/// Media item ID from Music Assistant
TextColumn get mediaId => text()();
/// Type: 'track', 'album', 'artist', 'playlist', 'audiobook'
TextColumn get mediaType => text()();
/// Display name of the item
TextColumn get name => text()();
/// Artist/author name (for display)
TextColumn get artistName => text().nullable()();
/// Image URL for the item
TextColumn get imageUrl => text().nullable()();
/// Additional metadata as JSON (e.g., album name for tracks)
TextColumn get metadata => text().nullable()();
/// When this was played
DateTimeColumn get playedAt => dateTime()();
}
/// Cached library items for fast startup
class LibraryCache extends Table {
/// Composite key: provider + item_id
TextColumn get cacheKey => text()();
/// Type: 'album', 'artist', 'track', 'playlist', 'audiobook', 'audiobook_author'
TextColumn get itemType => text()();
/// The item ID from Music Assistant
TextColumn get itemId => text()();
/// Serialized item data as JSON
TextColumn get data => text()();
/// When this was last synced from MA
DateTimeColumn get lastSynced => dateTime()();
/// Whether this item was deleted on the server
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {cacheKey};
}
/// Sync metadata - tracks last sync times per data type
class SyncMetadata extends Table {
/// What was synced: 'albums', 'artists', 'audiobooks', etc.
TextColumn get syncType => text()();
/// When the last successful sync completed
DateTimeColumn get lastSyncedAt => dateTime()();
/// Number of items synced
IntColumn get itemCount => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {syncType};
}
/// Persisted playback state - survives app restarts and disconnects
class PlaybackState extends Table {
/// Always 'current' - single row table
TextColumn get id => text().withDefault(const Constant('current'))();
/// Selected player ID
TextColumn get playerId => text().nullable()();
/// Selected player name (for display if player unavailable)
TextColumn get playerName => text().nullable()();
/// Current track as JSON
TextColumn get currentTrackJson => text().nullable()();
/// Current position in seconds
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
/// Whether playback was active
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
/// When this state was saved
DateTimeColumn get savedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Cached player list - for instant display on app resume
class CachedPlayers extends Table {
/// Player ID from Music Assistant
TextColumn get playerId => text()();
/// Player data as JSON
TextColumn get playerJson => text()();
/// Current track for this player as JSON (for mini player display)
TextColumn get currentTrackJson => text().nullable()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {playerId};
}
/// Cached queue items for selected player
class CachedQueue extends Table {
/// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()();
/// Player ID this queue belongs to
TextColumn get playerId => text()();
/// Queue item as JSON
TextColumn get itemJson => text()();
/// Position in queue
IntColumn get position => integer()();
}
/// Cached home row data for instant display on startup
class HomeRowCache extends Table {
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
TextColumn get rowType => text()();
/// Serialized list of items as JSON array
TextColumn get itemsJson => text()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {rowType};
}
/// Search history for quick access to recent searches
class SearchHistory extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
/// The search query
TextColumn get query => text()();
/// When the search was performed
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 4;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
if (from < 2) {
await m.createTable(playbackState);
await m.createTable(cachedPlayers);
await m.createTable(cachedQueue);
}
// Migration from v2 to v3: Add home row cache table
if (from < 3) {
await m.createTable(homeRowCache);
}
// Migration from v3 to v4: Add search history table
if (from < 4) {
await m.createTable(searchHistory);
}
},
);
}
static QueryExecutor _openConnection() {
return driftDatabase(name: 'ensemble_db');
}
// ============================================
// Profile Operations
// ============================================
/// Get the currently active profile
Future<Profile?> getActiveProfile() async {
return (select(profiles)..where((p) => p.isActive.equals(true)))
.getSingleOrNull();
}
/// Get a profile by username
Future<Profile?> getProfile(String username) async {
return (select(profiles)..where((p) => p.username.equals(username)))
.getSingleOrNull();
}
/// Create or update a profile and set it as active
Future<Profile> setActiveProfile({
required String username,
String? displayName,
required String source,
}) async {
return transaction(() async {
// Deactivate all profiles
await (update(profiles)..where((p) => p.isActive.equals(true)))
.write(const ProfilesCompanion(isActive: Value(false)));
// Check if profile exists
final existing = await getProfile(username);
if (existing != null) {
// Update and activate existing profile
await (update(profiles)..where((p) => p.username.equals(username)))
.write(ProfilesCompanion(
displayName: Value(displayName ?? existing.displayName),
isActive: const Value(true),
));
} else {
// Create new profile
await into(profiles).insert(ProfilesCompanion.insert(
username: username,
displayName: Value(displayName),
source: Value(source),
isActive: const Value(true),
));
}
return (await getProfile(username))!;
});
}
/// Get all profiles
Future<List<Profile>> getAllProfiles() async {
return select(profiles).get();
}
// ============================================
// Recently Played Operations
// ============================================
/// Add an item to recently played for the active profile
Future<void> addRecentlyPlayed({
required String mediaId,
required String mediaType,
required String name,
String? artistName,
String? imageUrl,
String? metadata,
}) async {
final profile = await getActiveProfile();
if (profile == null) return;
await transaction(() async {
// Remove existing entry for this item (to move it to top)
await (delete(recentlyPlayed)
..where((r) => r.profileUsername.equals(profile.username))
..where((r) => r.mediaId.equals(mediaId))
..where((r) => r.mediaType.equals(mediaType)))
.go();
// Add new entry
await into(recentlyPlayed).insert(RecentlyPlayedCompanion.insert(
profileUsername: profile.username,
mediaId: mediaId,
mediaType: mediaType,
name: name,
artistName: Value(artistName),
imageUrl: Value(imageUrl),
metadata: Value(metadata),
playedAt: DateTime.now(),
));
// Keep only last 50 items per profile
final allItems = await (select(recentlyPlayed)
..where((r) => r.profileUsername.equals(profile.username))
..orderBy([(r) => OrderingTerm.desc(r.playedAt)]))
.get();
if (allItems.length > 50) {
final toDelete = allItems.sublist(50);
for (final item in toDelete) {
await (delete(recentlyPlayed)..where((r) => r.id.equals(item.id))).go();
}
}
});
}
/// Get recently played items for the active profile
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) async {
final profile = await getActiveProfile();
if (profile == null) return [];
return (select(recentlyPlayed)
..where((r) => r.profileUsername.equals(profile.username))
..orderBy([(r) => OrderingTerm.desc(r.playedAt)])
..limit(limit))
.get();
}
/// Clear recently played for the active profile
Future<void> clearRecentlyPlayed() async {
final profile = await getActiveProfile();
if (profile == null) return;
await (delete(recentlyPlayed)
..where((r) => r.profileUsername.equals(profile.username)))
.go();
}
// ============================================
// Library Cache Operations
// ============================================
/// Cache a library item
Future<void> cacheItem({
required String itemType,
required String itemId,
required String data,
}) async {
final cacheKey = '${itemType}_$itemId';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
}
/// Get cached items by type
Future<List<LibraryCacheData>> getCachedItems(String itemType) async {
return (select(libraryCache)
..where((c) => c.itemType.equals(itemType))
..where((c) => c.isDeleted.equals(false)))
.get();
}
/// Get a single cached item
Future<LibraryCacheData?> getCachedItem(String itemType, String itemId) async {
final cacheKey = '${itemType}_$itemId';
return (select(libraryCache)..where((c) => c.cacheKey.equals(cacheKey)))
.getSingleOrNull();
}
/// Mark items as deleted (soft delete)
Future<void> markItemsDeleted(String itemType, List<String> itemIds) async {
for (final itemId in itemIds) {
final cacheKey = '${itemType}_$itemId';
await (update(libraryCache)..where((c) => c.cacheKey.equals(cacheKey)))
.write(const LibraryCacheCompanion(isDeleted: Value(true)));
}
}
/// Clear all cached items of a type
Future<void> clearCache(String itemType) async {
await (delete(libraryCache)..where((c) => c.itemType.equals(itemType))).go();
}
/// Clear entire cache
Future<void> clearAllCache() async {
await delete(libraryCache).go();
}
// ============================================
// Sync Metadata Operations
// ============================================
/// Update sync metadata for a type
Future<void> updateSyncMetadata(String syncType, int itemCount) async {
await into(syncMetadata).insertOnConflictUpdate(SyncMetadataCompanion.insert(
syncType: syncType,
lastSyncedAt: DateTime.now(),
itemCount: Value(itemCount),
));
}
/// Get last sync time for a type
Future<DateTime?> getLastSyncTime(String syncType) async {
final meta = await (select(syncMetadata)
..where((s) => s.syncType.equals(syncType)))
.getSingleOrNull();
return meta?.lastSyncedAt;
}
/// Check if sync is needed (older than duration)
Future<bool> needsSync(String syncType, Duration maxAge) async {
final lastSync = await getLastSyncTime(syncType);
if (lastSync == null) return true;
return DateTime.now().difference(lastSync) > maxAge;
}
// ============================================
// Playback State Operations
// ============================================
/// Save current playback state
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) async {
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
id: const Value('current'),
playerId: Value(playerId),
playerName: Value(playerName),
currentTrackJson: Value(currentTrackJson),
positionSeconds: Value(positionSeconds),
isPlaying: Value(isPlaying),
savedAt: DateTime.now(),
));
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() async {
return (select(playbackState)..where((p) => p.id.equals('current')))
.getSingleOrNull();
}
/// Clear playback state
Future<void> clearPlaybackState() async {
await delete(playbackState).go();
}
// ============================================
// Cached Players Operations
// ============================================
/// Cache a player with its current track
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) async {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: Value(currentTrackJson),
lastUpdated: DateTime.now(),
));
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
await transaction(() async {
for (final player in players) {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: player['playerId'] as String,
playerJson: player['playerJson'] as String,
currentTrackJson: Value(player['currentTrackJson'] as String?),
lastUpdated: DateTime.now(),
));
}
});
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() async {
return (select(cachedPlayers)
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
.get();
}
/// Get a specific cached player
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.getSingleOrNull();
}
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.write(CachedPlayersCompanion(
currentTrackJson: Value(trackJson),
lastUpdated: Value(DateTime.now()),
));
}
/// Clear all cached players
Future<void> clearCachedPlayers() async {
await delete(cachedPlayers).go();
}
// ============================================
// Cached Queue Operations
// ============================================
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
await transaction(() async {
// Clear existing queue for this player
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
// Insert new queue items
for (var i = 0; i < itemJsonList.length; i++) {
await into(cachedQueue).insert(CachedQueueCompanion.insert(
playerId: playerId,
itemJson: itemJsonList[i],
position: i,
));
}
});
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
return (select(cachedQueue)
..where((q) => q.playerId.equals(playerId))
..orderBy([(q) => OrderingTerm.asc(q.position)]))
.get();
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) async {
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
}
/// Clear all cached queues
Future<void> clearAllCachedQueues() async {
await delete(cachedQueue).go();
}
// ============================================
// Home Row Cache Operations
// ============================================
/// Save home row data
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
await into(homeRowCache).insertOnConflictUpdate(
HomeRowCacheCompanion(
rowType: Value(rowType),
itemsJson: Value(itemsJson),
lastUpdated: Value(DateTime.now()),
),
);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
.getSingleOrNull();
}
/// Clear all home row cache
Future<void> clearHomeRowCache() async {
await delete(homeRowCache).go();
}
// ============================================
// Search History Operations
// ============================================
/// Save a search query to history (deduplicates and limits to 10)
Future<void> saveSearchQuery(String query) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) return;
await transaction(() async {
// Delete existing entry with same query (case-insensitive)
await (delete(searchHistory)
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
.go();
// Insert the new search
await into(searchHistory).insert(SearchHistoryCompanion.insert(
query: trimmedQuery,
searchedAt: DateTime.now(),
));
// Keep only the 10 most recent searches
final allSearches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
.get();
if (allSearches.length > 10) {
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
}
});
}
/// Get recent search queries (up to 10, most recent first)
Future<List<String>> getRecentSearches() async {
final searches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
..limit(10))
.get();
return searches.map((s) => s.query).toList();
}
/// Clear all search history
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
}
+291
View File
@@ -0,0 +1,291 @@
{
"@@locale": "de",
"appTitle": "Ensemble",
"connecting": "Verbinde...",
"connected": "Verbunden",
"disconnected": "Getrennt",
"serverAddress": "Serveradresse",
"serverAddressHint": "z.B. music.example.com oder 192.168.1.100",
"yourName": "Dein Name",
"yourFirstName": "Dein Vorname",
"portOptional": "Port (Optional)",
"portHint": "z.B. 8095 oder leer lassen",
"portDescription": "Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.",
"username": "Benutzername",
"password": "Passwort",
"detectAndConnect": "Erkennen & Verbinden",
"connect": "Verbinden",
"disconnect": "Trennen",
"authServerUrlOptional": "Auth-Server URL (Optional)",
"authServerUrlHint": "z.B. auth.example.com (wenn abweichend vom Server)",
"authServerUrlDescription": "Leer lassen, wenn Authentifizierung auf demselben Server",
"detectedAuthType": "Erkannt: {authType}",
"serverRequiresAuth": "Dieser Server erfordert Authentifizierung",
"tailscaleVpnConnection": "Tailscale VPN-Verbindung",
"unencryptedConnection": "Unverschlusselte Verbindung",
"usingHttpOverTailscale": "HTTP uber Tailscale (verschlusselter Tunnel)",
"httpsFailedUsingHttp": "HTTPS fehlgeschlagen, verwende HTTP",
"httpNotEncrypted": "HTTP-Verbindung - Daten sind nicht verschlusselt",
"pleaseEnterServerAddress": "Bitte gib deine Music Assistant Serveradresse ein",
"pleaseEnterName": "Bitte gib deinen Namen ein",
"pleaseEnterValidPort": "Bitte gib eine gultige Portnummer ein (1-65535)",
"pleaseEnterCredentials": "Bitte gib Benutzername und Passwort ein",
"authFailed": "Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"maLoginFailed": "Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"connectionFailed": "Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.",
"detectingAuth": "Erkenne Authentifizierung...",
"cannotDetermineAuth": "Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.",
"noAuthentication": "Keine Authentifizierung",
"httpBasicAuth": "HTTP Basic Auth",
"authelia": "Authelia",
"musicAssistantLogin": "Music Assistant Anmeldung",
"pressBackToMinimize": "Erneut drucken zum Minimieren",
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
"favoriteAlbums": "Lieblingsalben",
"favoriteArtists": "Lieblingskünstler",
"favoriteTracks": "Lieblingssongs",
"searchMusic": "Musik suchen...",
"searchForContent": "Nach Künstlern, Alben oder Songs suchen",
"retry": "Wiederholen",
"all": "Alle",
"artists": "Künstler",
"albums": "Alben",
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
"playerQueue": "{playerName} Warteschlange",
"noPlayerSelected": "Kein Player ausgewählt",
"undo": "Rückgängig",
"removedItem": "{itemName} entfernt",
"settings": "Einstellungen",
"debugLogs": "Debug-Protokolle",
"themeMode": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"pullToRefresh": "Ziehe zum Aktualisieren der Bibliothek",
"viewDebugLogs": "Debug-Protokolle anzeigen",
"viewAllPlayers": "Alle Player anzeigen",
"copyLogs": "Protokolle kopieren",
"clearLogs": "Protokolle löschen",
"copyList": "Liste kopieren",
"close": "Schließen",
"allPlayersCount": "Alle Player ({count})",
"errorLoadingPlayers": "Fehler beim Laden der Player: {error}",
"logsCopied": "Protokolle in Zwischenablage kopiert",
"logsCleared": "Protokolle gelöscht",
"playerListCopied": "Playerliste in Zwischenablage kopiert!",
"noLogsYet": "Noch keine Protokolle",
"infoPlus": "Info+",
"warnings": "Warnungen",
"errors": "Fehler",
"showingEntries": "Zeige {filtered} von {total} Einträgen",
"thisDevice": "Dieses Gerät",
"ghostPlayer": "Geist-Player (Duplikat)",
"unavailableCorrupt": "Nicht verfügbar/Beschädigt",
"playerId": "ID: {id}",
"playerInfo": "Verfügbar: {available} | Anbieter: {provider}",
"shareBugReport": "Fehlerbericht teilen",
"moreOptions": "Weitere Optionen",
"failedToUpdateFavorite": "Favorit konnte nicht aktualisiert werden: {error}",
"noPlayersAvailable": "Keine Player verfügbar",
"albumAddedToQueue": "Album zur Warteschlange hinzugefügt",
"tracksAddedToQueue": "Songs zur Warteschlange hinzugefügt",
"play": "Abspielen",
"noPlayersFound": "Keine Player gefunden",
"startingRadio": "Starte {name} Radio",
"failedToStartRadio": "Radio konnte nicht gestartet werden: {error}",
"startingRadioOnPlayer": "Starte {name} Radio auf {playerName}",
"addedRadioToQueue": "{name} Radio zur Warteschlange hinzugefügt",
"failedToAddToQueue": "Konnte nicht zur Warteschlange hinzufügen: {error}",
"radio": "Radio",
"inLibrary": "In Bibliothek",
"noAlbumsFound": "Keine Alben gefunden",
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"nowPlaying": "Wird gespielt",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
"markedAsUnplayed": "{name} als ungespielt markiert",
"failedToMarkUnplayed": "Konnte nicht als ungespielt markieren: {error}",
"failedToPlay": "Abspielen fehlgeschlagen: {error}",
"markAsFinished": "Als beendet markieren",
"markAsUnplayed": "Als ungespielt markieren",
"byAuthor": "Von {author}",
"audiobookCount": "{count} Hörbuch/Hörbücher",
"loading": "Laden...",
"bookCount": "{count} Buch/Bücher",
"books": "Bücher",
"noFavoriteAudiobooks": "Keine Lieblingshörbücher",
"tapHeartAudiobook": "Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen",
"noAudiobooks": "Keine Hörbücher",
"addAudiobooksHint": "Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen",
"noFavoriteArtists": "Keine Lieblingskünstler",
"tapHeartArtist": "Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen",
"noFavoriteAlbums": "Keine Lieblingsalben",
"tapHeartAlbum": "Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen",
"noFavoritePlaylists": "Keine Lieblings-Wiedergabelisten",
"tapHeartPlaylist": "Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen",
"noFavoriteTracks": "Keine Lieblingssongs",
"longPressTrackHint": "Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen",
"loadSeries": "Serien laden",
"notConnected": "Nicht mit Music Assistant verbunden",
"notConnectedTitle": "Nicht verbunden",
"connectHint": "Verbinde dich mit deinem Music Assistant Server, um Musik zu hören",
"configureServer": "Server konfigurieren",
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
"debugConsole": "Debug-Konsole",
"copy": "Kopieren",
"clear": "Löschen",
"noLogsToCopy": "Keine Protokolle zum Kopieren",
"noDebugLogsYet": "Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.",
"showDebug": "Debug anzeigen",
"hideDebug": "Debug ausblenden",
"chapters": "Kapitel",
"noChapters": "Keine Kapitel",
"noChapterInfo": "Dieses Hörbuch hat keine Kapitelinformationen",
"errorSeeking": "Fehler beim Spulen: {error}",
"error": "Fehler: {error}",
"home": "Start",
"library": "Bibliothek",
"homeScreen": "Startbildschirm",
"metadataApis": "Metadaten-APIs",
"theme": "Design",
"materialYou": "Material You",
"adaptiveTheme": "Adaptives Design",
"language": "Sprache",
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
"sortByYear": "Nach Jahr sortieren",
"sortBySeriesOrder": "Nach Serienreihenfolge sortieren",
"listView": "Listenansicht",
"gridView": "Rasteransicht",
"noBooksInSeries": "Keine Bücher in dieser Serie gefunden",
"artist": "Künstler",
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
"showFavoriteAlbums": "Lieblingsalben anzeigen",
"showFavoriteArtists": "Lieblingskünstler anzeigen",
"showFavoriteTracks": "Lieblingssongs anzeigen",
"extractColorsFromArtwork": "Farben aus Album- und Künstlercover extrahieren",
"chooseHomeScreenRows": "Wähle welche Zeilen auf dem Startbildschirm angezeigt werden",
"addedToFavorites": "Zu Favoriten hinzugefügt",
"removedFromFavorites": "Aus Favoriten entfernt",
"unknown": "Unbekannt",
"noUpcomingTracks": "Keine weiteren Songs",
"showAll": "Alle anzeigen",
"showFavoritesOnly": "Nur Favoriten anzeigen",
"changeView": "Ansicht ändern",
"authors": "Autoren",
"series": "Serien",
"shows": "Sendungen",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast-Unterstützung kommt bald",
"playlist": "Wiedergabeliste",
"connectionError": "Verbindungsfehler",
"twoColumnGrid": "2-Spalten-Raster",
"threeColumnGrid": "3-Spalten-Raster",
"fromProviders": "Von Anbietern",
"resume": "Fortsetzen",
"about": "Über",
"inProgress": "In Bearbeitung",
"narratedBy": "Gesprochen von {narrators}",
"unknownNarrator": "Unbekannter Sprecher",
"unknownAuthor": "Unbekannter Autor",
"loadingChapters": "Lade Kapitel...",
"noChapterInfoAvailable": "Keine Kapitelinformationen verfügbar",
"percentComplete": "{percent}% abgeschlossen",
"theAudioDbApiKey": "TheAudioDB API-Schlüssel",
"theAudioDbApiKeyHint": "\"2\" für kostenlosen Zugang oder Premium-Schlüssel",
"audiobookLibraries": "Hörbuch-Bibliotheken",
"chooseAudiobookLibraries": "Wähle welche Audiobookshelf-Bibliotheken einbezogen werden",
"unknownLibrary": "Unbekannte Bibliothek",
"noSeriesFound": "Keine Serien gefunden",
"ensembleBugReport": "Ensemble Fehlerbericht",
"byOwner": "Von {owner}",
"noSeriesAvailable": "Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.",
"pullToLoadSeries": "Ziehe nach unten um Serien\nvon Music Assistant zu laden",
"search": "Suche",
"metadataApisDescription": "Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.",
"lastFmApiKey": "Last.fm API-Schlüssel",
"lastFmApiKeyHint": "Kostenlosen Schlüssel auf last.fm/api holen",
"swipeToSwitchDevice": "Wischen um Gerät zu wechseln",
"chapterNumber": "Kapitel {number}",
"pcmAudio": "PCM-Audio",
"playOn": "Abspielen auf...",
"addAlbumToQueueOn": "Album zur Warteschlange hinzufügen auf...",
"addToQueueOn": "Zur Warteschlange hinzufügen auf...",
"startRadioOn": "{name} Radio starten auf...",
"book": "Buch",
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"accentColor": "Akzentfarbe",
"players": "Geräte",
"preferLocalPlayer": "Lokales Gerät bevorzugen",
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
"smartSortPlayers": "Intelligente Sortierung",
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerStateExternalSource": "Externe Quelle",
"playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
"hintsAndTips": "Tipps & Hinweise",
"showHints": "Hinweise anzeigen",
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gerät gedrückt halten zum Synchronisieren"
}
+1305
View File
File diff suppressed because it is too large Load Diff
+487
View File
@@ -0,0 +1,487 @@
{
"@@locale": "es",
"appTitle": "Ensemble",
"connecting": "Conectando...",
"connected": "Conectado",
"disconnected": "Desconectado",
"serverAddress": "Dirección del servidor",
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
"yourName": "Tu nombre",
"yourFirstName": "Tu nombre de pila",
"portOptional": "Puerto (Opcional)",
"portHint": "ej., 8095 o dejar en blanco",
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
"username": "Usuario",
"password": "Contraseña",
"detectAndConnect": "Detectar y conectar",
"connect": "Conectar",
"disconnect": "Desconectar",
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
"detectedAuthType": "Detectado: {authType}",
"@detectedAuthType": {
"placeholders": {
"authType": {
"type": "String"
}
}
},
"serverRequiresAuth": "Este servidor requiere autenticación",
"tailscaleVpnConnection": "Conexión VPN Tailscale",
"unencryptedConnection": "Conexión sin cifrar",
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
"pleaseEnterName": "Por favor, introduce tu nombre",
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
"detectingAuth": "Detectando autenticación...",
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
"noAuthentication": "Sin autenticación",
"httpBasicAuth": "Autenticación HTTP Basic",
"authelia": "Authelia",
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
"favoriteAlbums": "Álbumes favoritos",
"favoriteArtists": "Artistas favoritos",
"favoriteTracks": "Canciones favoritas",
"searchMusic": "Buscar música...",
"searchForContent": "Buscar artistas, álbumes o canciones",
"retry": "Reintentar",
"all": "Todo",
"artists": "Artistas",
"albums": "Álbumes",
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
"@playerQueue": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"noPlayerSelected": "Ningún reproductor seleccionado",
"undo": "Deshacer",
"removedItem": "Eliminado {itemName}",
"@removedItem": {
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"settings": "Ajustes",
"debugLogs": "Registros de depuración",
"themeMode": "Modo de tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
"viewDebugLogs": "Ver registros de depuración",
"viewAllPlayers": "Ver todos los reproductores",
"copyLogs": "Copiar registros",
"clearLogs": "Borrar registros",
"copyList": "Copiar lista",
"close": "Cerrar",
"allPlayersCount": "Todos los reproductores ({count})",
"@allPlayersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
"@errorLoadingPlayers": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"logsCopied": "Registros copiados al portapapeles",
"logsCleared": "Registros borrados",
"playerListCopied": "Lista de reproductores copiada al portapapeles",
"noLogsYet": "Aún no hay registros",
"infoPlus": "Info+",
"warnings": "Advertencias",
"errors": "Errores",
"showingEntries": "Mostrando {filtered} de {total} entradas",
"@showingEntries": {
"placeholders": {
"filtered": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"thisDevice": "Este dispositivo",
"ghostPlayer": "Reproductor fantasma (duplicado)",
"unavailableCorrupt": "No disponible/Corrupto",
"playerId": "ID: {id}",
"@playerId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
"@playerInfo": {
"placeholders": {
"available": {
"type": "String"
},
"provider": {
"type": "String"
}
}
},
"shareBugReport": "Compartir informe de errores",
"moreOptions": "Más opciones",
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
"@failedToUpdateFavorite": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noPlayersAvailable": "No hay reproductores disponibles",
"albumAddedToQueue": "Álbum añadido a la cola",
"tracksAddedToQueue": "Canciones añadidas a la cola",
"play": "Reproducir",
"noPlayersFound": "No se encontraron reproductores",
"startingRadio": "Iniciando radio de {name}",
"@startingRadio": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToStartRadio": "Error al iniciar radio: {error}",
"@failedToStartRadio": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
"@startingRadioOnPlayer": {
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"addedRadioToQueue": "Radio de {name} añadida a la cola",
"@addedRadioToQueue": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToAddToQueue": "Error al añadir a la cola: {error}",
"@failedToAddToQueue": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"radio": "Radio",
"inLibrary": "En la biblioteca",
"noAlbumsFound": "No se encontraron álbumes",
"playing": "Reproduciendo {name}",
"@playing": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"playingTrack": "Reproduciendo canción",
"nowPlaying": "Reproduciendo ahora",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkFinished": "Error al marcar como terminado: {error}",
"@failedToMarkFinished": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markedAsUnplayed": "{name} marcado como no reproducido",
"@markedAsUnplayed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
"@failedToMarkUnplayed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlay": "Error al reproducir: {error}",
"@failedToPlay": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markAsFinished": "Marcar como terminado",
"markAsUnplayed": "Marcar como no reproducido",
"byAuthor": "Por {author}",
"@byAuthor": {
"placeholders": {
"author": {
"type": "String"
}
}
},
"audiobookCount": "{count} audiolibro(s)",
"@audiobookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"loading": "Cargando...",
"bookCount": "{count} libro(s)",
"@bookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"books": "Libros",
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
"noAudiobooks": "No hay audiolibros",
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
"noFavoriteArtists": "No hay artistas favoritos",
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
"noFavoriteAlbums": "No hay álbumes favoritos",
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
"noFavoriteTracks": "No hay canciones favoritas",
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
"loadSeries": "Cargar series",
"notConnected": "No conectado a Music Assistant",
"notConnectedTitle": "No conectado",
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
"configureServer": "Configurar servidor",
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
"debugConsole": "Consola de depuración",
"copy": "Copiar",
"clear": "Borrar",
"noLogsToCopy": "No hay registros para copiar",
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
"showDebug": "Mostrar depuración",
"hideDebug": "Ocultar depuración",
"chapters": "Capítulos",
"noChapters": "Sin capítulos",
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
"errorSeeking": "Error al buscar: {error}",
"@errorSeeking": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"error": "Error: {error}",
"@error": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"home": "Inicio",
"library": "Biblioteca",
"homeScreen": "Pantalla de inicio",
"metadataApis": "APIs de metadatos",
"theme": "Tema",
"materialYou": "Material You",
"adaptiveTheme": "Tema adaptativo",
"language": "Idioma",
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
"sortBySeriesOrder": "Ordenar por orden de serie",
"listView": "Vista de lista",
"gridView": "Vista de cuadrícula",
"noBooksInSeries": "No se encontraron libros en esta serie",
"artist": "Artista",
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
"addedToFavorites": "Añadido a favoritos",
"removedFromFavorites": "Eliminado de favoritos",
"unknown": "Desconocido",
"noUpcomingTracks": "No hay canciones próximas",
"showAll": "Mostrar todo",
"showFavoritesOnly": "Mostrar solo favoritos",
"changeView": "Cambiar vista",
"authors": "Autores",
"series": "Series",
"shows": "Programas",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
"playlist": "Lista de reproducción",
"connectionError": "Error de conexión",
"twoColumnGrid": "Cuadrícula de 2 columnas",
"threeColumnGrid": "Cuadrícula de 3 columnas",
"fromProviders": "De proveedores",
"resume": "Continuar",
"about": "Acerca de",
"inProgress": "En progreso",
"narratedBy": "Narrado por {narrators}",
"@narratedBy": {
"placeholders": {
"narrators": {
"type": "String"
}
}
},
"unknownNarrator": "Narrador desconocido",
"unknownAuthor": "Autor desconocido",
"loadingChapters": "Cargando capítulos...",
"noChapterInfoAvailable": "No hay información de capítulos disponible",
"percentComplete": "{percent}% completado",
"@percentComplete": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"theAudioDbApiKey": "Clave API de TheAudioDB",
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
"audiobookLibraries": "Bibliotecas de audiolibros",
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
"unknownLibrary": "Biblioteca desconocida",
"noSeriesFound": "No se encontraron series",
"ensembleBugReport": "Informe de errores de Ensemble",
"byOwner": "Por {owner}",
"@byOwner": {
"placeholders": {
"owner": {
"type": "String"
}
}
},
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
"search": "Buscar",
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"pcmAudio": "Audio PCM",
"playOn": "Reproducir en...",
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
"addToQueueOn": "Añadir a la cola en...",
"startRadioOn": "Iniciar radio de {name} en...",
"@startRadioOn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"book": "libro",
"audiobookSingular": "audiolibro",
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
"@trackCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"accentColor": "Color de acento",
"players": "Dispositivos",
"preferLocalPlayer": "Preferir dispositivo local",
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
"smartSortPlayers": "Orden inteligente",
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerStateExternalSource": "Fuente Externa",
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente",
"hintsAndTips": "Consejos y Sugerencias",
"showHints": "Mostrar Sugerencias",
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado un dispositivo para sincronizar"
}
+95 -8
View File
@@ -3,11 +3,17 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'providers/locale_provider.dart';
import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/settings_service.dart';
import 'services/database_service.dart';
import 'services/profile_service.dart';
import 'services/sync_service.dart';
import 'services/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart';
@@ -27,6 +33,17 @@ final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// Create auth manager for streaming headers
final authManager = AuthManager();
@@ -73,6 +90,7 @@ class MusicAssistantApp extends StatefulWidget {
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider;
late LocaleProvider _localeProvider;
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
@@ -87,6 +105,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
super.initState();
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
@@ -153,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
// Priority order: device list > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
@@ -183,9 +211,10 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
providers: [
ChangeNotifierProvider.value(value: _musicProvider),
ChangeNotifierProvider.value(value: _themeProvider),
ChangeNotifierProvider.value(value: _localeProvider),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
child: Consumer2<ThemeProvider, LocaleProvider>(
builder: (context, themeProvider, localeProvider, _) {
return FutureBuilder<(ColorScheme, ColorScheme)?>(
future: themeProvider.useMaterialTheme
? SystemThemeHelper.getSystemColorSchemes()
@@ -201,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme = light;
darkColorScheme = dark;
} else {
// Use brand color schemes
lightColorScheme = brandLightColorScheme;
darkColorScheme = brandDarkColorScheme;
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
}
return SystemUIWrapper(
@@ -214,6 +243,15 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble',
debugShowCheckedModeBanner: false,
// Localization
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
@@ -310,7 +348,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
}
}
/// Startup widget that checks if user is logged in and auto-connects
/// Startup widget that checks if user is logged in and auto-connects.
///
/// CRITICAL: This widget is the single source of truth for the startup transition.
/// It loads settings FIRST (before connection), then waits for the appropriate
/// conditions before showing HomeScreen. This eliminates the race condition between
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
///
/// For first-time users: Holds dark screen until connected + player selected,
/// so the welcome overlay can appear seamlessly.
/// For returning users: Transitions as soon as connection is established.
class AppStartup extends StatefulWidget {
const AppStartup({super.key});
@@ -323,9 +370,32 @@ class _AppStartupState extends State<AppStartup> {
String? _savedServerUrl;
bool _connectionAttempted = false;
// Welcome system state - loaded FIRST before connection check
bool _settingsLoaded = false;
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
@override
void initState() {
super.initState();
_loadSettingsFirst();
}
/// Load settings BEFORE checking connection.
/// This ensures we know if this is a first-time user before transitioning.
Future<void> _loadSettingsFirst() async {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
// Now check connection
_checkAndConnect();
}
@@ -350,14 +420,30 @@ class _AppStartupState extends State<AppStartup> {
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
// For first-time users, we need to wait for both connection AND player selection
// so the welcome overlay can appear immediately without home screen flash.
// For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
}
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
@@ -384,7 +470,8 @@ class _AppStartupState extends State<AppStartup> {
@override
Widget build(BuildContext context) {
// Show loading while checking settings or connecting
if (!_connectionAttempted || _isConnecting) {
// CRITICAL: Don't transition until settings are loaded - this prevents the race
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
body: Center(
+413 -1
View File
@@ -4,6 +4,10 @@ enum MediaType {
track,
playlist,
radio,
audiobook,
chapter,
podcast,
podcastEpisode,
}
class ProviderMapping {
@@ -106,7 +110,8 @@ class MediaItem {
'media_type': mediaType.name,
if (sortName != null) 'sort_name': sortName,
if (uri != null) 'uri': uri,
if (providerMappings != null) 'provider_mappings': providerMappings,
if (providerMappings != null)
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
if (metadata != null) 'metadata': metadata,
if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position,
@@ -140,11 +145,15 @@ class Artist extends MediaItem {
favorite: item.favorite,
);
}
@override
Map<String, dynamic> toJson() => super.toJson();
}
class Album extends MediaItem {
final List<Artist>? artists;
final String? albumType;
final int? year;
Album({
required super.itemId,
@@ -152,6 +161,7 @@ class Album extends MediaItem {
required super.name,
this.artists,
this.albumType,
this.year,
super.sortName,
super.uri,
super.providerMappings,
@@ -161,6 +171,15 @@ class Album extends MediaItem {
factory Album.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json);
// Parse year - can be int or null
int? year;
final yearValue = json['year'];
if (yearValue is int) {
year = yearValue;
} else if (yearValue is String) {
year = int.tryParse(yearValue);
}
return Album(
itemId: item.itemId,
provider: item.provider,
@@ -169,6 +188,7 @@ class Album extends MediaItem {
?.map((e) => Artist.fromJson(e as Map<String, dynamic>))
.toList(),
albumType: json['album_type'] as String?,
year: year,
sortName: item.sortName,
uri: item.uri,
providerMappings: item.providerMappings,
@@ -179,6 +199,28 @@ class Album extends MediaItem {
String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Get album name with year appended (e.g., "Album Name (2023)")
String get nameWithYear {
return year != null ? '$name ($year)' : name;
}
/// Check if this album is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (artists != null) {
json['artists'] = artists!.map((a) => a.toJson()).toList();
}
if (albumType != null) json['album_type'] = albumType;
if (year != null) json['year'] = year;
return json;
}
}
class Track extends MediaItem {
@@ -224,6 +266,16 @@ class Track extends MediaItem {
String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (artists != null) {
json['artists'] = artists!.map((a) => a.toJson()).toList();
}
if (album != null) json['album'] = album!.toJson();
return json;
}
}
class Playlist extends MediaItem {
@@ -262,3 +314,363 @@ class Playlist extends MediaItem {
);
}
}
class Chapter {
final int chapterNumber;
final int positionMs;
final String title;
final Duration? duration;
Chapter({
required this.chapterNumber,
required this.positionMs,
required this.title,
this.duration,
});
factory Chapter.fromJson(Map<String, dynamic> json) {
// MA API returns chapters with format:
// {position: 1, name: "Opening Credits", start: 0.0, end: 17.6}
// We need to handle both this format and potential others
// Get chapter number (MA uses 'position' for chapter number)
int chapterNum = 0;
if (json['position'] is int) {
chapterNum = json['position'] as int;
} else if (json['chapter_number'] is int) {
chapterNum = json['chapter_number'] as int;
} else if (json['chapter_id'] is int) {
chapterNum = json['chapter_id'] as int;
}
// Get position in milliseconds (MA uses 'start' in seconds)
int posMs = 0;
if (json['start'] != null) {
// MA provides start time in seconds (can be double)
posMs = ((json['start'] as num) * 1000).toInt();
} else if (json['position_ms'] != null) {
posMs = json['position_ms'] as int;
}
// Get title (MA uses 'name')
String title = json['name'] as String? ??
json['title'] as String? ??
'Chapter $chapterNum';
// Calculate duration from start/end if available
Duration? dur;
if (json['start'] != null && json['end'] != null) {
final startSec = (json['start'] as num).toDouble();
final endSec = (json['end'] as num).toDouble();
dur = Duration(milliseconds: ((endSec - startSec) * 1000).toInt());
} else if (json['duration'] != null) {
dur = Duration(seconds: (json['duration'] as num).toInt());
}
return Chapter(
chapterNumber: chapterNum,
positionMs: posMs,
title: title,
duration: dur,
);
}
Map<String, dynamic> toJson() {
return {
'chapter_number': chapterNumber,
'position_ms': positionMs,
'title': title,
if (duration != null) 'duration': duration!.inSeconds,
};
}
}
class Audiobook extends MediaItem {
final List<Artist>? authors;
final List<Artist>? narrators;
final String? publisher;
final String? description;
final int? year;
final List<Chapter>? chapters;
final int? resumePositionMs;
final bool? fullyPlayed;
/// Browse order from API - used as fallback for series sequencing
final int? browseOrder;
Audiobook({
required super.itemId,
required super.provider,
required super.name,
this.authors,
this.narrators,
this.publisher,
this.description,
this.year,
this.chapters,
this.resumePositionMs,
this.fullyPlayed,
this.browseOrder,
super.sortName,
super.uri,
super.providerMappings,
super.metadata,
super.favorite,
super.duration,
}) : super(mediaType: MediaType.audiobook);
factory Audiobook.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json);
// Parse year
int? year;
final yearValue = json['year'];
if (yearValue is int) {
year = yearValue;
} else if (yearValue is String) {
year = int.tryParse(yearValue);
}
// Helper to parse authors/narrators that can be either strings or Artist objects
List<Artist>? parseArtistList(dynamic data) {
if (data == null) return null;
if (data is! List) return null;
return data.map((e) {
if (e is String) {
// MA returns just the name as a string
return Artist(
itemId: '',
provider: 'library',
name: e,
);
} else if (e is Map<String, dynamic>) {
// Full Artist object
return Artist.fromJson(e);
}
return Artist(itemId: '', provider: 'library', name: 'Unknown');
}).toList();
}
// Parse chapters - check top level first, then metadata
List<Chapter>? chapters;
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic chaptersData = json['chapters'];
if (chaptersData == null && metadata != null) {
chaptersData = metadata['chapters'];
}
if (chaptersData is List && chaptersData.isNotEmpty) {
chapters = chaptersData
.map((e) => Chapter.fromJson(e as Map<String, dynamic>))
.toList();
}
// Parse description - check top level first, then metadata
String? description = json['description'] as String?;
if (description == null && metadata != null) {
description = metadata['description'] as String?;
}
return Audiobook(
itemId: item.itemId,
provider: item.provider,
name: item.name,
authors: parseArtistList(json['authors']),
narrators: parseArtistList(json['narrators']),
publisher: json['publisher'] as String?,
description: description,
year: year,
chapters: chapters,
resumePositionMs: json['resume_position_ms'] as int?,
fullyPlayed: json['fully_played'] as bool?,
browseOrder: json['_browse_order'] as int?,
sortName: item.sortName,
uri: item.uri,
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
duration: item.duration,
);
}
String get authorsString =>
authors?.map((a) => a.name).join(', ') ?? 'Unknown Author';
String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Get progress as a percentage (0.0 to 1.0)
double get progress {
if (fullyPlayed == true) return 1.0;
if (resumePositionMs == null || duration == null) return 0.0;
final totalMs = duration!.inMilliseconds;
if (totalMs == 0) return 0.0;
return (resumePositionMs! / totalMs).clamp(0.0, 1.0);
}
/// Get series sequence number from metadata (for sorting within a series)
/// Tries various metadata fields commonly used by audiobook providers
double? get seriesSequence {
// First try the parent's position field (used by MA browse results)
if (position != null) return position!.toDouble();
// Try extracting number from sort_name (e.g., "01 - Book Title" or "Book 1")
if (sortName != null && sortName!.isNotEmpty) {
// Try to extract leading number
final match = RegExp(r'^(\d+)').firstMatch(sortName!);
if (match != null) {
return double.tryParse(match.group(1)!);
}
// Try to extract "Book N" pattern
final bookMatch = RegExp(r'[Bb]ook\s*(\d+)').firstMatch(sortName!);
if (bookMatch != null) {
return double.tryParse(bookMatch.group(1)!);
}
}
// Try extracting from the name itself
final nameMatch = RegExp(r'^(\d+)').firstMatch(name);
if (nameMatch != null) {
return double.tryParse(nameMatch.group(1)!);
}
final bookNameMatch = RegExp(r'[Bb]ook\s*(\d+)').firstMatch(name);
if (bookNameMatch != null) {
return double.tryParse(bookNameMatch.group(1)!);
}
if (metadata == null) return null;
// Try common metadata fields for series sequence
final seq = metadata!['sequence'] ??
metadata!['series_sequence'] ??
metadata!['series_number'] ??
metadata!['position'] ??
metadata!['sort_order'] ??
metadata!['order'];
if (seq is num) return seq.toDouble();
if (seq is String) return double.tryParse(seq);
// Check if there's a series object with sequence
final series = metadata!['series'];
if (series is Map) {
final seriesSeq = series['sequence'] ?? series['number'] ?? series['order'];
if (seriesSeq is num) return seriesSeq.toDouble();
if (seriesSeq is String) return double.tryParse(seriesSeq);
}
if (series is List && series.isNotEmpty) {
final first = series.first;
if (first is Map) {
final seriesSeq = first['sequence'] ?? first['number'] ?? first['order'];
if (seriesSeq is num) return seriesSeq.toDouble();
if (seriesSeq is String) return double.tryParse(seriesSeq);
}
}
// Last resort: use browse order from API (preserves MA/Audiobookshelf order)
if (browseOrder != null) return browseOrder!.toDouble();
return null;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (authors != null) {
json['authors'] = authors!.map((a) => a.toJson()).toList();
}
if (narrators != null) {
json['narrators'] = narrators!.map((n) => n.toJson()).toList();
}
if (publisher != null) json['publisher'] = publisher;
if (description != null) json['description'] = description;
if (year != null) json['year'] = year;
if (chapters != null) {
json['chapters'] = chapters!.map((c) => c.toJson()).toList();
}
if (resumePositionMs != null) json['resume_position_ms'] = resumePositionMs;
if (fullyPlayed != null) json['fully_played'] = fullyPlayed;
if (browseOrder != null) json['_browse_order'] = browseOrder;
return json;
}
}
/// Represents a browseable folder in Music Assistant (providers, series, authors, etc.)
class BrowseFolder {
final String path;
final String name;
final String? label;
final String? thumbnailUrl;
final bool canExpand;
final List<MediaItem>? items;
BrowseFolder({
required this.path,
required this.name,
this.label,
this.thumbnailUrl,
this.canExpand = true,
this.items,
});
factory BrowseFolder.fromJson(Map<String, dynamic> json) {
// MA returns either a BrowseFolder or a MediaItem
// BrowseFolder has 'path' and 'name', MediaItem has 'item_id' and 'media_type'
return BrowseFolder(
path: json['path'] as String? ?? json['uri'] as String? ?? '',
name: json['name'] as String? ?? json['label'] as String? ?? 'Unknown',
label: json['label'] as String?,
thumbnailUrl: _extractThumbnail(json),
canExpand: json['can_expand'] as bool? ?? true,
);
}
static String? _extractThumbnail(Map<String, dynamic> json) {
// Check for image in metadata
final metadata = json['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
final images = metadata['images'] as List<dynamic>?;
if (images != null && images.isNotEmpty) {
final first = images.first as Map<String, dynamic>;
return first['path'] as String?;
}
}
// Check for direct thumbnail
return json['thumbnail'] as String? ?? json['image'] as String?;
}
}
/// Represents an audiobook series
class AudiobookSeries {
final String id;
final String name;
final String? thumbnailUrl;
final List<Audiobook>? books;
final int? bookCount;
AudiobookSeries({
required this.id,
required this.name,
this.thumbnailUrl,
this.books,
this.bookCount,
});
factory AudiobookSeries.fromBrowseFolder(BrowseFolder folder) {
return AudiobookSeries(
id: folder.path,
name: folder.name,
thumbnailUrl: folder.thumbnailUrl,
);
}
factory AudiobookSeries.fromJson(Map<String, dynamic> json) {
return AudiobookSeries(
id: json['path'] as String? ?? json['id'] as String? ?? '',
name: json['name'] as String? ?? json['label'] as String? ?? 'Unknown Series',
thumbnailUrl: BrowseFolder._extractThumbnail(json),
bookCount: json['book_count'] as int?,
);
}
}
+109
View File
@@ -12,6 +12,10 @@ class Player {
final bool? volumeMuted;
final double? elapsedTime; // Seconds elapsed in current track
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
Player({
required this.playerId,
@@ -25,13 +29,60 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
this.activeSource,
this.isExternalSource = false,
});
/// Create a copy of this Player with some fields replaced
Player copyWith({
String? playerId,
String? name,
String? provider,
bool? available,
bool? powered,
String? state,
String? currentItemId,
int? volumeLevel,
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
String? activeSource,
bool? isExternalSource,
}) {
return Player(
playerId: playerId ?? this.playerId,
name: name ?? this.name,
provider: provider ?? this.provider,
available: available ?? this.available,
powered: powered ?? this.powered,
state: state ?? this.state,
currentItemId: currentItemId ?? this.currentItemId,
volumeLevel: volumeLevel ?? this.volumeLevel,
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
);
}
// Derived properties
bool get isPlaying => state == 'playing';
bool get isMuted => volumeMuted ?? false;
int get volume => volumeLevel ?? 0;
// Group properties
// A player is grouped if it's a leader with members OR a child synced to another
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
bool get isGroupChild => syncedTo != null;
// Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {};
@@ -135,6 +186,56 @@ class Player {
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
// Parse group members - MA returns this as a list of player IDs
final groupMembersList = json['group_members'] as List<dynamic>?;
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
// Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?;
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// External sources have non-MA URIs and often media_type='unknown'
bool isExternalSource = false;
if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
}
// Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
}
}
}
}
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -147,6 +248,10 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
);
}
@@ -163,6 +268,10 @@ class Player {
if (volumeMuted != null) 'volume_muted': volumeMuted,
if (elapsedTime != null) 'elapsed_time': elapsedTime,
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo,
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
};
}
}
+21 -2
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
@@ -109,7 +111,7 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Fetch user profile from MA and set owner name
/// Fetch user profile from MA and set owner name + create/activate local profile
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
@@ -132,6 +134,15 @@ class ConnectionProvider with ChangeNotifier {
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
// Create/activate profile in local database
if (DatabaseService.instance.isInitialized && username != null) {
await ProfileService.instance.onMaAuthenticated(
username: username,
displayName: displayName,
);
_logger.log('✅ Local profile activated: $username');
}
} else {
_logger.log('⚠️ No valid name in profile');
}
@@ -178,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_cacheService.clearAll();
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call();
}
},
@@ -204,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class LocaleProvider extends ChangeNotifier {
Locale? _locale; // null = system default
LocaleProvider() {
_loadLocale();
}
Locale? get locale => _locale;
Future<void> _loadLocale() async {
final savedLocale = await SettingsService.getLocale();
if (savedLocale != null) {
_locale = Locale(savedLocale);
}
notifyListeners();
}
Future<void> setLocale(Locale? locale) async {
_locale = locale;
await SettingsService.saveLocale(locale?.languageCode);
notifyListeners();
}
/// Returns the display name for a locale
static String getDisplayName(String? languageCode, BuildContext context) {
switch (languageCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case null:
return 'System';
default:
return languageCode;
}
}
}
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -506,7 +506,15 @@ class PlayerProvider with ChangeNotifier {
return false;
}
if (nameLower == 'this device' || player.playerId.startsWith('ma_')) {
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
filteredCount++;
return false;
}
+174 -56
View File
@@ -8,17 +8,22 @@ import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
final String? heroTagSuffix;
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
final String? initialImageUrl;
const AlbumDetailsScreen({
super.key,
required this.album,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -35,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata
/// Get the best album data available (fresh with images, or widget.album as fallback)
Album get _displayAlbum => _freshAlbum ?? widget.album;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_loadAlbumDescription();
_refreshFavoriteStatus();
// CRITICAL FIX: Defer color extraction until after the transition completes
// This prevents expensive palette extraction during hero animation
// Wait for the first frame to be painted, then extract colors
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
WidgetsBinding.instance.addPostFrameCallback((_) {
// Add small delay to ensure transition is complete
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
}
Future<void> _refreshFavoriteStatus() async {
/// Load fresh album data from API to get full metadata including images
Future<void> _loadFreshAlbumData() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
// Need a URI to fetch fresh album data
final albumUri = widget.album.uri;
if (albumUri == null || albumUri.isEmpty) {
_logger.log('Cannot refresh favorite status: album has no URI');
_logger.log('Cannot load fresh album: album has no URI');
return;
}
@@ -74,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Re-extract colors now that we have fresh album with images
_extractColors();
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
_logger.log('Error loading fresh album data: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
if (imageUrl == null) return;
@@ -106,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
@@ -130,17 +144,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// For removing: need the library_item_id (numeric)
// When provider is "library", itemId is the library ID as a string
// Otherwise, we need to find it from provider_mappings
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
// Find the library mapping
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
@@ -156,9 +171,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
@@ -167,21 +187,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
maProvider.invalidateHomeCache();
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -194,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
final currentFavorite = track.favorite ?? false;
try {
bool success;
if (currentFavorite) {
// Remove from favorites - need library_item_id
int? libraryItemId;
@@ -215,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
} else {
// Add to favorites
@@ -234,10 +263,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
actualItemId = mapping.itemId;
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Update local state - create new track with toggled favorite
if (success) {
// Optimistically update local state
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
@@ -253,21 +287,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
});
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
isOffline
? S.of(context)!.actionQueuedForSync
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -277,19 +315,49 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
);
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
// 1. Show cached data immediately (if available)
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = tracks;
_tracks = cachedTracks;
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
_logger.log('⚠️ Background refresh failed: $e');
}
// Ensure loading is false even if everything failed
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
Future<void> _playAlbum() async {
if (_tracks.isEmpty) return;
@@ -299,7 +367,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -308,6 +376,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Queue all tracks via Music Assistant
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
_logger.log('Album queued on ${player.name}');
// Record to local recently played (per-profile)
RecentlyPlayedService.instance.recordAlbumPlayed(widget.album);
// Stay on album page - mini player will appear
} catch (e) {
_logger.log('Error playing album: $e');
@@ -322,7 +393,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -358,14 +429,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Add album to queue on...',
S.of(context)!.addAlbumToQueueOn,
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'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -393,9 +464,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
@@ -438,14 +509,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Add to queue on...',
S.of(context)!.addToQueueOn,
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'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -472,9 +543,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tracks added to queue'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
@@ -545,13 +616,57 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
/// Show fullscreen album art overlay
void _showFullscreenArt(String? imageUrl) {
if (imageUrl == null) return;
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
barrierColor: Colors.black87,
barrierDismissible: true,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 3.0,
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.contain,
memCacheWidth: 1024,
memCacheHeight: 1024,
),
),
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
// Only rebuild when specific properties change, not on every provider update
final imageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(widget.album, size: 512),
// Use _displayAlbum which has fresh data with images if available
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
@@ -607,7 +722,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
GestureDetector(
onTap: () => _showFullscreenArt(imageUrl),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: 280, // Increased size
@@ -638,6 +755,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
: null,
),
),
),
],
),
),
@@ -653,7 +771,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Material(
color: Colors.transparent,
child: Text(
widget.album.name,
widget.album.nameWithYear,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -715,7 +833,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: ElevatedButton.icon(
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -789,7 +907,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SizedBox(height: 24),
Text(
'Tracks',
S.of(context)!.tracks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -810,7 +928,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
SliverFillRemaining(
child: Center(
child: Text(
'No tracks found',
S.of(context)!.noTracksFound,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
@@ -960,7 +1078,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
],
),
),
@@ -1041,14 +1159,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Play on...',
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? const Center(
child: Text('No players available'),
? Center(
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
+338 -67
View File
@@ -9,8 +9,10 @@ import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/metadata_service.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -40,6 +42,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
String? _artistDescription;
String? _artistImageUrl;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
@@ -48,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isFavorite = widget.artist.favorite ?? false;
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
_loadArtistAlbums();
_loadArtistDescription();
_refreshFavoriteStatus();
@@ -64,6 +71,66 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
});
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
_viewMode = viewMode;
});
}
}
void _toggleSortOrder() {
final newOrder = _sortOrder == 'alpha' ? 'year' : 'alpha';
setState(() {
_sortOrder = newOrder;
_sortAlbums();
});
SettingsService.setArtistAlbumsSortOrder(newOrder);
}
void _cycleViewMode() {
String newMode;
switch (_viewMode) {
case 'grid2':
newMode = 'grid3';
break;
case 'grid3':
newMode = 'list';
break;
default:
newMode = 'grid2';
}
setState(() {
_viewMode = newMode;
});
SettingsService.setArtistAlbumsViewMode(newMode);
}
void _sortAlbums() {
if (_sortOrder == 'year') {
// Sort by year ascending (oldest first), null years at end
_albums.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
_providerAlbums.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
} else {
// Sort alphabetically
_albums.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_providerAlbums.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
}
Future<void> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
@@ -88,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
@@ -110,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -132,9 +203,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isFavorite = newState;
});
@@ -142,21 +217,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
maProvider.invalidateHomeCache();
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -170,7 +249,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
@@ -180,7 +259,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio'),
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
@@ -190,7 +269,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -216,14 +295,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start ${widget.artist.name} radio on...',
S.of(context)!.startRadioOn(widget.artist.name),
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'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -245,7 +324,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -255,7 +334,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -279,18 +358,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
final api = maProvider.api;
if (api == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
return;
}
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${widget.artist.name} radio to queue'),
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
@@ -300,7 +387,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add to queue: $e'),
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -360,18 +447,54 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _loadArtistAlbums() async {
final provider = context.read<MusicAssistantProvider>();
// Use cached artist albums method
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
// 1. Show cached data immediately (if available)
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
setState(() {
_albums = allAlbums;
_providerAlbums = []; // All albums are merged in the cache method
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
try {
final allAlbums = await provider.getArtistAlbumsWithCache(
widget.artist.name,
forceRefresh: cachedAlbums != null,
);
if (mounted && allAlbums.isNotEmpty) {
// Check if data actually changed
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
if (albumsChanged || _albums.isEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
@@ -438,10 +561,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 200,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
color: colorScheme.onSurfaceVariant,
@@ -512,7 +637,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
icon: const Icon(Icons.radio),
label: const Text('Radio'),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -598,7 +723,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
SliverFillRemaining(
child: Center(
child: Text(
'No albums found',
S.of(context)!.noAlbumsFound,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
@@ -607,72 +732,117 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
)
else ...[
// Library Albums Section
// Library Albums Section with inline controls
if (_albums.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
child: Text(
'In Library',
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.inLibrary,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final album = _albums[index];
return _buildAlbumCard(album);
},
childCount: _albums.length,
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
),
_buildAlbumSliver(_albums),
],
// Provider Albums Section
if (_providerAlbums.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
child: Text(
'From Providers',
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.fromProviders,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
// Only show controls here if no library albums
if (_albums.isEmpty) ...[
const Spacer(),
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final album = _providerAlbums[index];
return _buildAlbumCard(album);
},
childCount: _providerAlbums.length,
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
],
),
),
),
_buildAlbumSliver(_providerAlbums),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
],
],
@@ -700,6 +870,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: heroSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -707,7 +878,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
AspectRatio(
aspectRatio: 1.0, // Square album art
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect(
@@ -715,12 +887,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) => Center(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
@@ -745,7 +919,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Material(
color: Colors.transparent,
child: Text(
album.name,
album.nameWithYear,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
@@ -773,4 +947,101 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
);
}
Widget _buildAlbumSliver(List<Album> albums) {
if (_viewMode == 'list') {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAlbumListTile(albums[index]),
childCount: albums.length,
),
),
);
}
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
final childAspectRatio = _viewMode == 'grid3' ? 0.70 : 0.78;
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: childAspectRatio,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAlbumCard(albums[index]),
childCount: albums.length,
),
),
);
}
Widget _buildAlbumListTile(Album album) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
title: Text(
album.nameWithYear,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
album.artistsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: 'artist_albums',
initialImageUrl: imageUrl,
),
),
);
},
);
}
}
+546
View File
@@ -0,0 +1,546 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart';
class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
final List<Audiobook> audiobooks;
final String? heroTagSuffix;
final String? initialAuthorImageUrl;
const AudiobookAuthorScreen({
super.key,
required this.authorName,
required this.audiobooks,
this.heroTagSuffix,
this.initialAuthorImageUrl,
});
@override
State<AudiobookAuthorScreen> createState() => _AudiobookAuthorScreenState();
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String? _authorImageUrl;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
_audiobooks = List.from(widget.audiobooks);
// Use initial image URL immediately for smooth hero animation
_authorImageUrl = widget.initialAuthorImageUrl;
_loadViewPreferences();
_sortAudiobooks();
_loadAuthorImage();
}
Future<void> _loadAuthorImage() async {
final imageUrl = await MetadataService.getAuthorImageUrl(widget.authorName);
if (mounted && imageUrl != null) {
setState(() {
_authorImageUrl = imageUrl;
});
}
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getAuthorAudiobooksSortOrder();
final viewMode = await SettingsService.getAuthorAudiobooksViewMode();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
_viewMode = viewMode;
_sortAudiobooks();
});
}
}
void _toggleSortOrder() {
final newOrder = _sortOrder == 'alpha' ? 'year' : 'alpha';
setState(() {
_sortOrder = newOrder;
_sortAudiobooks();
});
SettingsService.setAuthorAudiobooksSortOrder(newOrder);
}
void _cycleViewMode() {
String newMode;
switch (_viewMode) {
case 'grid2':
newMode = 'grid3';
break;
case 'grid3':
newMode = 'list';
break;
default:
newMode = 'grid2';
}
setState(() {
_viewMode = newMode;
});
SettingsService.setAuthorAudiobooksViewMode(newMode);
}
void _sortAudiobooks() {
if (_sortOrder == 'year') {
_audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
} else {
_audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
}
@override
Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: ClipOval(
child: _authorImageUrl != null
? CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
)
: Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
),
// Audiobooks Section Header with controls
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
),
_buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
],
),
),
);
}
Widget _buildAudiobookSliver() {
if (_viewMode == 'list') {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAudiobookListTile(_audiobooks[index]),
childCount: _audiobooks.length,
),
),
);
}
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
final childAspectRatio = _viewMode == 'grid3' ? 0.65 : 0.70;
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: childAspectRatio,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAudiobookCard(_audiobooks[index]),
childCount: _audiobooks.length,
),
),
);
}
Widget _buildAudiobookCard(Audiobook book) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(book, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0,
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
// Progress indicator
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 3,
),
),
],
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
if (book.narratorsString != S.of(context)!.unknownNarrator)
Text(
S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildAudiobookListTile(Audiobook book) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(book, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
),
),
title: Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
book.narratorsString != S.of(context)!.unknownNarrator
? S.of(context)!.narratedBy(book.narratorsString)
: book.duration != null
? _formatDuration(book.duration!)
: '',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: book.progress > 0
? Text(
'${(book.progress * 100).toInt()}%',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
}
File diff suppressed because it is too large Load Diff
+845
View File
@@ -0,0 +1,845 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import 'audiobook_detail_screen.dart';
import '../l10n/app_localizations.dart';
class AudiobookSeriesScreen extends StatefulWidget {
final AudiobookSeries series;
final String? heroTag;
/// Pre-loaded cover URLs from the library screen for smooth Hero animation
final List<String>? initialCovers;
const AudiobookSeriesScreen({
super.key,
required this.series,
this.heroTag,
this.initialCovers,
});
@override
State<AudiobookSeriesScreen> createState() => _AudiobookSeriesScreenState();
}
class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final _logger = DebugLogger();
List<Audiobook> _audiobooks = [];
bool _isLoading = true;
String? _error;
// View preferences
String _sortOrder = 'series'; // 'series' (by series number) or 'alpha'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
// Extracted colors from book covers
List<Color> _extractedColors = [];
// Cover URLs - start with initial covers, update when books load
List<String> _coverUrls = [];
@override
void initState() {
super.initState();
// Use initial covers immediately for smooth Hero animation
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
_coverUrls = List.from(widget.initialCovers!);
// Start color extraction from initial covers
_extractColorsFromUrls(widget.initialCovers!);
}
_loadViewPreferences();
// Defer loading until after first frame to ensure UI renders first
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_loadSeriesBooks();
}
});
}
/// Extract colors from cover URLs (used for initial covers)
Future<void> _extractColorsFromUrls(List<String> coverUrls) async {
final extractedColors = <Color>[];
for (final url in coverUrls.take(4)) {
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_extractedColors = extractedColors;
});
}
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getSeriesAudiobooksSortOrder();
final viewMode = await SettingsService.getSeriesAudiobooksViewMode();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
_viewMode = viewMode;
});
}
}
void _toggleSortOrder() {
final newOrder = _sortOrder == 'series' ? 'alpha' : 'series';
setState(() {
_sortOrder = newOrder;
_sortAudiobooks();
});
SettingsService.setSeriesAudiobooksSortOrder(newOrder);
}
void _cycleViewMode() {
String newMode;
switch (_viewMode) {
case 'grid2':
newMode = 'grid3';
break;
case 'grid3':
newMode = 'list';
break;
default:
newMode = 'grid2';
}
setState(() {
_viewMode = newMode;
});
SettingsService.setSeriesAudiobooksViewMode(newMode);
}
void _sortAudiobooks() {
if (_sortOrder == 'series') {
// Sort by series number (sequence), falling back to name for books without sequence
_audiobooks.sort((a, b) {
final seqA = a.seriesSequence;
final seqB = b.seriesSequence;
if (seqA == null && seqB == null) return a.name.compareTo(b.name);
if (seqA == null) return 1; // Books without sequence go to end
if (seqB == null) return -1;
return seqA.compareTo(seqB);
});
} else {
// Sort alphabetically
_audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
}
Future<void> _loadSeriesBooks() async {
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
try {
setState(() {
_isLoading = true;
_error = null;
});
_logger.log('📚 SeriesScreen setState done, getting provider...');
final maProvider = context.read<MusicAssistantProvider>();
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
if (maProvider.api == null) {
setState(() {
_error = S.of(context)!.notConnected;
_isLoading = false;
});
return;
}
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
_logger.log('📚 SeriesScreen got ${books.length} books');
// Debug: Log sequence data for each book
for (final book in books) {
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
}
if (mounted) {
// Update cover URLs from loaded books
final newCovers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
newCovers.add(imageUrl);
}
}
setState(() {
_audiobooks = books;
_sortAudiobooks(); // Sort inside setState so UI updates
// Update covers if we got new ones (may have more than initial)
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
_isLoading = false;
});
// Debug: Log sorted order
_logger.log('📚 Sorted order ($_sortOrder):');
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
}
// Extract colors from book covers asynchronously
_extractCoverColors(maProvider);
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
}
}
/// Extract colors from book covers for empty grid cells
Future<void> _extractCoverColors(MusicAssistantProvider maProvider) async {
final extractedColors = <Color>[];
// Extract colors from first few book covers
for (final book in _audiobooks.take(4)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl == null) continue;
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
maximumColorCount: 8,
);
// Get dark muted colors for grid squares
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
// Darken the dominant color for better appearance
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_extractedColors = extractedColors;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
expandedHeight: 350,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
// Series cover collage - same size as audiobook detail cover
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: 280,
height: 280,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider),
),
),
),
],
),
),
),
// Title and count
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.series.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_isLoading
? l10n.loading
: l10n.bookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
),
// Books section header with controls
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
l10n.books,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'series' ? Icons.format_list_numbered : Icons.sort_by_alpha,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'series' ? S.of(context)!.sortAlphabetically : S.of(context)!.sortBySeriesOrder,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
),
// Content
if (_isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(_error!, style: TextStyle(color: colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadSeriesBooks,
child: Text(l10n.retry),
),
],
),
),
)
else if (_audiobooks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.library_books_outlined,
size: 48, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
S.of(context)!.noBooksInSeries,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
),
),
)
else
_buildAudiobookSliver(maProvider),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
);
}
// Dark pastel colors for empty cells - muted and book-like
static const _emptyColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
Color(0xFF2C3E50), // Midnight blue
Color(0xFF3D3D3D), // Charcoal
Color(0xFF4A4458), // Dark purple-grey
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
// Use _coverUrls which may have initial covers or loaded covers
// Only show placeholder if we truly have no covers
if (_coverUrls.isEmpty) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 64,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
);
}
final covers = _coverUrls;
// Dynamic grid size based on number of covers (matches library screen)
// 1 cover = 1x1, 2-4 covers = 2x2, 5+ covers = 3x3
int gridSize;
if (covers.length == 1) {
gridSize = 1;
} else if (covers.length <= 4) {
gridSize = 2;
} else {
gridSize = 3;
}
// Use series ID to pick consistent colors and nested grid patterns
final colorSeed = widget.series.id.hashCode;
// Pad covers to fill grid
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
for (var i = 0; i < covers.length && i < displayCovers.length; i++) {
displayCovers[i] = covers[i];
}
// Build grid using Column/Row for proper sizing (no scrolling issues)
return Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
children: List.generate(gridSize, (col) {
final index = row * gridSize + col;
final coverUrl = displayCovers[index];
return Expanded(
child: coverUrl != null
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.book,
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
size: 20,
),
),
)
: _buildEmptyCell(colorSeed, index),
);
}),
),
);
}),
);
}
/// Builds an empty cell with either a solid color or a nested grid
/// The pattern is deterministic based on series ID and cell index
Widget _buildEmptyCell(int colorSeed, int cellIndex) {
// Use extracted colors if available, otherwise fall back to static palette
final baseColors = _extractedColors.isNotEmpty ? _extractedColors : _emptyColors;
// Tone down colors - reduce saturation and darken
final colors = baseColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17; // Prime multiplier for better distribution
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1; // Solid color
} else if (sizeRoll < 80) {
nestedSize = 2; // 2x2 grid
} else {
nestedSize = 3; // 3x3 grid
}
if (nestedSize == 1) {
// Solid color
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (no margins - seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
// Use different seed for each nested cell
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
);
}),
);
}
Widget _buildAudiobookSliver(MusicAssistantProvider maProvider) {
if (_viewMode == 'list') {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAudiobookListTile(_audiobooks[index], maProvider),
childCount: _audiobooks.length,
),
),
);
}
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
final childAspectRatio = _viewMode == 'grid3' ? 0.65 : 0.70;
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: childAspectRatio,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildAudiobookCard(_audiobooks[index], maProvider),
childCount: _audiobooks.length,
),
),
);
}
Widget _buildAudiobookCard(Audiobook book, MusicAssistantProvider maProvider) {
final imageUrl = maProvider.getImageUrl(book, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'series_${widget.series.id}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0,
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
// Progress indicator
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 3,
),
),
],
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
if (book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor)
Text(
book.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildAudiobookListTile(Audiobook book, MusicAssistantProvider maProvider) {
final imageUrl = maProvider.getImageUrl(book, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'series_${widget.series.id}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
),
),
title: Hero(
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
child: Material(
color: Colors.transparent,
child: Text(
book.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor
? book.authorsString
: book.duration != null
? _formatDuration(book.duration!)
: '',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: book.progress > 0
? Text(
'${(book.progress * 100).toInt()}%',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
}
+46 -45
View File
@@ -5,6 +5,7 @@ import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
class DebugLogScreen extends StatefulWidget {
const DebugLogScreen({super.key});
@@ -42,10 +43,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard'),
SnackBar(
content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
duration: const Duration(seconds: 2),
),
);
}
@@ -58,7 +59,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
await Share.share(
report,
subject: 'Ensemble Bug Report',
subject: S.of(context)!.ensembleBugReport,
);
} finally {
if (mounted) {
@@ -72,9 +73,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs cleared'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.logsCleared),
duration: const Duration(seconds: 1),
),
);
}
@@ -93,7 +94,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF2a2a2a),
title: Text(
'All Players (${allPlayers.length})',
S.of(context)!.allPlayersCount(allPlayers.length),
style: const TextStyle(color: Colors.white),
),
content: SizedBox(
@@ -153,27 +154,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ID: ${player.playerId}',
S.of(context)!.playerId(player.playerId),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
Text(
'Available: ${player.available} | Provider: ${player.provider}',
S.of(context)!.playerInfo(player.available.toString(), player.provider ?? 'unknown'),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
if (isCurrentPlayer)
const Text(
'← This device',
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
Text(
S.of(context)!.thisDevice,
style: const TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
),
if (isGhost && !isCorrupt)
const Text(
'⚠️ Ghost player (duplicate)',
style: TextStyle(color: Colors.red, fontSize: 11),
Text(
S.of(context)!.ghostPlayer,
style: const TextStyle(color: Colors.red, fontSize: 11),
),
if (isCorrupt)
const Text(
'⚠️ Unavailable/Corrupt',
style: TextStyle(color: Colors.orange, fontSize: 11),
Text(
S.of(context)!.unavailableCorrupt,
style: const TextStyle(color: Colors.orange, fontSize: 11),
),
],
),
@@ -195,15 +196,15 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Player list copied to clipboard!')),
SnackBar(content: Text(S.of(context)!.playerListCopied)),
);
}
},
child: const Text('Copy List'),
child: Text(S.of(context)!.copyList),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(S.of(context)!.close),
),
],
),
@@ -211,7 +212,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading players: $e')),
SnackBar(content: Text(S.of(context)!.errorLoadingPlayers(e.toString()))),
);
}
}
@@ -229,9 +230,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
onPressed: () => Navigator.pop(context),
color: Colors.white,
),
title: const Text(
'Debug Logs',
style: TextStyle(
title: Text(
S.of(context)!.debugLogs,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w300,
@@ -256,11 +257,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
icon: const Icon(Icons.share_rounded),
onPressed: _shareBugReport,
color: Colors.white,
tooltip: 'Share bug report',
tooltip: S.of(context)!.shareBugReport,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'More options',
tooltip: S.of(context)!.moreOptions,
onSelected: (value) async {
switch (value) {
case 'show_players':
@@ -275,27 +276,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
}
},
itemBuilder: (context) => [
const PopupMenuItem(
PopupMenuItem(
value: 'show_players',
child: ListTile(
leading: Icon(Icons.speaker_group_rounded),
title: Text('View All Players'),
leading: const Icon(Icons.speaker_group_rounded),
title: Text(S.of(context)!.viewAllPlayers),
dense: true,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'copy_logs',
child: ListTile(
leading: Icon(Icons.copy_rounded),
title: Text('Copy Logs'),
leading: const Icon(Icons.copy_rounded),
title: Text(S.of(context)!.copyLogs),
dense: true,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'clear_logs',
child: ListTile(
leading: Icon(Icons.delete_rounded),
title: Text('Clear Logs'),
leading: const Icon(Icons.delete_rounded),
title: Text(S.of(context)!.clearLogs),
dense: true,
),
),
@@ -317,20 +318,20 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('All', LogLevel.debug),
_buildFilterChip(S.of(context)!.all, LogLevel.debug),
const SizedBox(width: 8),
_buildFilterChip('Info+', LogLevel.info),
_buildFilterChip(S.of(context)!.infoPlus, LogLevel.info),
const SizedBox(width: 8),
_buildFilterChip('Warnings', LogLevel.warning),
_buildFilterChip(S.of(context)!.warnings, LogLevel.warning),
const SizedBox(width: 8),
_buildFilterChip('Errors', LogLevel.error),
_buildFilterChip(S.of(context)!.errors, LogLevel.error),
],
),
),
const SizedBox(height: 8),
// Stats
Text(
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
S.of(context)!.showingEntries(_filteredEntries.length, _logger.entries.length),
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
@@ -338,10 +339,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
),
Expanded(
child: _filteredEntries.isEmpty
? const Center(
? Center(
child: Text(
'No logs yet',
style: TextStyle(
S.of(context)!.noLogsYet,
style: const TextStyle(
color: Colors.white54,
fontSize: 16,
),
+8 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart';
@@ -39,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Press back again to minimize'),
content: Text(S.of(context)!.pressBackToMinimize),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
@@ -61,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+6 -2
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryAlbumsScreen extends StatelessWidget {
const LibraryAlbumsScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Albums',
S.of(context)!.albums,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -46,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (albums.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -54,6 +57,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
if (albums.isEmpty) {
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+6 -2
View File
@@ -6,6 +6,7 @@ import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryArtistsScreen extends StatelessWidget {
const LibraryArtistsScreen({super.key});
@@ -27,7 +28,7 @@ class LibraryArtistsScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Artists',
S.of(context)!.artists,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -48,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (artists.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -56,6 +59,7 @@ class LibraryArtistsScreen extends StatelessWidget {
if (artists.isEmpty) {
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+8 -5
View File
@@ -5,6 +5,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -59,7 +60,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
color: colorScheme.onBackground,
),
title: Text(
'Playlists',
S.of(context)!.playlists,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -74,14 +75,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (_playlists.isEmpty && _isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (_playlists.isEmpty) {
return EmptyState.playlists(onRefresh: _loadPlaylists);
return EmptyState.playlists(context: context, onRefresh: _loadPlaylists);
}
return RefreshIndicator(
@@ -138,8 +141,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} tracks'
: playlist.owner ?? 'Playlist',
? '${playlist.trackCount} ${S.of(context)!.tracks}'
: playlist.owner ?? S.of(context)!.playlist,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
+7 -4
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 '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryTracksScreen extends StatelessWidget {
const LibraryTracksScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryTracksScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Tracks',
S.of(context)!.tracks,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -41,14 +42,16 @@ class LibraryTracksScreen extends StatelessWidget {
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.tracks.isEmpty) {
return EmptyState.tracks(onRefresh: provider.loadLibrary);
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
}
return ListView.builder(
@@ -129,7 +132,7 @@ class LibraryTracksScreen extends StatelessWidget {
if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
+153 -36
View File
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
@@ -30,6 +33,8 @@ class _LoginScreenState extends State<LoginScreen> {
String? _error;
bool _showDebug = false;
List<String> _debugLogs = [];
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
@override
void initState() {
@@ -94,10 +99,27 @@ class _LoginScreenState extends State<LoginScreen> {
return 'http://$url';
}
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
return 'http://$url';
}
// For domain names, default to https://
return 'https://$url';
}
/// Check if the URL is using HTTP (for security warning display)
bool _isHttpConnection(String url) {
final normalized = _normalizeServerUrl(url);
return normalized.startsWith('http://') && !normalized.startsWith('https://');
}
/// Check if the URL is a Tailscale URL (safe for HTTP)
bool _isTailscaleUrl(String url) {
final normalized = url.trim().toLowerCase();
return normalized.contains('.ts.net');
}
String _buildServerUrl() {
// Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim());
@@ -137,7 +159,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your Music Assistant server address';
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
@@ -147,6 +169,8 @@ class _LoginScreenState extends State<LoginScreen> {
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear();
});
@@ -160,11 +184,18 @@ class _LoginScreenState extends State<LoginScreen> {
// Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
// Check if auth manager used a different URL (e.g., HTTP fallback)
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
if (resolvedUrl != null && resolvedUrl != serverUrl) {
_addDebugLog('URL resolved via fallback: $resolvedUrl');
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
}
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) {
setState(() {
_error = 'Cannot determine authentication requirements. Please check server URL.';
_error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false;
});
return;
@@ -173,6 +204,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false;
});
@@ -199,13 +231,13 @@ class _LoginScreenState extends State<LoginScreen> {
String _getAuthTypeName(String strategyName) {
switch (strategyName) {
case 'none':
return 'No Authentication';
return S.of(context)!.noAuthentication;
case 'basic':
return 'HTTP Basic Auth';
return S.of(context)!.httpBasicAuth;
case 'authelia':
return 'Authelia';
return S.of(context)!.authelia;
case 'music_assistant':
return 'Music Assistant Login';
return S.of(context)!.musicAssistantLogin;
default:
return 'Unknown';
}
@@ -229,7 +261,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your Music Assistant server address';
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
@@ -239,7 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your name';
_error = S.of(context)!.pleaseEnterName;
});
return;
}
@@ -250,7 +282,9 @@ class _LoginScreenState extends State<LoginScreen> {
});
try {
final serverUrl = _buildServerUrl();
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
_addDebugLog('Using server URL for connection: $serverUrl');
final port = _portController.text.trim();
// Validate port (if provided)
@@ -259,7 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() {
_error = 'Please enter a valid port number (1-65535)';
_error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false;
});
return;
@@ -269,7 +303,13 @@ class _LoginScreenState extends State<LoginScreen> {
// Save owner name (if provided) and port to settings
// For MA auth, the owner name will be set from user profile after login
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
await SettingsService.setOwnerName(_ownerNameController.text.trim());
final ownerName = _ownerNameController.text.trim();
await SettingsService.setOwnerName(ownerName);
// Create/activate profile in local database for manual name entry
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(ownerName);
}
}
await SettingsService.setWebSocketPort(portNum);
@@ -285,7 +325,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = 'Please enter username and password';
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
@@ -311,7 +351,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!success) {
setState(() {
_error = 'Authentication failed. Please check your credentials.';
_error = S.of(context)!.authFailed;
_isConnecting = false;
});
return;
@@ -345,7 +385,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = 'Please enter username and password';
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
@@ -369,7 +409,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (accessToken == null) {
setState(() {
_error = 'Music Assistant login failed. Please check your credentials.';
_error = S.of(context)!.maLoginFailed;
_isConnecting = false;
});
return;
@@ -403,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
}
if (provider.isConnected) {
// For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break;
}
}
// Navigate to home screen
if (mounted) {
// Ensure keyboard is closed before navigating
@@ -413,7 +465,7 @@ class _LoginScreenState extends State<LoginScreen> {
}
} else {
setState(() {
_error = 'Could not connect to server. Please check the address and try again.';
_error = S.of(context)!.connectionFailed;
_isConnecting = false;
});
}
@@ -461,7 +513,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Server URL
Text(
'Server Address',
S.of(context)!.serverAddress,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -473,7 +525,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _serverUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., music.example.com or 192.168.1.100',
hintText: S.of(context)!.serverAddressHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -500,7 +552,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[
Text(
'Your Name',
S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -512,7 +564,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Your first name',
hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -535,7 +587,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Port
Text(
'Port (Optional)',
S.of(context)!.portOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -543,7 +595,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 8),
Text(
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.',
S.of(context)!.portDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
@@ -555,7 +607,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _portController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., 8095 or leave blank',
hintText: S.of(context)!.portHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -600,7 +652,7 @@ class _LoginScreenState extends State<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Detected: $_detectedAuthType',
S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle(
color: needsAuth
? colorScheme.onPrimaryContainer
@@ -613,7 +665,7 @@ class _LoginScreenState extends State<LoginScreen> {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'This server requires authentication',
S.of(context)!.serverRequiresAuth,
style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12,
@@ -627,12 +679,77 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
// HTTP connection info banner
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondaryContainer.withOpacity(0.3)
: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: _isTailscaleUrl(_serverUrlController.text)
? null
: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
_isTailscaleUrl(_serverUrlController.text)
? Icons.vpn_lock_rounded
: Icons.warning_amber_rounded,
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondary
: colorScheme.error,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.tailscaleVpnConnection
: S.of(context)!.unencryptedConnection,
style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback
? S.of(context)!.httpsFailedUsingHttp
: S.of(context)!.httpNotEncrypted,
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer).withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Authentication fields (shown only if auth required)
if (needsAuth) ...[
// Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[
Text(
'Auth Server URL (Optional)',
S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -640,7 +757,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 8),
Text(
'Leave empty if authentication is on the same server',
S.of(context)!.authServerUrlDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
@@ -652,7 +769,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., auth.example.com (if different from server)',
hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -673,7 +790,7 @@ class _LoginScreenState extends State<LoginScreen> {
],
Text(
'Username',
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -686,7 +803,7 @@ class _LoginScreenState extends State<LoginScreen> {
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Username',
hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -706,7 +823,7 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
Text(
'Password',
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -718,7 +835,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Password',
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -808,9 +925,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(width: 12),
const Text(
'Detecting authentication...',
style: TextStyle(
Text(
S.of(context)!.detectingAuth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -827,7 +944,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: Text(
_detectedAuthStrategy == null ? 'Detect & Connect' : 'Connect',
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
+176 -76
View File
@@ -1,14 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -31,8 +35,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
// Random order for favorites (generated once per session)
late List<int> _favoritesOrder;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
@override
bool get wantKeepAlive => true;
@@ -41,8 +49,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Generate random order for favorites rows (0, 1, 2 shuffled)
_favoritesOrder = [0, 1, 2]..shuffle();
_loadSettings();
}
@@ -67,6 +73,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -75,6 +85,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
});
}
}
@@ -84,6 +98,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final provider = context.read<MusicAssistantProvider>();
provider.invalidateHomeCache();
// Force full library sync from MA API
await provider.forceLibrarySync();
// Reload settings in case they changed
await _loadSettings();
@@ -127,6 +144,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0,
centerTitle: false,
actions: [
// Sync indicator - shows when library is syncing in background
ListenableBuilder(
listenable: SyncService.instance,
builder: (context, _) {
if (!SyncService.instance.isSyncing) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary.withOpacity(0.7),
),
),
),
);
},
),
const PlayerSelector(),
],
),
@@ -135,18 +174,62 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
selector: (_, p) => p.isConnected,
builder: (context, isConnected, child) {
final maProvider = context.read<MusicAssistantProvider>();
return !isConnected
? DisconnectedState.full(
final syncService = SyncService.instance;
// Show cached data even when not connected (if we have cache)
// Only show disconnected state if we have no cached data at all
if (!isConnected && !syncService.hasCache) {
return DisconnectedState.full(
context: context,
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
)
: RefreshIndicator(
);
}
return Stack(
children: [
// Main content
RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !maProvider.hasCachedPlayers)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: colorScheme.primaryContainer.withOpacity(0.9),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
S.of(context)!.connecting,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
],
);
},
),
@@ -165,9 +248,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
// Build favorites rows in random order
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
@@ -192,37 +272,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Recently played albums (optional)
if (_showRecentAlbums)
AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
),
// Discover Artists (optional)
if (_showDiscoverArtists)
ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
),
// Discover Albums (optional)
if (_showDiscoverAlbums)
AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
),
// Favorites rows in random order
...favoritesWidgets,
],
children: _buildOrderedRows(provider, rowHeight),
),
),
),
@@ -231,51 +281,101 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
);
}
/// Build favorites rows in random order (order set once per session)
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
// Create list of enabled favorites with their order index
final enabledFavorites = <MapEntry<int, Widget>>[];
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
final rows = <Widget>[];
if (_showFavoriteAlbums) {
enabledFavorites.add(MapEntry(
_favoritesOrder[0],
AlbumRow(
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
rows.add(widget);
}
}
return rows;
}
/// Build a single row widget by ID, returns null if row is disabled
Widget? _buildRowWidget(String rowId, MusicAssistantProvider provider, double rowHeight) {
switch (rowId) {
case 'recent-albums':
if (!_showRecentAlbums) return null;
return AlbumRow(
key: const ValueKey('recent-albums'),
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
rowHeight: rowHeight,
);
case 'discover-artists':
if (!_showDiscoverArtists) return null;
return ArtistRow(
key: const ValueKey('discover-artists'),
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
rowHeight: rowHeight,
);
case 'discover-albums':
if (!_showDiscoverAlbums) return null;
return AlbumRow(
key: const ValueKey('discover-albums'),
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'continue-listening':
if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
if (!_showDiscoverAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
if (!_showDiscoverSeries) return null;
return SeriesRow(
key: const ValueKey('discover-series'),
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
if (!_showFavoriteAlbums) return null;
return AlbumRow(
key: const ValueKey('favorite-albums'),
title: 'Favorite Albums',
title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteArtists) {
enabledFavorites.add(MapEntry(
_favoritesOrder[1],
ArtistRow(
);
case 'favorite-artists':
if (!_showFavoriteArtists) return null;
return ArtistRow(
key: const ValueKey('favorite-artists'),
title: 'Favorite Artists',
title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight,
),
));
}
if (_showFavoriteTracks) {
enabledFavorites.add(MapEntry(
_favoritesOrder[2],
TrackRow(
);
case 'favorite-tracks':
if (!_showFavoriteTracks) return null;
return TrackRow(
key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks',
title: S.of(context)!.favoriteTracks,
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
),
));
);
default:
return null;
}
// Sort by the random order index
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
return enabledFavorites.map((e) => e.value).toList();
}
}
File diff suppressed because it is too large Load Diff
+52 -20
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
final _playlistLogger = DebugLogger();
@@ -33,22 +35,49 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
}
Future<void> _loadTracks() async {
setState(() {
_isLoading = true;
});
final maProvider = context.read<MusicAssistantProvider>();
final tracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
);
final cacheKey = '${widget.provider}_${widget.itemId}';
// 1. Show cached data immediately (if available)
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = tracks;
_tracks = cachedTracks;
_isLoading = false;
});
}
} else {
setState(() => _isLoading = true);
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
forceRefresh: cachedTracks != null,
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
Future<void> _playPlaylist() async {
@@ -60,7 +89,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -70,10 +99,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
// Record to local recently played (per-profile)
RecentlyPlayedService.instance.recordPlaylistPlayed(widget.playlist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${widget.playlist.name}'),
content: Text(S.of(context)!.playing(widget.playlist.name)),
duration: const Duration(seconds: 2),
),
);
@@ -191,7 +223,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _playPlaylist,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
@@ -204,9 +236,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
),
const SizedBox(height: 24),
// Tracks header
const Text(
'Tracks',
style: TextStyle(
Text(
S.of(context)!.tracks,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -226,7 +258,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
? SliverFillRemaining(
child: Center(
child: Text(
'No tracks in playlist',
S.of(context)!.noTracksInPlaylist,
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
@@ -323,7 +355,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
onPressed: () async {
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -332,9 +364,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
await maProvider.playTracks(player.playerId, _tracks, startIndex: index);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Playing track'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.playingTrack),
duration: const Duration(seconds: 1),
),
);
}
+10 -9
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
const QueueScreen({super.key});
@@ -51,7 +52,7 @@ class _QueueScreenState extends State<QueueScreen> {
if (mounted) {
setState(() {
_isLoading = false;
_error = 'No player selected';
_error = S.of(context)!.noPlayerSelected;
});
}
}
@@ -77,7 +78,7 @@ class _QueueScreenState extends State<QueueScreen> {
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
player != null ? '${player.name} Queue' : 'Queue',
player != null ? S.of(context)!.playerQueue(player.name) : S.of(context)!.queue,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
@@ -116,7 +117,7 @@ class _QueueScreenState extends State<QueueScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadQueue,
child: const Text('Retry'),
child: Text(S.of(context)!.retry),
),
],
),
@@ -131,7 +132,7 @@ class _QueueScreenState extends State<QueueScreen> {
Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]),
const SizedBox(height: 16),
Text(
'No player selected',
S.of(context)!.noPlayerSelected,
style: TextStyle(color: Colors.grey[600], fontSize: 18),
),
],
@@ -140,7 +141,7 @@ class _QueueScreenState extends State<QueueScreen> {
}
if (_queue == null || _queue!.items.isEmpty) {
return EmptyState.queue();
return EmptyState.queue(context: context);
}
// Filter to show only current and upcoming items (not history)
@@ -148,9 +149,9 @@ class _QueueScreenState extends State<QueueScreen> {
final upcomingItems = _queue!.items.sublist(currentIndex);
if (upcomingItems.isEmpty) {
return const EmptyState(
return EmptyState(
icon: Icons.queue_music,
message: 'No upcoming tracks',
message: S.of(context)!.noUpcomingTracks,
);
}
@@ -220,9 +221,9 @@ class _QueueScreenState extends State<QueueScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Removed ${item.track.name}'),
content: Text(S.of(context)!.removedItem(item.track.name)),
action: SnackBarAction(
label: 'Undo',
label: S.of(context)!.undo,
onPressed: () {
setState(() {
_queue!.items.insert(index, item);
File diff suppressed because it is too large Load Diff
+544 -136
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -26,6 +29,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
// Hint settings
bool _showHints = true;
@override
void initState() {
@@ -51,6 +68,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
// Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
// Load home row order
final rowOrder = await SettingsService.getHomeRowOrder();
// Load audiobook library settings
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
final enabled = await SettingsService.getEnabledAbsLibraries();
final libraryEnabled = <String, bool>{};
for (final lib in discovered) {
final path = lib['path'] ?? '';
// null means all enabled
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
// Load hint settings
final showHints = await SettingsService.getShowHints();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -59,6 +102,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_showHints = showHints;
});
}
}
@@ -70,6 +122,103 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.dispose();
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
switch (rowId) {
case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
case 'discover-artists':
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
return {'title': s.discoverAudiobooks, 'subtitle': s.showRandomAudiobooks};
case 'discover-series':
return {'title': s.discoverSeries, 'subtitle': s.showRandomSeries};
case 'favorite-albums':
return {'title': s.favoriteAlbums, 'subtitle': s.showFavoriteAlbums};
case 'favorite-artists':
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
default:
return {'title': rowId, 'subtitle': ''};
}
}
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
case 'discover-artists':
return _showDiscoverArtists;
case 'discover-albums':
return _showDiscoverAlbums;
case 'continue-listening':
return _showContinueListeningAudiobooks;
case 'discover-audiobooks':
return _showDiscoverAudiobooks;
case 'discover-series':
return _showDiscoverSeries;
case 'favorite-albums':
return _showFavoriteAlbums;
case 'favorite-artists':
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
default:
return false;
}
}
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
SettingsService.setShowRecentAlbums(value);
break;
case 'discover-artists':
_showDiscoverArtists = value;
SettingsService.setShowDiscoverArtists(value);
break;
case 'discover-albums':
_showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value);
break;
case 'continue-listening':
_showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value);
break;
case 'discover-audiobooks':
_showDiscoverAudiobooks = value;
SettingsService.setShowDiscoverAudiobooks(value);
break;
case 'discover-series':
_showDiscoverSeries = value;
SettingsService.setShowDiscoverSeries(value);
break;
case 'favorite-albums':
_showFavoriteAlbums = value;
SettingsService.setShowFavoriteAlbums(value);
break;
case 'favorite-artists':
_showFavoriteArtists = value;
SettingsService.setShowFavoriteArtists(value);
break;
case 'favorite-tracks':
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
}
});
}
Future<void> _disconnect() async {
final provider = context.read<MusicAssistantProvider>();
await provider.disconnect();
@@ -103,7 +252,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
color: colorScheme.onBackground,
),
title: Text(
'Settings',
S.of(context)!.settings,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -122,7 +271,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
color: colorScheme.onBackground,
tooltip: 'Debug Logs',
tooltip: S.of(context)!.debugLogs,
),
],
),
@@ -178,9 +327,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: FilledButton.tonalIcon(
onPressed: _disconnect,
icon: const Icon(Icons.logout_rounded),
label: const Text(
'Disconnect',
style: TextStyle(
label: Text(
S.of(context)!.disconnect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
@@ -199,7 +348,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Theme section
Text(
'Theme',
S.of(context)!.theme,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -210,14 +359,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Mode',
S.of(context)!.themeMode,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -228,21 +377,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
return SizedBox(
width: double.infinity,
child: SegmentedButton<ThemeMode>(
segments: const [
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode_rounded),
label: Text(S.of(context)!.light),
icon: const Icon(Icons.light_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode_rounded),
label: Text(S.of(context)!.dark),
icon: const Icon(Icons.dark_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.auto_mode_rounded),
label: Text(S.of(context)!.system),
icon: const Icon(Icons.auto_mode_rounded),
),
],
selected: {themeProvider.themeMode},
@@ -254,7 +403,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (states.contains(WidgetState.selected)) {
return colorScheme.primaryContainer;
}
return colorScheme.surfaceVariant.withOpacity(0.3);
return colorScheme.surfaceVariant.withOpacity(0.5);
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
@@ -263,6 +412,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
return colorScheme.onSurfaceVariant;
}),
side: WidgetStateProperty.all(BorderSide.none),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
);
@@ -274,6 +426,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Accent color picker
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDisabled = themeProvider.useMaterialTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.accentColor,
style: TextStyle(
color: isDisabled
? colorScheme.onSurface.withOpacity(0.4)
: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: accentColorOptions.map((color) {
final isSelected = themeProvider.customColor.value == color.value;
return GestureDetector(
onTap: isDisabled ? null : () {
themeProvider.setCustomColor(color);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDisabled ? color.withOpacity(0.3) : color,
shape: BoxShape.circle,
border: isSelected && !isDisabled
? Border.all(color: colorScheme.onSurface, width: 3)
: null,
boxShadow: isSelected && !isDisabled
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
: null,
),
child: isSelected && !isDisabled
? Icon(Icons.check, color: Colors.white, size: 20)
: null,
),
);
}).toList(),
),
],
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
@@ -284,11 +497,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
'Material You',
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Use system colors (Android 12+)',
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
@@ -314,11 +527,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
'Adaptive Theme',
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Extract colors from album and artist artwork',
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
@@ -334,9 +547,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Home Screen section
// Language section
Text(
'Home Screen',
S.of(context)!.language,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
switch (code) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
default:
return S.of(context)!.system;
}
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
tileColor: colorScheme.surfaceVariant.withOpacity(0.5),
leading: Icon(Icons.language_rounded, color: colorScheme.onSurfaceVariant),
title: Text(S.of(context)!.language),
subtitle: Text(
getLanguageName(localeProvider.locale?.languageCode),
style: TextStyle(color: colorScheme.primary),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(S.of(context)!.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(null);
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('English'),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('en'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('de'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Español'),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('es'));
Navigator.pop(dialogContext);
},
),
],
),
);
},
);
},
);
},
),
const SizedBox(height: 32),
// Player section
Text(
S.of(context)!.players,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.smartSortPlayers,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.smartSortPlayersDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _smartSortPlayers,
onChanged: (value) {
setState(() => _smartSortPlayers = value);
SettingsService.setSmartSortPlayers(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Hints & Tips section
Text(
S.of(context)!.hintsAndTips,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -344,159 +715,124 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Choose which rows to display on the home screen',
S.of(context)!.showHintsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
// Main rows section
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
SwitchListTile(
child: SwitchListTile(
title: Text(
'Recently Played',
S.of(context)!.showHints,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show recently played albums',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showRecentAlbums,
value: _showHints,
onChanged: (value) {
setState(() => _showRecentAlbums = value);
SettingsService.setShowRecentAlbums(value);
setState(() => _showHints = value);
SettingsService.setShowHints(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random artists to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverArtists,
onChanged: (value) {
setState(() => _showDiscoverArtists = value);
SettingsService.setShowDiscoverArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random albums to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAlbums,
onChanged: (value) {
setState(() => _showDiscoverAlbums = value);
SettingsService.setShowDiscoverAlbums(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 32),
// Home Screen section
Text(
'Favorites',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
S.of(context)!.homeScreen,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.chooseHomeScreenRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
// Favorites rows section
// Reorderable home rows list
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
clipBehavior: Clip.antiAlias,
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _homeRowOrder.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
child: Row(
children: [
SwitchListTile(
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Icon(
Icons.drag_handle,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
Expanded(
child: SwitchListTile(
title: Text(
'Favorite Albums',
rowInfo['title']!,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite albums',
rowInfo['subtitle']!,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteAlbums,
onChanged: (value) {
setState(() => _showFavoriteAlbums = value);
SettingsService.setShowFavoriteAlbums(value);
},
value: isEnabled,
onChanged: (value) => _setRowEnabled(rowId, value),
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.only(right: 8),
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Artists',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite artists',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteArtists,
onChanged: (value) {
setState(() => _showFavoriteArtists = value);
SettingsService.setShowFavoriteArtists(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Favorite Tracks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show a row of your favorite tracks',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showFavoriteTracks,
onChanged: (value) {
setState(() => _showFavoriteTracks = value);
SettingsService.setShowFavoriteTracks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
);
},
),
),
const SizedBox(height: 32),
Text(
'Metadata APIs (Optional)',
S.of(context)!.metadataApis,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -504,7 +840,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
S.of(context)!.metadataApisDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -515,8 +851,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _lastFmApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Last.fm API Key',
hintText: 'Get free key at last.fm/api',
labelText: S.of(context)!.lastFmApiKey,
hintText: S.of(context)!.lastFmApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -552,8 +888,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _audioDbApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'TheAudioDB API Key',
hintText: 'Use "2" for free tier or premium key',
labelText: S.of(context)!.theAudioDbApiKey,
hintText: S.of(context)!.theAudioDbApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -585,6 +921,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[
Text(
S.of(context)!.audiobookLibraries,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.chooseAudiobookLibraries,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: _discoveredLibraries.asMap().entries.map((entry) {
final index = entry.key;
final library = entry.value;
final path = library['path'] ?? '';
final name = library['name'] ?? S.of(context)!.unknownLibrary;
final isEnabled = _libraryEnabled[path] ?? true;
return Column(
children: [
SwitchListTile(
title: Text(
name,
style: TextStyle(color: colorScheme.onSurface),
),
value: isEnabled,
onChanged: (value) async {
setState(() {
_libraryEnabled[path] = value;
});
await SettingsService.toggleAbsLibrary(path, value);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.pullToRefresh),
duration: const Duration(seconds: 2),
),
);
}
},
activeColor: colorScheme.primary,
),
if (index < _discoveredLibraries.length - 1)
Divider(
height: 1,
indent: 16,
endIndent: 16,
color: colorScheme.onSurface.withOpacity(0.1),
),
],
);
}).toList(),
),
),
const SizedBox(height: 32),
],
SizedBox(
width: double.infinity,
height: 50,
@@ -598,7 +1004,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
icon: const Icon(Icons.bug_report_rounded),
label: const Text('View Debug Logs'),
label: Text(S.of(context)!.viewDebugLogs),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
foregroundColor: colorScheme.onSurfaceVariant,
@@ -647,17 +1053,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
String _getStatusText(MAConnectionState state) {
final s = S.of(context)!;
switch (state) {
case MAConnectionState.connected:
case MAConnectionState.authenticated:
return 'Connected';
return s.connected;
case MAConnectionState.connecting:
case MAConnectionState.authenticating:
return 'Connecting...';
return s.connecting;
case MAConnectionState.error:
return 'Connection Error';
return s.connectionError;
case MAConnectionState.disconnected:
return 'Disconnected';
return s.disconnected;
}
}
}
+137 -17
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
@@ -31,11 +32,42 @@ class AuthManager {
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = serverUrl;
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// Normalize a server URL with appropriate protocol
String _normalizeUrl(String url) {
var baseUrl = url;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') ||
@@ -44,13 +76,38 @@ class AuthManager {
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
baseUrl = 'https://$baseUrl';
}
}
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
// First, check if this is a Music Assistant server with native auth
_logger.log('Checking for Music Assistant native auth...');
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) {
if (maAuthResult == 'required') {
@@ -124,7 +181,7 @@ class AuthManager {
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null instead of rethrow to show generic error
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
@@ -136,24 +193,61 @@ class AuthManager {
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required
// Note: The command is just 'info', not 'server/info'
final response = await http.post(
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
@@ -213,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
// Test root endpoint - Music Assistant doesn't have /api/info
final testUrl = serverUrl;
_logger.log('No-auth test URL: $testUrl');
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final response = await http.get(
Uri.parse(testUrl),
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
_logger.log('No-auth status: ${response.statusCode}');
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307 = redirect (probably to auth)
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (response.statusCode == 200) {
if (statusCode == 200) {
return true;
}
+113
View File
@@ -1,11 +1,15 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../services/database_service.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();
bool _homeRowsLoaded = false;
// Home screen row caching
List<Album>? _cachedRecentAlbums;
@@ -28,6 +32,8 @@ class CacheService {
final Map<String, DateTime> _searchCacheTime = {};
// Player list caching
List<Player>? _cachedPlayers;
Player? _cachedSelectedPlayer;
DateTime? _playersLastFetched;
// Player track cache (for smooth swipe transitions)
@@ -54,6 +60,8 @@ class CacheService {
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discover artists cache is valid
@@ -73,6 +81,8 @@ class CacheService {
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
}
/// Check if discover albums cache is valid
@@ -92,6 +102,8 @@ class CacheService {
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Invalidate home screen cache (call on pull-to-refresh)
@@ -219,6 +231,26 @@ class CacheService {
_playersLastFetched = DateTime.now();
}
/// Get cached players list
List<Player>? getCachedPlayers() => _cachedPlayers;
/// Set cached players list
void setCachedPlayers(List<Player> players) {
_cachedPlayers = players;
_playersLastFetched = DateTime.now();
}
/// Get cached selected player
Player? getCachedSelectedPlayer() => _cachedSelectedPlayer;
/// Set cached selected player
void setCachedSelectedPlayer(Player? player) {
_cachedSelectedPlayer = player;
}
/// Check if we have cached players (for instant UI display on app resume)
bool get hasCachedPlayers => _cachedPlayers != null && _cachedPlayers!.isNotEmpty;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
@@ -227,6 +259,11 @@ class CacheService {
_playerTrackCache[playerId] = track;
}
/// Clear cached track for a player (e.g., when external source is active)
void clearCachedTrackForPlayer(String playerId) {
_playerTrackCache.remove(playerId);
}
// ============================================================================
// CLEAR ALL
// ============================================================================
@@ -249,6 +286,82 @@ class CacheService {
void clearAll() {
invalidateHomeCache();
clearAllDetailCaches();
_cachedPlayers = null;
_cachedSelectedPlayer = null;
_playersLastFetched = null;
}
// ============================================================================
// DATABASE PERSISTENCE FOR HOME ROWS
// ============================================================================
/// Load home row data from database for instant display on startup
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
try {
if (!DatabaseService.instance.isInitialized) return;
// Load recent albums
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
if (recentData != null) {
try {
final items = (jsonDecode(recentData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedRecentAlbums = items;
_recentAlbumsLastFetched = recentData.lastUpdated;
_logger.log('📦 Loaded ${items.length} recent albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse recent albums: $e');
}
}
// Load discover artists
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
if (artistsData != null) {
try {
final items = (jsonDecode(artistsData.itemsJson) as List)
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverArtists = items;
_discoverArtistsLastFetched = artistsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover artists from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover artists: $e');
}
}
// Load discover albums
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
if (albumsData != null) {
try {
final items = (jsonDecode(albumsData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverAlbums = items;
_discoverAlbumsLastFetched = albumsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover albums: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
}
}
/// Persist home row data to database (fire-and-forget)
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
_logger.log('💾 Persisted $rowType to database');
} catch (e) {
_logger.log('⚠️ Failed to persist $rowType: $e');
}
}();
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'dart:convert';
import '../database/database.dart';
import 'debug_logger.dart';
/// Singleton service for database access throughout the app
class DatabaseService {
static DatabaseService? _instance;
static AppDatabase? _database;
static final _logger = DebugLogger();
DatabaseService._();
/// Get the singleton instance
static DatabaseService get instance {
_instance ??= DatabaseService._();
return _instance!;
}
/// Get the database instance
AppDatabase get db {
if (_database == null) {
throw StateError('Database not initialized. Call initialize() first.');
}
return _database!;
}
/// Check if database is initialized
bool get isInitialized => _database != null;
/// Initialize the database
Future<void> initialize() async {
if (_database != null) {
_logger.log('Database already initialized');
return;
}
_logger.log('Initializing database...');
_database = AppDatabase();
_logger.log('Database initialized successfully');
}
/// Close the database (for cleanup)
Future<void> close() async {
await _database?.close();
_database = null;
_logger.log('Database closed');
}
// ============================================
// Profile Convenience Methods
// ============================================
/// Get the currently active profile
Future<Profile?> getActiveProfile() => db.getActiveProfile();
/// Set the active profile (creates if doesn't exist)
Future<Profile> setActiveProfile({
required String username,
String? displayName,
required String source,
}) {
_logger.log('Setting active profile: $username (source: $source)');
return db.setActiveProfile(
username: username,
displayName: displayName,
source: source,
);
}
/// Get all profiles
Future<List<Profile>> getAllProfiles() => db.getAllProfiles();
// ============================================
// Recently Played Convenience Methods
// ============================================
/// Add an item to recently played
Future<void> addRecentlyPlayed({
required String mediaId,
required String mediaType,
required String name,
String? artistName,
String? imageUrl,
Map<String, dynamic>? metadata,
}) {
_logger.log('Adding to recently played: $name ($mediaType)');
return db.addRecentlyPlayed(
mediaId: mediaId,
mediaType: mediaType,
name: name,
artistName: artistName,
imageUrl: imageUrl,
metadata: metadata != null ? jsonEncode(metadata) : null,
);
}
/// Get recently played items
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) {
return db.getRecentlyPlayed(limit: limit);
}
/// Clear recently played for current profile
Future<void> clearRecentlyPlayed() => db.clearRecentlyPlayed();
// ============================================
// Library Cache Convenience Methods
// ============================================
/// Cache an item with JSON serialization
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
}) {
return db.cacheItem(
itemType: itemType,
itemId: itemId,
data: jsonEncode(data),
);
}
/// Get cached items with JSON deserialization
Future<List<Map<String, dynamic>>> getCachedItems(String itemType) async {
final items = await db.getCachedItems(itemType);
return items.map((item) {
try {
return jsonDecode(item.data) as Map<String, dynamic>;
} catch (e) {
_logger.log('Error decoding cached item: $e');
return <String, dynamic>{};
}
}).where((item) => item.isNotEmpty).toList();
}
/// Get a single cached item
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
final item = await db.getCachedItem(itemType, itemId);
if (item == null) return null;
try {
return jsonDecode(item.data) as Map<String, dynamic>;
} catch (e) {
_logger.log('Error decoding cached item: $e');
return null;
}
}
/// Check if sync is needed
Future<bool> needsSync(String syncType, {Duration maxAge = const Duration(minutes: 5)}) {
return db.needsSync(syncType, maxAge);
}
/// Update sync timestamp
Future<void> updateSyncMetadata(String syncType, int itemCount) {
return db.updateSyncMetadata(syncType, itemCount);
}
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
// ============================================
// Player Cache Convenience Methods
// ============================================
/// Save playback state for instant resume
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) {
return db.savePlaybackState(
playerId: playerId,
playerName: playerName,
currentTrackJson: currentTrackJson,
positionSeconds: positionSeconds,
isPlaying: isPlaying,
);
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
/// Cache a player with optional track info
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) {
return db.cachePlayer(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: currentTrackJson,
);
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
return db.cachePlayers(players);
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
return db.updateCachedPlayerTrack(playerId, trackJson);
}
/// Clear all cached players
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
return db.saveQueue(playerId, itemJsonList);
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
return db.getCachedQueue(playerId);
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
// ============================================
// Home Row Cache Convenience Methods
// ============================================
/// Save home row data (recent albums, discover artists, etc.)
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
return db.saveHomeRowCache(rowType, itemsJson);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
return db.getHomeRowCache(rowType);
}
/// Clear all home row cache
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
// ============================================
// Search History Convenience Methods
// ============================================
/// Save a search query to history
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
/// Get recent search queries (up to 10)
Future<List<String>> getRecentSearches() => db.getRecentSearches();
/// Clear all search history
Future<void> clearSearchHistory() => db.clearSearchHistory();
}
+12
View File
@@ -71,6 +71,18 @@ class ErrorHandler {
);
}
// Unavailable content errors - these won't resolve with retry
if (errorStr.contains('no playable') ||
errorStr.contains('lack available providers') ||
errorStr.contains('no available providers')) {
return ErrorInfo(
type: ErrorType.playback,
userMessage: 'These tracks are unavailable. The music provider may not have them available for streaming.',
technicalMessage: error.toString(),
canRetry: false,
);
}
// Playback errors
if (errorStr.contains('queue') ||
errorStr.contains('player') ||
+145
View File
@@ -254,6 +254,9 @@ class MetadataService {
// Cache for album images
static final Map<String, String?> _albumImageCache = {};
// Cache for author images (audiobooks)
static final Map<String, String?> _authorImageCache = {};
/// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
@@ -323,10 +326,152 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Returns the image URL if found, null otherwise
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Try Audnexus (specifically for audiobook authors, uses Audible data)
try {
final audnexusUri = Uri.https(
'api.audnex.us',
'/authors',
{'name': authorName},
);
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
if (audnexusResponse.statusCode == 200) {
final data = json.decode(audnexusResponse.body);
// Audnexus returns an array of authors
if (data is List && data.isNotEmpty) {
final author = data[0];
final imageUrl = author['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
}
// Fall back to Open Library API
try {
final uri = Uri.https(
'openlibrary.org',
'/search/authors.json',
{
'q': authorName,
'limit': '1',
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final author = authors[0];
final authorKey = author['key'] as String?;
if (authorKey != null) {
// Open Library author images are available at:
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify the image actually exists (Open Library returns 404 for missing images)
try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
} catch (_) {
// Image doesn't exist, continue to cache null
}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error: $e', context: 'Metadata');
}
// Fall back to Wikipedia API
try {
// Search for author on Wikipedia
final searchUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'list': 'search',
'srsearch': '$authorName writer',
'srlimit': '1',
'format': 'json',
},
);
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
},
);
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
final imageData = json.decode(imageResponse.body);
final pages = imageData['query']?['pages'] as Map<String, dynamic>?;
if (pages != null && pages.isNotEmpty) {
final page = pages.values.first as Map<String, dynamic>?;
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
}
}
}
}
} catch (e) {
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_authorImageCache[cacheKey] = null;
return null;
}
/// Clears the metadata cache
static void clearCache() {
_cache.clear();
_artistImageCache.clear();
_albumImageCache.clear();
_authorImageCache.clear();
}
}
+775 -20
View File
@@ -498,6 +498,544 @@ class MusicAssistantAPI {
}
}
Future<List<Audiobook>> getAudiobooks({
int? limit,
int? offset,
String? search,
bool? favoriteOnly,
String? authorId,
}) async {
try {
// Check if library filtering is enabled
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
// If specific libraries are enabled, use browse API for filtering
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
// Otherwise use the faster MA API (returns all audiobooks)
final args = <String, dynamic>{
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (authorId != null) 'author_id': authorId,
};
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
);
// Check for error in response
if (response.containsKey('error_code')) {
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
return [];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('📚 Audiobooks: result is null');
return [];
}
_logger.log('📚 Audiobooks: found ${items.length} items from API');
// Log first item's structure including metadata to find chapters/series
if (items.isNotEmpty) {
final firstItem = items.first as Map<String, dynamic>;
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
// Log metadata contents - this is where chapters/series info likely lives
if (firstItem.containsKey('metadata')) {
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
for (final key in metadata.keys) {
final value = metadata[key];
if (value is List) {
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
} else if (value is Map) {
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
} else if (value != null) {
_logger.log('📚 metadata[$key]: $value');
}
}
}
}
// Check for chapters at top level too
if (firstItem.containsKey('chapters')) {
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
}
}
final audiobooks = <Audiobook>[];
final parseErrors = <String>[];
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book);
} catch (e) {
parseErrors.add('Item $i: $e');
}
}
if (parseErrors.isNotEmpty) {
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
}
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
return audiobooks;
} catch (e, stack) {
_logger.log('Error getting audiobooks: $e');
_logger.log('Stack: $stack');
return [];
}
}
/// Get audiobooks using the browse API with library filtering
Future<List<Audiobook>> _getAudiobooksFromBrowse(
List<String> enabledLibraryPaths, {
bool? favoriteOnly,
}) async {
final allAudiobooks = <Audiobook>[];
for (final libraryPath in enabledLibraryPaths) {
try {
_logger.log('📚 Browsing library: $libraryPath');
// Browse the library to find the Audiobooks folder
final libraryContents = await browse(libraryPath);
// Find the Audiobooks folder (path ends with /b)
String? audiobooksPath;
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
final name = (itemMap['name'] as String? ?? '').toLowerCase();
if (path.endsWith('/b') || name == 'audiobooks') {
audiobooksPath = path;
_logger.log('📚 Found Audiobooks folder: $audiobooksPath');
break;
}
}
if (audiobooksPath == null) {
_logger.log('📚 No Audiobooks folder found in library $libraryPath');
continue;
}
// Browse the Audiobooks folder
final audiobookItems = await browse(audiobooksPath);
_logger.log('📚 Found ${audiobookItems.length} items in Audiobooks folder');
for (final item in audiobookItems) {
final itemMap = item as Map<String, dynamic>;
final mediaType = itemMap['media_type'] as String?;
final name = itemMap['name'] as String? ?? '';
// Skip navigation items
if (name == '..') continue;
// Only include audiobooks
if (mediaType == 'audiobook') {
try {
final book = Audiobook.fromJson(itemMap);
// Apply favorite filter if requested
if (favoriteOnly == true && book.favorite != true) {
continue;
}
allAudiobooks.add(book);
} catch (e) {
_logger.log('📚 Error parsing audiobook "$name": $e');
}
}
}
} catch (e) {
_logger.log('📚 Error browsing library $libraryPath: $e');
}
}
_logger.log('📚 Total audiobooks from browse: ${allAudiobooks.length}');
return allAudiobooks;
}
/// Play an audiobook
Future<void> playAudiobook(String playerId, Audiobook audiobook) async {
try {
// Build the audiobook URI
final uri = audiobook.uri ?? 'library://audiobook/${audiobook.itemId}';
_logger.log('📚 Playing audiobook: ${audiobook.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing audiobook: $e');
rethrow;
}
}
/// Get full audiobook details including chapters
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
try {
_logger.log('📚 Getting audiobook details: provider=$provider, itemId=$itemId');
// Try different API patterns to find the correct one
final apiPatterns = [
{'cmd': 'music/get_item', 'args': {'media_type': 'audiobook', 'item_id': itemId, 'provider_instance_id_or_domain': provider}},
{'cmd': 'music/audiobooks/get', 'args': {'item_id': itemId, 'provider_instance_id_or_domain': provider}},
{'cmd': 'music/item/audiobook/$itemId', 'args': <String, dynamic>{}},
];
Map<String, dynamic>? response;
String? successCmd;
for (final pattern in apiPatterns) {
final cmd = pattern['cmd'] as String;
final args = pattern['args'] as Map<String, dynamic>;
_logger.log('📚 Trying: $cmd');
try {
response = await _sendCommand(cmd, args: args);
if (!response.containsKey('error_code')) {
successCmd = cmd;
_logger.log('📚 SUCCESS with: $cmd');
break;
} else {
_logger.log('📚 Failed: ${response['error_code']}');
}
} catch (e) {
_logger.log('📚 Exception with $cmd: $e');
}
}
if (successCmd == null || response == null || response.containsKey('error_code')) {
_logger.log('📚 All API patterns failed, using list data instead');
// Fall back to fetching from list (which includes metadata)
final audiobooks = await getAudiobooks(limit: 10000);
final match = audiobooks.where((b) => b.itemId == itemId).firstOrNull;
if (match != null) {
_logger.log('📚 Found in list: ${match.name}');
return match;
}
return null;
}
final result = response['result'];
if (result == null) {
_logger.log('📚 Audiobook details: result is null');
return null;
}
// Log raw response to understand what MA returns
final resultMap = result as Map<String, dynamic>;
_logger.log('📚 ============ AUDIOBOOK DETAILS RAW RESPONSE ============');
_logger.log('📚 All keys: ${resultMap.keys.toList()}');
// Log each field for discovery
for (final key in resultMap.keys) {
final value = resultMap[key];
if (value is List) {
_logger.log('📚 [$key] (List, ${value.length} items): ${value.take(2)}...');
} else if (value is Map) {
_logger.log('📚 [$key] (Map): ${(value as Map).keys.toList()}');
} else {
_logger.log('📚 [$key]: $value');
}
}
// Check for chapters specifically
if (resultMap.containsKey('chapters')) {
final chapters = resultMap['chapters'];
_logger.log('📚 CHAPTERS FOUND! Type: ${chapters.runtimeType}');
if (chapters is List && chapters.isNotEmpty) {
_logger.log('📚 First chapter: ${chapters.first}');
}
} else {
_logger.log('📚 NO chapters field! Checking alternatives...');
final possibleFields = ['chapters', 'chapter', 'tracks', 'parts', 'segments', 'media_items', 'items', 'content'];
for (final field in possibleFields) {
if (resultMap.containsKey(field)) {
_logger.log('📚 Alternative field "$field" found: ${resultMap[field]}');
}
}
}
// Check for series info
if (resultMap.containsKey('metadata')) {
final metadata = resultMap['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
for (final key in metadata.keys) {
_logger.log('📚 metadata[$key]: ${metadata[key]}');
}
}
}
_logger.log('📚 ============ END RAW RESPONSE ============');
final audiobook = Audiobook.fromJson(resultMap);
_logger.log('📚 Parsed audiobook: ${audiobook.name}, chapters: ${audiobook.chapters?.length ?? 0}');
return audiobook;
} catch (e, stack) {
_logger.log('📚 Error getting audiobook details: $e');
_logger.log('📚 Stack: $stack');
return null;
}
}
/// Browse Music Assistant content by path
/// Returns a list of browse items (folders or media items)
Future<List<dynamic>> browse(String? path) async {
try {
_logger.log('📚 Browse: path=$path');
final response = await _sendCommand(
'music/browse',
args: path != null ? {'path': path} : <String, dynamic>{},
);
if (response.containsKey('error_code')) {
_logger.log('📚 Browse error: ${response['error_code']}');
return [];
}
final result = response['result'] as List<dynamic>?;
if (result == null) {
_logger.log('📚 Browse: result is null');
return [];
}
_logger.log('📚 Browse: found ${result.length} items');
// Log first few items for debugging
for (var i = 0; i < result.length && i < 3; i++) {
final item = result[i] as Map<String, dynamic>;
_logger.log('📚 Browse[$i]: ${item.keys.toList()}');
_logger.log('📚 Browse[$i]: path=${item['path']}, name=${item['name']}, label=${item['label']}');
}
return result;
} catch (e) {
_logger.log('📚 Browse error: $e');
return [];
}
}
/// Get the audiobookshelf provider instance ID
Future<String?> getAudiobookshelfProviderId() async {
try {
// Get an audiobook and extract the provider instance from it
final audiobooks = await getAudiobooks(limit: 1);
if (audiobooks.isEmpty) return null;
final book = audiobooks.first;
final mapping = book.providerMappings?.firstWhere(
(m) => m.providerDomain == 'audiobookshelf',
orElse: () => book.providerMappings!.first,
);
return mapping?.providerInstance;
} catch (e) {
_logger.log('📚 Error getting ABS provider ID: $e');
return null;
}
}
/// Get audiobook series from the browse API
Future<List<AudiobookSeries>> getAudiobookSeries() async {
try {
_logger.log('📚 Getting audiobook series...');
// First, browse the root to discover available providers
final rootItems = await browse(null);
_logger.log('📚 Browse root has ${rootItems.length} items');
// Find audiobookshelf provider in browse results
String? providerPath;
for (final item in rootItems) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
if (path.contains('audiobookshelf')) {
providerPath = path;
_logger.log('📚 Found audiobookshelf provider at: $providerPath');
break;
}
}
if (providerPath == null) {
_logger.log('📚 No audiobookshelf provider found in browse root');
return [];
}
// Browse the provider to find libraries
final providerRoot = await browse(providerPath);
_logger.log('📚 Provider has ${providerRoot.length} items (libraries)');
// Discover and save libraries
final discoveredLibraries = <Map<String, String>>[];
for (final item in providerRoot) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
final name = itemMap['name'] as String? ?? '';
if (path != 'root' && name != '..') {
discoveredLibraries.add({'path': path, 'name': name});
}
}
if (discoveredLibraries.isNotEmpty) {
await SettingsService.setDiscoveredAbsLibraries(discoveredLibraries);
_logger.log('📚 Saved ${discoveredLibraries.length} discovered libraries');
}
// Get enabled libraries (null means all enabled)
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
// Collect series from enabled libraries only
final allSeries = <AudiobookSeries>[];
for (final libraryItem in providerRoot) {
final libraryMap = libraryItem as Map<String, dynamic>;
final libraryPath = libraryMap['path'] as String? ?? '';
final libraryName = libraryMap['name'] as String? ?? '';
// Skip "root" (parent navigation) item
if (libraryPath == 'root' || libraryName == '..') continue;
// Check if this library is enabled
if (enabledLibraries != null && !enabledLibraries.contains(libraryPath)) {
_logger.log('📚 Skipping disabled library: $libraryName');
continue;
}
_logger.log('📚 Checking library: $libraryName at $libraryPath');
// Browse into the library to find categories
final libraryContents = await browse(libraryPath);
_logger.log('📚 Library "$libraryName" has ${libraryContents.length} items');
// Log library contents to find series
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = itemMap['name'] as String? ?? '';
final path = itemMap['path'] as String? ?? '';
_logger.log('📚 Library item: name=$name, path=$path');
}
// Look for "Series" folder in the library
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = (itemMap['name'] as String? ?? '').toLowerCase();
final path = itemMap['path'] as String? ?? '';
if (name.contains('series') || path.contains('series')) {
_logger.log('📚 Found series folder: $path');
// Browse the series folder
final seriesItems = await browse(path);
_logger.log('📚 Found ${seriesItems.length} series in library "$libraryName"');
for (final seriesItem in seriesItems) {
final seriesMap = seriesItem as Map<String, dynamic>;
// Skip parent navigation
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
// Skip actual audiobook items (we only want series folders)
final mediaType = seriesMap['media_type'] as String?;
if (mediaType == 'audiobook' || mediaType == 'track') continue;
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
allSeries.add(AudiobookSeries.fromJson(seriesMap));
}
break; // Found series in this library, move to next library
}
}
}
_logger.log('📚 Total series found: ${allSeries.length}');
return allSeries;
} catch (e, stack) {
_logger.log('📚 Error getting series: $e');
_logger.log('📚 Stack: $stack');
return [];
}
}
/// Get audiobooks in a specific series
Future<List<Audiobook>> getSeriesAudiobooks(String seriesPath) async {
try {
_logger.log('📚 Getting audiobooks for series: $seriesPath');
_logger.log('📚 About to call browse...');
final items = await browse(seriesPath);
_logger.log('📚 Browse returned ${items.length} items');
final audiobooks = <Audiobook>[];
for (var i = 0; i < items.length; i++) {
final item = items[i];
_logger.log('📚 Processing item $i: ${item.runtimeType}');
if (item is! Map<String, dynamic>) {
_logger.log('📚 Item $i is not a Map, skipping');
continue;
}
final itemMap = item;
final mediaType = itemMap['media_type'];
_logger.log('📚 Item $i: media_type=$mediaType, name=${itemMap['name']}');
// Only include audiobooks (items with media_type)
if (mediaType == 'audiobook') {
_logger.log('📚 Parsing audiobook $i...');
// Debug: Log key fields for sequence detection
_logger.log('📚 Raw data $i: position=${itemMap['position']}, sort_name=${itemMap['sort_name']}, metadata.series=${(itemMap['metadata'] as Map?)?['series']}');
try {
// Inject browse_order as fallback for series sequencing
// This preserves the order returned by MA/Audiobookshelf browse API
if (itemMap['position'] == null) {
itemMap['_browse_order'] = i + 1; // 1-indexed like series numbers
}
audiobooks.add(Audiobook.fromJson(itemMap));
_logger.log('📚 Parsed audiobook $i successfully');
} catch (e) {
_logger.log('📚 Error parsing audiobook $i: $e');
}
}
}
_logger.log('📚 Found ${audiobooks.length} audiobooks in series');
return audiobooks;
} catch (e, stack) {
_logger.log('📚 Error getting series audiobooks: $e');
_logger.log('📚 Stack: $stack');
return [];
}
}
/// Legacy method - now uses browse API
@Deprecated('Use getAudiobookSeries instead')
Future<void> exploreSeries() async {
_logger.log('📚 exploreSeries is deprecated, use getAudiobookSeries instead');
final series = await getAudiobookSeries();
_logger.log('📚 Found ${series.length} series via browse API');
}
/// Get recently played albums
/// Gets recently played tracks, then fetches full track details to extract album info
/// Optimized: batches track lookups to avoid N+1 query problem
@@ -531,16 +1069,14 @@ class MusicAssistantAPI {
return [];
}
// Batch fetch track details using parallel requests (max 5 concurrent)
final seenAlbumIds = <String>{};
final albums = <Album>[];
// Step 1: Fetch tracks in batches and collect unique album URIs
final seenAlbumUris = <String>{};
final albumUris = <String>[];
// Process in batches to avoid overwhelming the server
const batchSize = LibraryConstants.recentAlbumsBatchSize;
for (var i = 0; i < trackUris.length && albums.length < limit; i += batchSize) {
for (var i = 0; i < trackUris.length && albumUris.length < limit; i += batchSize) {
final batch = trackUris.skip(i).take(batchSize).toList();
// Fetch batch in parallel
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
@@ -549,27 +1085,52 @@ class MusicAssistantAPI {
final results = await Future.wait(futures, eagerError: false);
for (final trackResponse in results) {
if (albums.length >= limit) break;
if (albumUris.length >= limit) break;
try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?;
if (albumData != null) {
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
if (albumId != null && !seenAlbumIds.contains(albumId)) {
seenAlbumIds.add(albumId);
albums.add(Album.fromJson(albumData));
}
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri);
albumUris.add(albumUri);
}
} catch (_) {
// Skip tracks that fail to parse
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums using ${(trackUris.length / batchSize).ceil()} batch requests');
if (albumUris.isEmpty) {
return [];
}
// Step 2: Fetch full album details (with images) for each album URI
final albums = <Album>[];
for (var i = 0; i < albumUris.length; i += batchSize) {
final batch = albumUris.skip(i).take(batchSize).toList();
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
final results = await Future.wait(futures, eagerError: false);
for (final albumResponse in results) {
try {
final albumData = albumResponse['result'] as Map<String, dynamic>?;
if (albumData != null) {
albums.add(Album.fromJson(albumData));
}
} catch (_) {
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums with full details');
return albums;
} catch (e) {
_logger.log('❌ Error getting recent albums: $e');
@@ -838,7 +1399,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
// Parse results from the search response
@@ -857,6 +1418,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final playlists = (result['playlists'] as List<dynamic>?)
?.map((item) => Playlist.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final audiobooks = (result['audiobooks'] as List<dynamic>?)
?.map((item) => Audiobook.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -864,15 +1435,17 @@ class MusicAssistantAPI {
'artists': _deduplicateResults(artists),
'albums': _deduplicateResults(albums),
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
});
}
@@ -1041,11 +1614,43 @@ class MusicAssistantAPI {
}
}
/// Add a single track to the queue without starting playback
Future<void> addTrackToQueue(String playerId, Track track) async {
try {
final uri = _buildTrackUri(track);
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add', // Add to queue without starting
},
);
} catch (e) {
_logger.log('Error adding track to queue: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
return await RetryHelper.retryCritical(
return await RetryHelper.retry(
maxAttempts: 3,
initialDelaySeconds: 1,
maxDelaySeconds: 4,
// Don't retry errors about missing/unavailable tracks - those won't resolve with retries
shouldRetry: (error) {
final errorStr = error.toString().toLowerCase();
if (errorStr.contains('no playable') ||
errorStr.contains('no tracks') ||
errorStr.contains('lack available')) {
return false;
}
return true;
},
operation: () async {
// If startIndex is provided, slice the tracks list to start from that index
// This is a workaround since Music Assistant ignores the start_item parameter
@@ -1053,8 +1658,36 @@ class MusicAssistantAPI {
? tracks.sublist(startIndex)
: tracks;
if (tracksToPlay.isEmpty) {
throw Exception('No tracks to play');
}
// Filter to only tracks with available provider mappings
final playableTracks = tracksToPlay.where((track) {
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
_logger.log('⚠️ Track "${track.name}" has no provider mappings');
return false;
}
final hasAvailable = track.providerMappings!.any((m) => m.available);
if (!hasAvailable) {
_logger.log('⚠️ Track "${track.name}" has no available providers');
}
return hasAvailable;
}).toList();
if (playableTracks.isEmpty) {
_logger.log('❌ No playable tracks found (${tracksToPlay.length} tracks had no available providers)');
throw Exception('No playable tracks - all ${tracksToPlay.length} tracks lack available providers');
}
if (playableTracks.length < tracksToPlay.length) {
_logger.log('⚠️ ${tracksToPlay.length - playableTracks.length} tracks skipped (no available providers)');
}
// Build array of URI strings (not objects!)
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
final mediaUris = playableTracks.map((track) => _buildTrackUri(track)).toList();
_logger.log('🎵 Playing ${mediaUris.length} tracks: ${mediaUris.take(3).join(", ")}${mediaUris.length > 3 ? "..." : ""}');
final option = clearQueue ? 'replace' : 'play';
@@ -1394,6 +2027,45 @@ class MusicAssistantAPI {
}
}
/// Sync a player to a leader/group (temporary group)
/// The player will join the leader's sync group and play the same audio
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
try {
_logger.log('🔗 API syncPlayerToLeader called');
_logger.log('🔗 player_id: $playerId');
_logger.log('🔗 target_player: $leaderPlayerId');
final response = await _sendCommand(
'players/cmd/group',
args: {
'player_id': playerId,
'target_player': leaderPlayerId,
},
);
_logger.log('✅ Player synced successfully, response: $response');
} catch (e) {
_logger.log('❌ Error syncing player: $e');
rethrow;
}
}
/// Remove a player from its sync group
Future<void> unsyncPlayer(String playerId) async {
try {
_logger.log('🔓 Unsyncing player: $playerId');
await _sendCommand(
'players/cmd/ungroup',
args: {
'player_id': playerId,
},
);
_logger.log('✅ Player unsynced successfully');
} catch (e) {
_logger.log('Error unsyncing player: $e');
rethrow;
}
}
/// Toggle shuffle mode for queue
Future<void> toggleShuffle(String queueId) async {
try {
@@ -1458,6 +2130,14 @@ class MusicAssistantAPI {
return _eventStreams['player_updated']!.stream;
}
/// Stream of player_added events (for refreshing player list when new players join)
Stream<Map<String, dynamic>> get playerAddedEvents {
if (!_eventStreams.containsKey('player_added')) {
_eventStreams['player_added'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['player_added']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
@@ -1703,6 +2383,81 @@ class MusicAssistantAPI {
}
}
// ============================================================================
// SENDSPIN PLAYER MANAGEMENT (MA 2.7.0b20+ replacement for builtin_player)
// ============================================================================
/// Stream of Sendspin player events
Stream<Map<String, dynamic>> get sendspinPlayerEvents {
if (!_eventStreams.containsKey('sendspin_player')) {
_eventStreams['sendspin_player'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['sendspin_player']!.stream;
}
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
// - getSendspinConnectionInfo (sendspin/connection_info)
// - sendspinOffer (sendspin/webrtc_offer)
// - sendspinAnswer (sendspin/webrtc_answer)
// - sendspinIceCandidate (sendspin/ice_candidate)
//
// Sendspin connection is handled directly via WebSocket to:
// - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
/// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
Future<void> updateSendspinPlayerState(
String playerId, {
required bool powered,
required bool playing,
required bool paused,
required int position,
required int volume,
required bool muted,
}) async {
try {
_logger.log('Sendspin: Updating player state: powered=$powered, playing=$playing, paused=$paused');
await _sendCommand(
'sendspin/update_state',
args: {
'player_id': playerId,
'state': {
'powered': powered,
'playing': playing,
'paused': paused,
'position': position,
'volume': volume,
'muted': muted,
},
},
);
} catch (e) {
_logger.log('Sendspin: Error updating player state: $e');
// Non-fatal, don't rethrow
}
}
/// Disconnect Sendspin player from server
Future<void> disconnectSendspinPlayer(String playerId) async {
try {
_logger.log('Sendspin: Disconnecting player $playerId');
await _sendCommand(
'sendspin/disconnect',
args: {
'player_id': playerId,
},
);
} catch (e) {
_logger.log('Sendspin: Error disconnecting player: $e');
// Non-fatal during cleanup
}
}
// ============================================================================
// END SENDSPIN PLAYER MANAGEMENT
// ============================================================================
Future<void> _sendQueueCommand(String queueId, String command) async {
try {
final response = await _sendCommand(
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
/// Represents a queued action that will be executed when connection is restored
class QueuedAction {
final String type;
final Map<String, dynamic> params;
final DateTime queuedAt;
QueuedAction({
required this.type,
required this.params,
required this.queuedAt,
});
Map<String, dynamic> toJson() => {
'type': type,
'params': params,
'queuedAt': queuedAt.toIso8601String(),
};
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
type: json['type'] as String,
params: json['params'] as Map<String, dynamic>,
queuedAt: DateTime.parse(json['queuedAt'] as String),
);
}
/// Service for queuing actions when offline and executing them when back online
class OfflineActionQueue {
static OfflineActionQueue? _instance;
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
OfflineActionQueue._();
final _logger = DebugLogger();
static const _prefsKey = 'offline_action_queue';
List<QueuedAction> _queue = [];
bool _isProcessing = false;
/// Get the current queue (for display)
List<QueuedAction> get queue => List.unmodifiable(_queue);
/// Check if there are pending actions
bool get hasPendingActions => _queue.isNotEmpty;
/// Number of pending actions
int get pendingCount => _queue.length;
/// Initialize and load persisted queue
Future<void> initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString(_prefsKey);
if (queueJson != null) {
final List<dynamic> decoded = jsonDecode(queueJson);
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
}
} catch (e) {
_logger.log('⚠️ Error loading offline action queue: $e');
}
}
/// Queue an action for later execution
Future<void> queueAction(String type, Map<String, dynamic> params) async {
final action = QueuedAction(
type: type,
params: params,
queuedAt: DateTime.now(),
);
_queue.add(action);
await _persistQueue();
_logger.log('📋 Queued offline action: $type');
}
/// Process all queued actions (call when connection is restored)
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true;
_logger.log('🔄 Processing ${_queue.length} offline actions...');
final processed = <QueuedAction>[];
for (final action in _queue) {
try {
final success = await executor(action);
if (success) {
processed.add(action);
_logger.log('✅ Processed offline action: ${action.type}');
} else {
_logger.log('⚠️ Failed to process offline action: ${action.type}');
}
} catch (e) {
_logger.log('❌ Error processing offline action: $e');
}
}
// Remove processed actions
_queue.removeWhere((a) => processed.contains(a));
await _persistQueue();
_isProcessing = false;
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
}
/// Clear all queued actions
Future<void> clearQueue() async {
_queue.clear();
await _persistQueue();
_logger.log('🗑️ Offline action queue cleared');
}
/// Persist queue to storage
Future<void> _persistQueue() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_queue.isEmpty) {
await prefs.remove(_prefsKey);
} else {
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
await prefs.setString(_prefsKey, queueJson);
}
} catch (e) {
_logger.log('⚠️ Error persisting offline action queue: $e');
}
}
}
/// Action types for offline queuing
class OfflineActionTypes {
static const String toggleFavorite = 'toggle_favorite';
static const String addToPlaylist = 'add_to_playlist';
static const String removeFromPlaylist = 'remove_from_playlist';
}
+676
View File
@@ -0,0 +1,676 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show unawaited;
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart' as pcm;
import 'debug_logger.dart';
/// Callback type for elapsed time updates
typedef ElapsedTimeCallback = void Function(Duration elapsed);
/// Audio format configuration matching Sendspin protocol
class PcmAudioFormat {
final int sampleRate;
final int channels;
final int bitDepth;
const PcmAudioFormat({
this.sampleRate = 48000,
this.channels = 2,
this.bitDepth = 16,
});
/// Default Sendspin format: 48kHz, stereo, 16-bit PCM
static const sendspin = PcmAudioFormat();
}
/// Player state for PCM audio
enum PcmPlayerState {
idle,
initializing,
ready,
playing,
pausing, // Transitional state: pause requested, waiting for completion
paused,
resuming, // Transitional state: resume requested, re-initializing
stopping, // Transitional state: stop requested, cleaning up
error,
}
/// Error types for PCM player operations
enum PcmPlayerError {
initializationFailed,
feedFailed,
pauseFailed,
resumeFailed,
streamError,
releaseTimeout,
}
/// Callback type for error events
typedef PcmErrorCallback = void Function(PcmPlayerError error, String message);
/// Service to play raw PCM audio data from Sendspin WebSocket stream
/// Uses flutter_pcm_sound plugin for low-level PCM playback
///
/// State Machine:
/// - idle → initializing → ready → playing ↔ paused
/// - Any state can transition to error
/// - Transitional states (pausing, resuming, stopping) block operations
class PcmAudioPlayer {
final _logger = DebugLogger();
PcmPlayerState _state = PcmPlayerState.idle;
PcmAudioFormat _format = PcmAudioFormat.sendspin;
StreamSubscription<Uint8List>? _audioSubscription;
// Audio buffer for smooth playback
final List<Uint8List> _audioBuffer = [];
bool _isFeeding = false;
bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures
PcmErrorCallback? onError;
// Maximum chunks to feed at once to keep native buffer small (~200ms)
// Each chunk is ~25ms of audio, so 8 chunks = ~200ms
// Reduced from 12 (300ms) to 8 for faster pause response
static const int _maxChunksPerFeed = 8;
// Feed threshold - lower = faster pause but more risk of underruns
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
static const int _feedThreshold = 5000;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
// Elapsed time tracking for notification sync
Timer? _elapsedTimeTimer;
final _elapsedTimeController = StreamController<Duration>.broadcast();
ElapsedTimeCallback? onElapsedTimeUpdate;
// Track offset for pause/resume (preserves position across pause cycles)
int _bytesPlayedAtLastPause = 0;
DateTime? _playbackStartTime;
PcmPlayerState get state => _state;
bool get isPlaying => _state == PcmPlayerState.playing;
bool get isPaused => _state == PcmPlayerState.paused;
bool get isReady => _state == PcmPlayerState.ready || _state == PcmPlayerState.playing || _state == PcmPlayerState.paused;
int get framesPlayed => _framesPlayed;
int get bytesPlayed => _bytesPlayed;
/// Check if player is in a transitional state (operation in progress)
bool get isTransitioning => _state == PcmPlayerState.pausing ||
_state == PcmPlayerState.resuming ||
_state == PcmPlayerState.stopping;
/// Check if feeding should be blocked
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
/// Stream of elapsed time updates (emits every 500ms when playing)
Stream<Duration> get elapsedTimeStream => _elapsedTimeController.stream;
/// Calculate elapsed playback time from bytes played
/// For 48kHz stereo 16-bit: 4 bytes per frame, 48000 frames per second
Duration get elapsedTime {
// Bytes per frame = channels * (bitDepth / 8) = 2 * 2 = 4
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
final framesFromBytes = _bytesPlayed / bytesPerFrame;
final elapsedSeconds = framesFromBytes / _format.sampleRate;
return Duration(milliseconds: (elapsedSeconds * 1000).round());
}
/// Get elapsed time in seconds (for convenience)
double get elapsedTimeSeconds {
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
final framesFromBytes = _bytesPlayed / bytesPerFrame;
return framesFromBytes / _format.sampleRate;
}
/// Initialize the PCM player with the given format
Future<bool> initialize({PcmAudioFormat? format}) async {
if (_state == PcmPlayerState.initializing) return false;
_format = format ?? PcmAudioFormat.sendspin;
_state = PcmPlayerState.initializing;
try {
_logger.log('PcmAudioPlayer: Initializing (${_format.sampleRate}Hz, ${_format.channels}ch, ${_format.bitDepth}bit)');
// Setup flutter_pcm_sound with Sendspin audio format
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
// Set feed threshold - request more data when buffer has fewer frames
// Lower threshold = lower latency but more risk of underruns
// Using _feedThreshold (5000 frames = ~104ms) for faster pause response
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
// Set up feed callback for when buffer needs more data
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
// Set log level for debugging
await pcm.FlutterPcmSound.setLogLevel(pcm.LogLevel.standard);
_state = PcmPlayerState.ready;
_logger.log('PcmAudioPlayer: Initialized successfully');
return true;
} catch (e) {
_logger.log('PcmAudioPlayer: Initialization failed: $e');
_state = PcmPlayerState.error;
return false;
}
}
/// Callback when flutter_pcm_sound needs more audio data
void _onFeedRequested(int remainingFrames) {
// This is called from native when buffer is getting low
// Block feeding during pause, transitional states, or when not playing
if (_state != PcmPlayerState.playing) return;
if (_shouldBlockFeeding) return;
if (_audioBuffer.isEmpty || _isFeeding) return;
_feedNextChunk();
}
/// Connect to a Sendspin audio data stream and start playback
Future<bool> connectToStream(Stream<Uint8List> audioStream) async {
if (_state == PcmPlayerState.error || _state == PcmPlayerState.idle) {
_logger.log('PcmAudioPlayer: Cannot connect - player not initialized');
return false;
}
// Cancel any existing subscription
await _audioSubscription?.cancel();
_logger.log('PcmAudioPlayer: Connecting to audio stream');
// Subscribe to the audio stream
_audioSubscription = audioStream.listen(
_onAudioData,
onError: _onStreamError,
onDone: _onStreamDone,
);
return true;
}
/// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) {
// Ignore data if in error state
if (_state == PcmPlayerState.error) return;
// Handle audio arriving during paused/transitional states
// This can happen when audio frames arrive before stream/start message (race condition)
// We need to auto-recover in this case
if (_shouldBlockFeeding) {
// If we're paused and audio is arriving, this is likely a new stream
// starting before the stream/start message. Queue for auto-recovery.
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_audioBuffer.add(audioData);
// Trigger async recovery
_autoRecoverFromPause();
return;
}
// If already recovering or in transitional state, buffer the data
if (_isAutoRecovering) {
_audioBuffer.add(audioData);
return;
}
// For other blocking states (pausing, stopping, resuming), ignore
return;
}
// Add to buffer
_audioBuffer.add(audioData);
// Start playback if not already started
if (!_isStarted && _state == PcmPlayerState.ready) {
_startPlayback();
}
// Feed data if not currently feeding
if (!_isFeeding && _isStarted) {
_feedNextChunk();
}
}
/// Auto-recover from paused state when audio arrives unexpectedly
Future<void> _autoRecoverFromPause() async {
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
try {
// Re-initialize the native player
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
// Start feeding buffered data
_isAutoRecovering = false;
if (_audioBuffer.isNotEmpty && !_isFeeding) {
_feedNextChunk();
}
} catch (e) {
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
_isAutoRecovering = false;
_state = PcmPlayerState.error;
_emitError(PcmPlayerError.resumeFailed, e.toString());
}
}
/// Start audio playback
Future<void> _startPlayback() async {
if (_isStarted) return;
try {
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_playbackStartTime = DateTime.now();
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Started playback');
} catch (e) {
_logger.log('PcmAudioPlayer: Error starting playback: $e');
_state = PcmPlayerState.error;
}
}
/// Start the elapsed time update timer
void _startElapsedTimeTimer() {
_elapsedTimeTimer?.cancel();
_elapsedTimeTimer = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _emitElapsedTime(),
);
// Emit immediately
_emitElapsedTime();
}
/// Stop the elapsed time update timer
void _stopElapsedTimeTimer() {
_elapsedTimeTimer?.cancel();
_elapsedTimeTimer = null;
}
/// Emit the current elapsed time to listeners
void _emitElapsedTime() {
if (_state != PcmPlayerState.playing) return;
final elapsed = elapsedTime;
if (!_elapsedTimeController.isClosed) {
_elapsedTimeController.add(elapsed);
}
onElapsedTimeUpdate?.call(elapsed);
}
/// Feed the next chunk of audio data to the player
/// CRITICAL: Only feeds limited chunks to keep native buffer small (~200ms)
/// This enables faster pause response by minimizing buffered audio
Future<void> _feedNextChunk() async {
if (_isFeeding || _audioBuffer.isEmpty || _shouldBlockFeeding) return;
_isFeeding = true;
_feedCompleter = Completer<void>();
try {
int chunksProcessed = 0;
// CRITICAL: Only feed limited chunks to keep native buffer small
// This is the key to fast pause - we drain ~200ms instead of 5-8 seconds
while (_audioBuffer.isNotEmpty &&
chunksProcessed < _maxChunksPerFeed &&
_state == PcmPlayerState.playing &&
!_shouldBlockFeeding) {
final chunk = _audioBuffer.removeAt(0);
// Convert Uint8List (raw bytes) to Int16 samples
// Sendspin sends 16-bit little-endian PCM
final samples = _bytesToInt16List(chunk);
// Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) {
try {
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
} catch (feedError) {
// Auto-recover from "must call setup first" error
// This happens when audio frames arrive before stream/start message
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
_isAutoRecovering = true;
try {
// Re-initialize the native player
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Auto-recovery successful');
// Retry the feed with the current chunk
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
} catch (recoveryError) {
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
_isAutoRecovering = false;
rethrow;
}
_isAutoRecovering = false;
} else {
rethrow;
}
}
// Don't update stats if we got paused during the feed
if (!_shouldBlockFeeding) {
_framesPlayed++;
_bytesPlayed += chunk.length;
chunksProcessed++;
// Log periodically
if (_framesPlayed % 100 == 0) {
_logger.log('PcmAudioPlayer: Played $_framesPlayed frames (${(_bytesPlayed / 1024).toStringAsFixed(1)} KB)');
}
}
}
// Yield to event loop mid-feed to allow UI to respond
if (chunksProcessed % 4 == 0) {
await Future.delayed(Duration.zero);
}
}
} catch (e) {
// Don't log errors during pause/transition - expected when player is released
if (!_shouldBlockFeeding) {
_logger.log('PcmAudioPlayer: Error feeding audio: $e');
_emitError(PcmPlayerError.feedFailed, e.toString());
}
}
_isFeeding = false;
_feedCompleter?.complete();
_feedCompleter = null;
}
/// Emit an error to the callback
void _emitError(PcmPlayerError error, String message) {
_logger.log('PcmAudioPlayer: Error - $error: $message');
onError?.call(error, message);
}
/// Convert raw bytes (Uint8List) to Int16 samples
/// Assumes little-endian 16-bit PCM
List<int> _bytesToInt16List(Uint8List bytes) {
if (bytes.length < 2) return [];
final byteData = ByteData.sublistView(bytes);
final samples = <int>[];
for (int i = 0; i < bytes.length - 1; i += 2) {
samples.add(byteData.getInt16(i, Endian.little));
}
return samples;
}
/// Handle stream errors - notify listeners and pause playback
void _onStreamError(dynamic error) {
_logger.log('PcmAudioPlayer: Stream error: $error');
_emitError(PcmPlayerError.streamError, error.toString());
// Pause on stream error to prevent playback of corrupted/incomplete audio
if (_state == PcmPlayerState.playing) {
_logger.log('PcmAudioPlayer: Pausing due to stream error');
pause();
}
}
/// Handle stream completion
void _onStreamDone() {
_logger.log('PcmAudioPlayer: Audio stream ended');
// Don't stop immediately - let buffered audio finish
}
/// Start playback (if paused or ready)
/// Returns true if playback started successfully
Future<bool> play() async {
if (_state == PcmPlayerState.error) return false;
if (isTransitioning) {
_logger.log('PcmAudioPlayer: Cannot play - operation in progress (state: $_state)');
return false;
}
if (_state == PcmPlayerState.paused) {
// Resume from pause - need to re-initialize since we released on pause
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
_state = PcmPlayerState.resuming;
// Re-initialize the PCM player (it was released on pause)
try {
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
} catch (e) {
_logger.log('PcmAudioPlayer: Error re-initializing on resume: $e');
_emitError(PcmPlayerError.resumeFailed, e.toString());
_state = PcmPlayerState.error;
return false;
}
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
// Resume feeding - new audio will come from stream
if (!_isFeeding && _audioBuffer.isNotEmpty) {
_feedNextChunk();
}
_logger.log('PcmAudioPlayer: Resumed playback');
return true;
} else if (_state == PcmPlayerState.ready) {
await _startPlayback();
_logger.log('PcmAudioPlayer: Started playback');
// Start feeding
if (!_isFeeding && _audioBuffer.isNotEmpty) {
_feedNextChunk();
}
return true;
}
return false;
}
/// Pause playback - stops feeding and releases player for instant stop
/// Position is preserved via _bytesPlayed tracking
/// Uses release() to clear native buffer for instant audio stop
/// Returns true if pause was successful
Future<bool> pause() async {
if (_state != PcmPlayerState.playing) {
_logger.log('PcmAudioPlayer: Cannot pause - not playing (state: $_state)');
return false;
}
_logger.log('PcmAudioPlayer: Pause requested');
// Set pausing state FIRST to stop feed loop from starting new feeds
_state = PcmPlayerState.pausing;
// Clear our buffer - no more data will be fed
_audioBuffer.clear();
// Clear feed callback to prevent native from triggering more feeds
pcm.FlutterPcmSound.setFeedCallback(null);
// Mark as not feeding
_isFeeding = false;
// Save position and stop timer
_bytesPlayedAtLastPause = _bytesPlayed;
_stopElapsedTimeTimer();
// Schedule release() to clear native buffer - use Future.delayed to yield control
// This allows UI to update before the potentially blocking release() call
unawaited(Future.delayed(Duration.zero, () async {
try {
await pcm.FlutterPcmSound.release().timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Release timed out');
_emitError(PcmPlayerError.releaseTimeout, 'release() timed out');
},
);
_isStarted = false;
_logger.log('PcmAudioPlayer: Player released for instant stop');
} catch (e) {
_logger.log('PcmAudioPlayer: Release error (expected if already released): $e');
}
}));
// Transition to paused state
_state = PcmPlayerState.paused;
_logger.log('PcmAudioPlayer: Paused playback at ${elapsedTime.inSeconds}s');
return true;
}
/// Stop playback (clears buffer and resets position)
/// Returns true if stop was successful
Future<bool> stop() async {
if (_state == PcmPlayerState.idle) return true;
_logger.log('PcmAudioPlayer: Stop requested');
// Set stopping state to block any in-flight feed operations
_state = PcmPlayerState.stopping;
_isStarted = false;
_stopElapsedTimeTimer();
// Clear the feed callback to prevent native code from triggering new feeds
pcm.FlutterPcmSound.setFeedCallback(null);
// Wait for any in-progress feed operation to complete (with timeout)
final completer = _feedCompleter;
if (completer != null && !completer.isCompleted) {
await completer.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Feed wait timed out on stop');
},
);
}
_audioBuffer.clear();
_isFeeding = false;
try {
await pcm.FlutterPcmSound.release();
} catch (e) {
_logger.log('PcmAudioPlayer: Error releasing: $e');
}
_state = PcmPlayerState.ready;
_framesPlayed = 0;
_bytesPlayed = 0;
_bytesPlayedAtLastPause = 0;
_playbackStartTime = null;
_logger.log('PcmAudioPlayer: Stopped playback');
// Re-initialize for next playback
await initialize(format: _format);
return true;
}
/// Reset position to zero (for new track) without stopping playback
void resetPosition() {
_framesPlayed = 0;
_bytesPlayed = 0;
_bytesPlayedAtLastPause = 0;
_playbackStartTime = DateTime.now();
_logger.log('PcmAudioPlayer: Position reset to 0');
_emitElapsedTime();
}
/// Disconnect from audio stream
Future<void> disconnect() async {
await _audioSubscription?.cancel();
_audioSubscription = null;
await stop();
_logger.log('PcmAudioPlayer: Disconnected from stream');
}
/// Release all resources
Future<void> dispose() async {
_logger.log('PcmAudioPlayer: Disposing...');
// Set stopping state to block any feed operations
_state = PcmPlayerState.stopping;
_isStarted = false;
_stopElapsedTimeTimer();
// Clear the feed callback first
pcm.FlutterPcmSound.setFeedCallback(null);
// Wait for any in-progress feed operation
final completer = _feedCompleter;
if (completer != null && !completer.isCompleted) {
await completer.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Feed wait timed out on dispose');
},
);
}
_isFeeding = false;
await _audioSubscription?.cancel();
_audioSubscription = null;
_audioBuffer.clear();
try {
await pcm.FlutterPcmSound.release().timeout(
const Duration(milliseconds: 500),
onTimeout: () {
_logger.log('PcmAudioPlayer: Release timed out on dispose');
},
);
} catch (e) {
_logger.log('PcmAudioPlayer: Error releasing: $e');
}
if (!_elapsedTimeController.isClosed) {
await _elapsedTimeController.close();
}
_state = PcmPlayerState.idle;
_logger.log('PcmAudioPlayer: Disposed');
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'database_service.dart';
import 'settings_service.dart';
import 'debug_logger.dart';
import '../database/database.dart';
/// Service for managing user profiles
/// Profiles are auto-created from MA authentication or manual name entry
class ProfileService {
static ProfileService? _instance;
static final _logger = DebugLogger();
ProfileService._();
/// Get the singleton instance
static ProfileService get instance {
_instance ??= ProfileService._();
return _instance!;
}
DatabaseService get _db => DatabaseService.instance;
/// Get the currently active profile
Future<Profile?> getActiveProfile() async {
if (!_db.isInitialized) return null;
return _db.getActiveProfile();
}
/// Get the active profile's username/name
Future<String?> getActiveProfileName() async {
final profile = await getActiveProfile();
return profile?.displayName ?? profile?.username;
}
/// Called after successful MA authentication
/// Creates or activates profile based on MA user info
Future<Profile> onMaAuthenticated({
required String username,
String? displayName,
}) async {
_logger.log('MA authenticated: username=$username, displayName=$displayName');
final profile = await _db.setActiveProfile(
username: username,
displayName: displayName,
source: 'ma_auth',
);
_logger.log('Profile activated: ${profile.username} (${profile.displayName})');
return profile;
}
/// Called when user manually enters their name (no MA auth)
/// Creates or activates profile based on entered name
Future<Profile> onManualNameEntered(String name) async {
_logger.log('Manual name entered: $name');
// Use name as both username and display name for manual profiles
final profile = await _db.setActiveProfile(
username: name.toLowerCase().replaceAll(' ', '_'),
displayName: name,
source: 'manual',
);
_logger.log('Profile activated: ${profile.username} (${profile.displayName})');
return profile;
}
/// Migrate existing ownerName to profile (one-time migration)
Future<void> migrateFromOwnerName() async {
if (!_db.isInitialized) return;
// Check if we already have any profiles
final profiles = await _db.getAllProfiles();
if (profiles.isNotEmpty) {
_logger.log('Migration skipped - profiles already exist');
return;
}
// Check for existing ownerName in settings
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) {
_logger.log('Migration skipped - no ownerName to migrate');
return;
}
_logger.log('Migrating ownerName "$ownerName" to profile...');
// Create profile from ownerName
// We don't know if it was MA auth or manual, so default to manual
await _db.setActiveProfile(
username: ownerName.toLowerCase().replaceAll(' ', '_'),
displayName: ownerName,
source: 'migrated',
);
_logger.log('Migration complete');
}
/// Get all profiles (for potential profile switcher UI)
Future<List<Profile>> getAllProfiles() async {
if (!_db.isInitialized) return [];
return _db.getAllProfiles();
}
/// Check if any profile exists
Future<bool> hasAnyProfile() async {
final profiles = await getAllProfiles();
return profiles.isNotEmpty;
}
}
+253
View File
@@ -0,0 +1,253 @@
import 'dart:convert';
import 'database_service.dart';
import 'debug_logger.dart';
import '../database/database.dart';
import '../models/media_item.dart';
/// Service for tracking recently played items locally, per-profile
/// This supplements MA's server-side tracking with instant local data
class RecentlyPlayedService {
static RecentlyPlayedService? _instance;
static final _logger = DebugLogger();
RecentlyPlayedService._();
/// Get the singleton instance
static RecentlyPlayedService get instance {
_instance ??= RecentlyPlayedService._();
return _instance!;
}
DatabaseService get _db => DatabaseService.instance;
/// Record an album being played
Future<void> recordAlbumPlayed(Album album) async {
if (!_db.isInitialized) return;
try {
// Store images metadata for instant artwork display
final images = album.metadata?['images'];
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
name: album.name,
artistName: album.artistsString,
metadata: {
'provider': album.provider,
'uri': album.uri,
if (images != null) 'images': images,
},
);
_logger.log('📝 Recorded album play: ${album.name}');
} catch (e) {
_logger.log('⚠️ Failed to record album play: $e');
}
}
/// Record an artist being played (e.g., artist radio)
Future<void> recordArtistPlayed(Artist artist) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: artist.itemId,
mediaType: 'artist',
name: artist.name,
metadata: {
'provider': artist.provider,
'uri': artist.uri,
},
);
_logger.log('📝 Recorded artist play: ${artist.name}');
} catch (e) {
_logger.log('⚠️ Failed to record artist play: $e');
}
}
/// Record a track being played
Future<void> recordTrackPlayed(Track track) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: track.itemId,
mediaType: 'track',
name: track.name,
artistName: track.artistsString,
metadata: {
'provider': track.provider,
'uri': track.uri,
'albumName': track.album?.name,
},
);
_logger.log('📝 Recorded track play: ${track.name}');
} catch (e) {
_logger.log('⚠️ Failed to record track play: $e');
}
}
/// Record a playlist being played
Future<void> recordPlaylistPlayed(Playlist playlist) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: playlist.itemId,
mediaType: 'playlist',
name: playlist.name,
metadata: {
'provider': playlist.provider,
'uri': playlist.uri,
},
);
_logger.log('📝 Recorded playlist play: ${playlist.name}');
} catch (e) {
_logger.log('⚠️ Failed to record playlist play: $e');
}
}
/// Record an audiobook being played
Future<void> recordAudiobookPlayed(Audiobook audiobook) async {
if (!_db.isInitialized) return;
try {
await _db.addRecentlyPlayed(
mediaId: audiobook.itemId,
mediaType: 'audiobook',
name: audiobook.name,
artistName: audiobook.authorsString,
metadata: {
'provider': audiobook.provider,
'uri': audiobook.uri,
'narrators': audiobook.narratorsString,
'duration': audiobook.duration?.inSeconds,
'resumePositionMs': audiobook.resumePositionMs,
},
);
_logger.log('📝 Recorded audiobook play: ${audiobook.name}');
} catch (e) {
_logger.log('⚠️ Failed to record audiobook play: $e');
}
}
/// Get recently played albums for the current profile
/// Returns Album objects reconstructed from local data
Future<List<Album>> getRecentAlbums({int limit = 20}) async {
if (!_db.isInitialized) return [];
try {
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
// Filter for albums and convert to Album objects
final albums = <Album>[];
final seenIds = <String>{};
for (final item in items) {
if (item.mediaType != 'album') continue;
if (seenIds.contains(item.mediaId)) continue;
seenIds.add(item.mediaId);
Map<String, dynamic>? metadata;
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
}
// Build metadata with images if available (for instant artwork display)
Map<String, dynamic>? albumMetadata;
if (metadata?['images'] != null) {
albumMetadata = {'images': metadata!['images']};
}
albums.add(Album(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
name: item.name,
uri: metadata?['uri'],
// Create minimal artist info from stored name
artists: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
metadata: albumMetadata,
));
if (albums.length >= limit) break;
}
return albums;
} catch (e) {
_logger.log('⚠️ Failed to get recent albums: $e');
return [];
}
}
/// Get recently played audiobooks for the current profile
/// Returns Audiobook objects reconstructed from local data
Future<List<Audiobook>> getRecentAudiobooks({int limit = 20}) async {
if (!_db.isInitialized) return [];
try {
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
// Filter for audiobooks and convert to Audiobook objects
final audiobooks = <Audiobook>[];
final seenIds = <String>{};
for (final item in items) {
if (item.mediaType != 'audiobook') continue;
if (seenIds.contains(item.mediaId)) continue;
seenIds.add(item.mediaId);
Map<String, dynamic>? metadata;
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
}
audiobooks.add(Audiobook(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
name: item.name,
uri: metadata?['uri'],
// Create minimal author info from stored name
authors: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
duration: metadata?['duration'] != null
? Duration(seconds: metadata!['duration'] as int)
: null,
resumePositionMs: metadata?['resumePositionMs'] as int?,
));
if (audiobooks.length >= limit) break;
}
return audiobooks;
} catch (e) {
_logger.log('⚠️ Failed to get recent audiobooks: $e');
return [];
}
}
/// Get all recently played items for the current profile
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) async {
if (!_db.isInitialized) return [];
return _db.getRecentlyPlayed(limit: limit);
}
/// Check if we have local recently played data
Future<bool> hasLocalData() async {
if (!_db.isInitialized) return false;
final items = await _db.getRecentlyPlayed(limit: 1);
return items.isNotEmpty;
}
/// Clear recently played for current profile
Future<void> clear() async {
if (!_db.isInitialized) return;
await _db.clearRecentlyPlayed();
_logger.log('🗑️ Cleared recently played');
}
}
+663
View File
@@ -0,0 +1,663 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
/// Connection state for Sendspin player
enum SendspinConnectionState {
disconnected,
connecting,
connected,
error,
}
/// Callback types for Sendspin events
typedef SendspinPlayCallback = void Function(String streamUrl, Map<String, dynamic> trackInfo);
typedef SendspinPauseCallback = void Function();
typedef SendspinStopCallback = void Function();
typedef SendspinSeekCallback = void Function(int positionSeconds);
typedef SendspinVolumeCallback = void Function(int volumeLevel);
typedef SendspinAudioDataCallback = void Function(Uint8List audioData);
typedef SendspinStreamStartCallback = void Function(Map<String, dynamic>? trackInfo);
typedef SendspinStreamEndCallback = void Function();
/// Service to manage Sendspin WebSocket connection for local playback
/// Sendspin is the replacement for builtin_player in MA 2.7.0b20+
///
/// Connection strategy (smart fallback for external access):
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
/// 2. Fall back to local_ws_url from API (ws://local-ip:8927/sendspin)
/// 3. WebRTC fallback as last resort (requires TURN servers)
class SendspinService {
final String serverUrl;
final _logger = DebugLogger();
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
// Connection state
SendspinConnectionState _state = SendspinConnectionState.disconnected;
SendspinConnectionState get state => _state;
final _stateController = StreamController<SendspinConnectionState>.broadcast();
Stream<SendspinConnectionState> get stateStream => _stateController.stream;
// Player info
String? _playerId;
String? _playerName;
String? _connectedUrl;
String? get playerId => _playerId;
String? get playerName => _playerName;
// Event callbacks
SendspinPlayCallback? onPlay;
SendspinPauseCallback? onPause;
SendspinStopCallback? onStop;
SendspinSeekCallback? onSeek;
SendspinVolumeCallback? onVolume;
SendspinAudioDataCallback? onAudioData;
SendspinStreamStartCallback? onStreamStart;
SendspinStreamEndCallback? onStreamEnd;
// Audio data stream for raw PCM frames
final _audioDataController = StreamController<Uint8List>.broadcast();
Stream<Uint8List> get audioDataStream => _audioDataController.stream;
// Track audio streaming state
bool _isStreamingAudio = false;
int _audioFramesReceived = 0;
// Player state for reporting to server
bool _isPowered = true;
bool _isPlaying = false;
bool _isPaused = false;
int _position = 0;
int _volume = 100;
bool _isMuted = false;
bool _isDisposed = false;
// Auth token for proxy authentication (MA 2.7.1+)
String? _authToken;
SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication
void setAuthToken(String? token) {
_authToken = token;
}
/// Initialize and connect to Sendspin server
/// Uses persistent player ID and username-based naming
Future<bool> connect() async {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
_updateState(SendspinConnectionState.connecting);
try {
// Get persistent player ID and name
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_playerName = await SettingsService.getLocalPlayerName();
_logger.log('Sendspin: Connecting as "$_playerName" (ID: $_playerId)');
// Try connection strategies in order
bool connected = false;
// Strategy 1: If server is HTTPS, try external wss:// first
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
if (_isHttpsServer()) {
final externalUrl = _buildExternalSendspinUrl();
_logger.log('Sendspin: Trying external URL: $externalUrl');
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
}
// Note: Local network connections (ws://ip:8927/sendspin) are handled
// by the provider calling connectWithUrl() directly, not by this method.
if (!connected) {
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
return false;
}
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
return false;
}
}
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
Future<bool> connectWithUrl(String wsUrl) async {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
_updateState(SendspinConnectionState.connecting);
try {
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_playerName = await SettingsService.getLocalPlayerName();
_logger.log('Sendspin: Connecting with URL: $wsUrl');
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
if (!connected) {
_updateState(SendspinConnectionState.error);
return false;
}
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
return false;
}
}
/// Attempt to connect to a specific WebSocket URL
/// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
try {
// Connect to the base URL without query params - we send player info in hello message
_logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
// Create WebSocket connection
final webSocket = await WebSocket.connect(url).timeout(timeout);
_channel = IOWebSocketChannel(webSocket);
_connectedUrl = url;
// Set up message listener BEFORE sending hello
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDone,
);
// If using proxy auth (MA 2.7.1+), authenticate first
if (useProxyAuth && _authToken != null) {
_logger.log('Sendspin: Sending proxy auth with token');
_sendMessage({
'type': 'auth',
'token': _authToken,
}, allowDuringHandshake: true);
// Wait for auth_ok response
final authOk = await _waitForAuth().timeout(
const Duration(seconds: 5),
onTimeout: () => false,
);
if (!authOk) {
_logger.log('Sendspin: Proxy authentication failed or timed out');
await _channel?.sink.close();
_channel = null;
return false;
}
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
}
// Send client/hello message immediately after connecting
// This is required by the Sendspin protocol - server waits for this
// Message format follows aiosendspin protocol spec
_logger.log('Sendspin: Sending client/hello with player_id=$_playerId');
_sendMessage({
'type': 'client/hello',
'payload': {
'client_id': _playerId,
'name': _playerName,
'version': 1, // Protocol version (integer)
'supported_roles': ['player@v1'],
'player_support': {
'supported_formats': [
{
'codec': 'pcm',
'channels': 2,
'sample_rate': 48000,
'bit_depth': 16,
},
],
'buffer_capacity': 1048576, // 1MB buffer
'supported_commands': ['volume', 'mute'],
},
},
}, allowDuringHandshake: true);
// Wait for server acknowledgment (server sends welcome/registered after hello)
final ackReceived = await _waitForAck().timeout(
const Duration(seconds: 5),
onTimeout: () => false,
);
if (!ackReceived) {
_logger.log('Sendspin: No acknowledgment from server after hello');
await _channel?.sink.close();
_channel = null;
return false;
}
_logger.log('Sendspin: Connected and registered successfully');
_updateState(SendspinConnectionState.connected);
_startHeartbeat();
return true;
} catch (e) {
_logger.log('Sendspin: Connection attempt failed: $e');
return false;
}
}
/// Wait for server acknowledgment after connection
Completer<bool>? _ackCompleter;
Completer<bool>? _authCompleter;
Future<bool> _waitForAck() async {
_ackCompleter = Completer<bool>();
return _ackCompleter!.future;
}
Future<bool> _waitForAuth() async {
_authCompleter = Completer<bool>();
return _authCompleter!.future;
}
/// Handle incoming WebSocket messages
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
void _handleMessage(dynamic message) {
// Check if this is binary audio data (Uint8List) or text (String)
if (message is List<int>) {
_handleBinaryAudioData(Uint8List.fromList(message));
return;
}
// Handle text messages (JSON)
if (message is! String) {
_logger.log('Sendspin: Unexpected message type: ${message.runtimeType}');
return;
}
try {
final data = jsonDecode(message) as Map<String, dynamic>;
final type = data['type'] as String?;
_logger.log('Sendspin: Received message type: $type');
switch (type) {
case 'server/hello':
// Server acknowledged our client/hello - we're registered!
_logger.log('Sendspin: Received server/hello - connection successful');
final payload = data['payload'] as Map<String, dynamic>?;
if (payload != null) {
_logger.log('Sendspin: Server name: ${payload['name']}, version: ${payload['version']}');
_logger.log('Sendspin: Active roles: ${payload['active_roles']}');
}
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
_ackCompleter!.complete(true);
}
// Send initial client/state immediately after handshake (required by spec)
// Note: These must use allowDuringHandshake since state is still 'connecting'
_sendInitialState();
// Also send initial time sync
_sendClientTime(allowDuringHandshake: true);
break;
case 'server/time':
// Server responded to our time sync - can be used to calculate clock offset
// For now, just log it (proper sync would use Kalman filter)
_logger.log('Sendspin: Received server/time response');
break;
case 'group/update':
// Group state update - store for reference but no response needed
_logger.log('Sendspin: Received group/update');
final groupPayload = data['payload'] as Map<String, dynamic>?;
if (groupPayload != null) {
_logger.log('Sendspin: Group state: ${groupPayload['playback_state']}');
}
break;
case 'stream/start':
// Server is starting to send audio data
_logger.log('Sendspin: Audio stream starting');
_isStreamingAudio = true;
_audioFramesReceived = 0;
_isPlaying = true;
_isPaused = false;
// Notify provider to start PCM player and foreground service
final streamPayload = data['payload'] as Map<String, dynamic>?;
onStreamStart?.call(streamPayload);
break;
case 'stream/end':
// Server finished sending audio data
_logger.log('Sendspin: Audio stream ended (received $_audioFramesReceived frames)');
_isStreamingAudio = false;
_isPlaying = false;
// Notify provider to stop PCM player
onStreamEnd?.call();
break;
case 'auth_ok':
// Proxy authentication successful (MA 2.7.1+)
_logger.log('Sendspin: Proxy authentication successful');
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete(true);
}
break;
case 'ack':
case 'connected':
case 'registered':
// Legacy/fallback acknowledgment types
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
_ackCompleter!.complete(true);
}
break;
case 'play':
// Server wants us to play audio (URL-based, not raw PCM)
final streamUrl = data['url'] as String?;
final trackInfo = data['track'] as Map<String, dynamic>? ?? {};
if (streamUrl != null && onPlay != null) {
_isPlaying = true;
_isPaused = false;
onPlay!(streamUrl, trackInfo);
}
break;
case 'pause':
_isPaused = true;
_isPlaying = false;
onPause?.call();
break;
case 'stop':
_isPlaying = false;
_isPaused = false;
_isStreamingAudio = false;
onStop?.call();
break;
case 'seek':
final position = data['position'] as int?;
if (position != null) {
_position = position;
onSeek?.call(position);
}
break;
case 'volume':
final level = data['level'] as int?;
if (level != null) {
_volume = level;
onVolume?.call(level);
}
break;
case 'ping':
// Respond to server ping
_sendMessage({'type': 'pong'});
break;
case 'error':
final errorMsg = data['message'] as String?;
_logger.log('Sendspin: Server error: $errorMsg');
break;
default:
_logger.log('Sendspin: Unknown message type: $type');
}
} catch (e) {
_logger.log('Sendspin: Error handling JSON message: $e');
}
}
/// Handle binary audio data (PCM frames from server)
/// Sendspin binary frame format:
/// - Byte 0: message type (uint8) - Type 4 = audio data
/// - Bytes 1-8: timestamp (int64 microseconds, little-endian) for synchronization
/// - Bytes 9+: actual PCM audio data (16-bit stereo 48kHz)
void _handleBinaryAudioData(Uint8List frame) {
// Minimum frame size: 1 (type) + 8 (timestamp) + 2 (at least one sample)
if (frame.length < 11) {
_logger.log('Sendspin: Binary frame too short: ${frame.length} bytes');
return;
}
// Parse header
final messageType = frame[0];
// Type 4 = audio data, other types may be used for other purposes
if (messageType != 4) {
_logger.log('Sendspin: Unexpected binary message type: $messageType');
return;
}
// Extract timestamp (bytes 1-8, little-endian int64)
final timestampBytes = ByteData.sublistView(frame, 1, 9);
final timestamp = timestampBytes.getInt64(0, Endian.little);
// Extract actual PCM audio data (everything after the 9-byte header)
final audioData = Uint8List.sublistView(frame, 9);
_audioFramesReceived++;
// Log periodically to avoid spam
if (_audioFramesReceived == 1) {
_logger.log('Sendspin: Receiving audio data (first frame: ${frame.length} bytes, audio: ${audioData.length} bytes, ts: $timestamp)');
} else if (_audioFramesReceived % 100 == 0) {
_logger.log('Sendspin: Received $_audioFramesReceived audio frames');
}
// Emit only the PCM audio data (without header) to stream for consumers
if (!_audioDataController.isClosed) {
_audioDataController.add(audioData);
}
// Call callback if registered
onAudioData?.call(audioData);
}
/// Handle WebSocket errors
void _handleError(dynamic error) {
_logger.log('Sendspin: WebSocket error: $error');
_updateState(SendspinConnectionState.error);
_scheduleReconnect();
}
/// Handle WebSocket close
void _handleDone() {
_logger.log('Sendspin: WebSocket closed');
_updateState(SendspinConnectionState.disconnected);
_scheduleReconnect();
}
/// Send a JSON message to the server
/// allowDuringHandshake: set to true to send messages before connection is established (e.g., hello)
void _sendMessage(Map<String, dynamic> message, {bool allowDuringHandshake = false}) {
if (_channel == null) return;
// During handshake, we need to send hello even though state is still 'connecting'
if (!allowDuringHandshake && _state != SendspinConnectionState.connected) return;
try {
final json = jsonEncode(message);
_logger.log('Sendspin: Sending message: ${message['type']}');
_channel!.sink.add(json);
} catch (e) {
_logger.log('Sendspin: Error sending message: $e');
}
}
/// Report current player state to server
/// Uses Sendspin protocol 'client/state' message format
void reportState({
bool? powered,
bool? playing,
bool? paused,
int? position,
int? volume,
bool? muted,
}) {
if (powered != null) _isPowered = powered;
if (playing != null) _isPlaying = playing;
if (paused != null) _isPaused = paused;
if (position != null) _position = position;
if (volume != null) _volume = volume;
if (muted != null) _isMuted = muted;
// Determine player state for Sendspin protocol
// Valid values: 'synchronized', 'error', 'external_source'
// When connected, we're always synchronized (ready to receive audio)
const playerState = 'synchronized';
_sendMessage({
'type': 'client/state',
'payload': {
'player': {
'state': playerState,
'volume': _volume,
'muted': _isMuted,
},
},
});
}
/// Check if server URL is HTTPS
bool _isHttpsServer() {
return serverUrl.startsWith('https://') ||
serverUrl.startsWith('wss://') ||
(!serverUrl.contains('://') && !serverUrl.contains(':'));
}
/// Build external Sendspin WebSocket URL from server URL
String _buildExternalSendspinUrl() {
var url = serverUrl;
// Convert HTTP(S) to WS(S)
if (url.startsWith('https://')) {
url = 'wss://${url.substring(8)}';
} else if (url.startsWith('http://')) {
url = 'ws://${url.substring(7)}';
} else if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = 'wss://$url';
}
// Remove trailing slash and add /sendspin path
url = url.replaceAll(RegExp(r'/+$'), '');
// Remove any existing path and add /sendspin
final uri = Uri.parse(url);
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/sendspin',
).toString();
}
/// Start heartbeat timer using Sendspin's client/time for clock synchronization
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
_sendClientTime();
});
}
/// Send client/time message for clock synchronization (Sendspin protocol)
void _sendClientTime({bool allowDuringHandshake = false}) {
final timestampMicroseconds = DateTime.now().microsecondsSinceEpoch;
_sendMessage({
'type': 'client/time',
'payload': {
'client_transmitted': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
/// Send initial client/state immediately after handshake (required by Sendspin spec)
/// Must use allowDuringHandshake since state is still 'connecting' at this point
void _sendInitialState() {
_logger.log('Sendspin: Sending initial client/state');
_sendMessage({
'type': 'client/state',
'payload': {
'player': {
'state': 'synchronized',
'volume': _volume,
'muted': _isMuted,
},
},
}, allowDuringHandshake: true);
}
/// Stop heartbeat timer
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_isDisposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 5), () {
if (!_isDisposed && _state != SendspinConnectionState.connected) {
_logger.log('Sendspin: Attempting reconnection...');
if (_connectedUrl != null) {
connectWithUrl(_connectedUrl!);
}
}
});
}
/// Update connection state
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
_stateController.add(newState);
}
}
/// Disconnect from server using Sendspin's client/goodbye message
Future<void> disconnect() async {
_stopHeartbeat();
_reconnectTimer?.cancel();
if (_channel != null) {
// Send graceful goodbye per Sendspin protocol
_sendMessage({
'type': 'client/goodbye',
'payload': {
'reason': 'user_request',
},
});
await _channel!.sink.close();
_channel = null;
}
_updateState(SendspinConnectionState.disconnected);
}
/// Dispose the service
void dispose() {
_isDisposed = true;
_stopHeartbeat();
_reconnectTimer?.cancel();
_channel?.sink.close();
_stateController.close();
_audioDataController.close();
}
}
+424
View File
@@ -15,18 +15,69 @@ class SettingsService {
static const String _keyUseMaterialTheme = 'use_material_theme';
static const String _keyAdaptiveTheme = 'adaptive_theme';
static const String _keyCustomColor = 'custom_color';
static const String _keyLocale = 'locale';
static const String _keyLastFmApiKey = 'lastfm_api_key';
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
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 const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
static const String _keyShowDiscoverSeries = 'show_discover_series';
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order
static const List<String> defaultHomeRowOrder = [
'recent-albums',
'discover-artists',
'discover-albums',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
];
// View Mode Settings
static const String _keyArtistAlbumsSortOrder = 'artist_albums_sort_order'; // 'alpha' or 'year'
static const String _keyArtistAlbumsViewMode = 'artist_albums_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryArtistsViewMode = 'library_artists_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryAlbumsViewMode = 'library_albums_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryPlaylistsViewMode = 'library_playlists_view_mode'; // 'grid2', 'grid3', 'list'
// Audiobook View Mode Settings
static const String _keyAuthorAudiobooksSortOrder = 'author_audiobooks_sort_order'; // 'alpha' or 'year'
static const String _keyAuthorAudiobooksViewMode = 'author_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryAuthorsViewMode = 'library_authors_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryAudiobooksViewMode = 'library_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryAudiobooksSortOrder = 'library_audiobooks_sort_order'; // 'alpha' or 'year'
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
static const String _keyAbsApiToken = 'abs_api_token';
static const String _keyAbsEnabled = 'abs_enabled';
// Audiobookshelf Library Settings (via MA browse)
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
// Hint System Settings
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
@@ -222,6 +273,21 @@ class SettingsService {
await prefs.setString(_keyCustomColor, color);
}
// Locale (null = system default)
static Future<String?> getLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocale);
}
static Future<void> saveLocale(String? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
await prefs.remove(_keyLocale);
} else {
await prefs.setString(_keyLocale, locale);
}
}
// Metadata API Keys
static Future<String?> getLastFmApiKey() async {
final prefs = await SharedPreferences.getInstance();
@@ -305,6 +371,28 @@ class SettingsService {
}
}
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
}
static Future<void> setPreferLocalPlayer(bool prefer) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyPreferLocalPlayer, prefer);
}
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keySmartSortPlayers) ?? false;
}
static Future<void> setSmartSortPlayers(bool smartSort) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keySmartSortPlayers, smartSort);
}
// Helper to create player name with possessive apostrophe
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
@@ -351,6 +439,37 @@ class SettingsService {
await prefs.setBool(_keyShowDiscoverAlbums, show);
}
// Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowContinueListeningAudiobooks) ?? false;
}
static Future<void> setShowContinueListeningAudiobooks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowContinueListeningAudiobooks, show);
}
static Future<bool> getShowDiscoverAudiobooks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverAudiobooks) ?? false;
}
static Future<void> setShowDiscoverAudiobooks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverAudiobooks, show);
}
static Future<bool> getShowDiscoverSeries() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverSeries) ?? false;
}
static Future<void> setShowDiscoverSeries(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverSeries, show);
}
// Home Screen Favorites Settings (default off)
static Future<bool> getShowFavoriteAlbums() async {
final prefs = await SharedPreferences.getInstance();
@@ -381,4 +500,309 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteTracks, show);
}
// Home Row Order
static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyHomeRowOrder);
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
return decoded.cast<String>();
} catch (_) {
return List.from(defaultHomeRowOrder);
}
}
return List.from(defaultHomeRowOrder);
}
static Future<void> setHomeRowOrder(List<String> order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyHomeRowOrder, jsonEncode(order));
}
// View Mode Settings - Artist Albums
static Future<String> getArtistAlbumsSortOrder() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyArtistAlbumsSortOrder) ?? 'alpha';
}
static Future<void> setArtistAlbumsSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyArtistAlbumsSortOrder, order);
}
static Future<String> getArtistAlbumsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyArtistAlbumsViewMode) ?? 'grid2';
}
static Future<void> setArtistAlbumsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyArtistAlbumsViewMode, mode);
}
// View Mode Settings - Library Artists
static Future<String> getLibraryArtistsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryArtistsViewMode) ?? 'list';
}
static Future<void> setLibraryArtistsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryArtistsViewMode, mode);
}
// View Mode Settings - Library Albums
static Future<String> getLibraryAlbumsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryAlbumsViewMode) ?? 'grid2';
}
static Future<void> setLibraryAlbumsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryAlbumsViewMode, mode);
}
// View Mode Settings - Library Playlists
static Future<String> getLibraryPlaylistsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryPlaylistsViewMode) ?? 'list';
}
static Future<void> setLibraryPlaylistsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryPlaylistsViewMode, mode);
}
// View Mode Settings - Author Audiobooks (author detail screen)
static Future<String> getAuthorAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAuthorAudiobooksSortOrder) ?? 'alpha';
}
static Future<void> setAuthorAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAuthorAudiobooksSortOrder, order);
}
static Future<String> getAuthorAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAuthorAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setAuthorAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAuthorAudiobooksViewMode, mode);
}
// View Mode Settings - Library Authors
static Future<String> getLibraryAuthorsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryAuthorsViewMode) ?? 'list';
}
static Future<void> setLibraryAuthorsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryAuthorsViewMode, mode);
}
// View Mode Settings - Library Audiobooks
static Future<String> getLibraryAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setLibraryAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryAudiobooksViewMode, mode);
}
static Future<String> getLibraryAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryAudiobooksSortOrder) ?? 'alpha';
}
static Future<void> setLibraryAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryAudiobooksSortOrder, order);
}
static Future<String> getLibrarySeriesViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibrarySeriesViewMode) ?? 'grid2';
}
static Future<void> setLibrarySeriesViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibrarySeriesViewMode, mode);
}
// View Mode Settings - Series Audiobooks (series detail screen)
static Future<String> getSeriesAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keySeriesAudiobooksSortOrder) ?? 'series';
}
static Future<void> setSeriesAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySeriesAudiobooksSortOrder, order);
}
static Future<String> getSeriesAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keySeriesAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setSeriesAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySeriesAudiobooksViewMode, mode);
}
// Audiobookshelf Settings
static Future<String?> getAbsServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAbsServerUrl);
}
static Future<void> setAbsServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAbsServerUrl, url);
}
static Future<String?> getAbsApiToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAbsApiToken);
}
static Future<void> setAbsApiToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAbsApiToken, token);
}
static Future<bool> getAbsEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyAbsEnabled) ?? false;
}
static Future<void> setAbsEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAbsEnabled, enabled);
}
static Future<void> clearAbsSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAbsServerUrl);
await prefs.remove(_keyAbsApiToken);
await prefs.remove(_keyAbsEnabled);
}
// Audiobookshelf Library Settings (via MA browse API)
/// Get list of enabled library paths (all enabled by default if not set)
static Future<List<String>?> getEnabledAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyEnabledAbsLibraries);
if (json == null) return null; // null means all libraries are enabled
try {
final list = jsonDecode(json) as List<dynamic>;
return list.cast<String>();
} catch (e) {
return null;
}
}
/// Set list of enabled library paths
static Future<void> setEnabledAbsLibraries(List<String> paths) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyEnabledAbsLibraries, jsonEncode(paths));
}
/// Clear enabled libraries (reverts to all enabled)
static Future<void> clearEnabledAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyEnabledAbsLibraries);
}
/// Get discovered libraries [{path: String, name: String}]
static Future<List<Map<String, String>>?> getDiscoveredAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyDiscoveredAbsLibraries);
if (json == null) return null;
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((e) => Map<String, String>.from(e as Map)).toList();
} catch (e) {
return null;
}
}
/// Save discovered libraries
static Future<void> setDiscoveredAbsLibraries(List<Map<String, String>> libraries) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDiscoveredAbsLibraries, jsonEncode(libraries));
}
/// Check if a specific library is enabled
static Future<bool> isAbsLibraryEnabled(String libraryPath) async {
final enabled = await getEnabledAbsLibraries();
if (enabled == null) return true; // All enabled by default
return enabled.contains(libraryPath);
}
/// Toggle a specific library
static Future<void> toggleAbsLibrary(String libraryPath, bool enable) async {
final discovered = await getDiscoveredAbsLibraries();
if (discovered == null) return;
var enabled = await getEnabledAbsLibraries();
// If null (all enabled), initialize with all library paths
enabled ??= discovered.map((lib) => lib['path']!).toList();
if (enable && !enabled.contains(libraryPath)) {
enabled.add(libraryPath);
} else if (!enable && enabled.contains(libraryPath)) {
enabled.remove(libraryPath);
}
await setEnabledAbsLibraries(enabled);
}
// Hint System Settings
/// Get whether hints are enabled (default: true)
static Future<bool> getShowHints() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowHints) ?? true;
}
/// Set whether hints are enabled
static Future<void> setShowHints(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowHints, show);
}
/// Check if user has ever used the player reveal gesture
static Future<bool> getHasUsedPlayerReveal() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
}
/// Mark that user has used the player reveal gesture
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
}
/// Check if user has completed onboarding (seen welcome screen)
static Future<bool> getHasCompletedOnboarding() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
}
/// Mark that user has completed onboarding
static Future<void> setHasCompletedOnboarding(bool completed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
}
+299
View File
@@ -0,0 +1,299 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
import 'music_assistant_api.dart';
/// Sync status for UI indicators
enum SyncStatus {
idle,
syncing,
completed,
error,
}
/// Service for background library synchronization
/// Loads from database cache first, then syncs from MA API in background
class SyncService with ChangeNotifier {
static SyncService? _instance;
static final _logger = DebugLogger();
SyncService._();
static SyncService get instance {
_instance ??= SyncService._();
return _instance!;
}
DatabaseService get _db => DatabaseService.instance;
// Sync state
SyncStatus _status = SyncStatus.idle;
String? _lastError;
DateTime? _lastSyncTime;
bool _isSyncing = false;
// Cached data (loaded from DB, updated after sync)
List<Album> _cachedAlbums = [];
List<Artist> _cachedArtists = [];
List<Audiobook> _cachedAudiobooks = [];
List<Playlist> _cachedPlaylists = [];
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
DateTime? get lastSyncTime => _lastSyncTime;
bool get isSyncing => _isSyncing;
List<Album> get cachedAlbums => _cachedAlbums;
List<Artist> get cachedArtists => _cachedArtists;
List<Audiobook> get cachedAudiobooks => _cachedAudiobooks;
List<Playlist> get cachedPlaylists => _cachedPlaylists;
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
/// Load library data from database cache (instant)
/// Call this on app startup for immediate data
Future<void> loadFromCache() async {
if (!_db.isInitialized) {
_logger.log('⚠️ Database not initialized, skipping cache load');
return;
}
try {
_logger.log('📦 Loading library from database cache...');
// Load albums from cache
final albumData = await _db.getCachedItems('album');
_cachedAlbums = albumData.map((data) {
try {
return Album.fromJson(data);
} catch (e) {
_logger.log('⚠️ Failed to parse cached album: $e');
return null;
}
}).whereType<Album>().toList();
// Load artists from cache
final artistData = await _db.getCachedItems('artist');
_cachedArtists = artistData.map((data) {
try {
return Artist.fromJson(data);
} catch (e) {
_logger.log('⚠️ Failed to parse cached artist: $e');
return null;
}
}).whereType<Artist>().toList();
// Load audiobooks from cache
final audiobookData = await _db.getCachedItems('audiobook');
_cachedAudiobooks = audiobookData.map((data) {
try {
return Audiobook.fromJson(data);
} catch (e) {
_logger.log('⚠️ Failed to parse cached audiobook: $e');
return null;
}
}).whereType<Audiobook>().toList();
// Load playlists from cache
final playlistData = await _db.getCachedItems('playlist');
_cachedPlaylists = playlistData.map((data) {
try {
return Playlist.fromJson(data);
} catch (e) {
_logger.log('⚠️ Failed to parse cached playlist: $e');
return null;
}
}).whereType<Playlist>().toList();
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
notifyListeners();
} catch (e) {
_logger.log('❌ Failed to load from cache: $e');
}
}
/// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping');
return;
}
if (!_db.isInitialized) {
_logger.log('⚠️ Database not initialized, skipping sync');
return;
}
// Check if sync is needed (default: every 5 minutes)
if (!force) {
final albumsNeedSync = await _db.needsSync('albums', maxAge: const Duration(minutes: 5));
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
final playlistsNeedSync = await _db.needsSync('playlists', maxAge: const Duration(minutes: 5));
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync) {
_logger.log('✅ Cache is fresh, skipping sync');
return;
}
}
_isSyncing = true;
_status = SyncStatus.syncing;
_lastError = null;
notifyListeners();
try {
_logger.log('🔄 Starting background library sync...');
// Fetch fresh data from MA API (in parallel for speed)
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
final albums = results[0] as List<Album>;
final artists = results[1] as List<Artist>;
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
await _saveAudiobooksToCache(audiobooks);
await _savePlaylistsToCache(playlists);
// Update sync metadata
await _db.updateSyncMetadata('albums', albums.length);
await _db.updateSyncMetadata('artists', artists.length);
await _db.updateSyncMetadata('audiobooks', audiobooks.length);
await _db.updateSyncMetadata('playlists', playlists.length);
// Update in-memory cache
_cachedAlbums = albums;
_cachedArtists = artists;
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
_lastSyncTime = DateTime.now();
_status = SyncStatus.completed;
_logger.log('✅ Library sync complete');
} catch (e) {
_logger.log('❌ Library sync failed: $e');
_status = SyncStatus.error;
_lastError = e.toString();
} finally {
_isSyncing = false;
notifyListeners();
}
}
/// Save albums to database cache
Future<void> _saveAlbumsToCache(List<Album> albums) async {
for (final album in albums) {
try {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
}
}
_logger.log('💾 Saved ${albums.length} albums to cache');
}
/// Save artists to database cache
Future<void> _saveArtistsToCache(List<Artist> artists) async {
for (final artist in artists) {
try {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
}
}
_logger.log('💾 Saved ${artists.length} artists to cache');
}
/// Save audiobooks to database cache
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
for (final audiobook in audiobooks) {
try {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
}
}
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
}
/// Save playlists to database cache
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
for (final playlist in playlists) {
try {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
);
} catch (e) {
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
}
}
_logger.log('💾 Saved ${playlists.length} playlists to cache');
}
/// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async {
await syncFromApi(api, force: true);
}
/// Clear all cached data
Future<void> clearCache() async {
if (!_db.isInitialized) return;
await _db.clearAllCache();
_cachedAlbums = [];
_cachedArtists = [];
_cachedAudiobooks = [];
_cachedPlaylists = [];
_lastSyncTime = null;
_status = SyncStatus.idle;
notifyListeners();
_logger.log('🗑️ Library cache cleared');
}
/// Get albums (from cache or empty if not loaded)
List<Album> getAlbums() => _cachedAlbums;
/// Get artists (from cache or empty if not loaded)
List<Artist> getArtists() => _cachedArtists;
/// Get audiobooks (from cache or empty if not loaded)
List<Audiobook> getAudiobooks() => _cachedAudiobooks;
/// Get playlists (from cache or empty if not loaded)
List<Playlist> getPlaylists() => _cachedPlaylists;
/// Check if we have data available (from cache or sync)
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
}
+32 -1
View File
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
const Color brandPrimaryColor = Color(0xFF1a1a1a);
const Color brandAccentColor = Color(0xFF604CEC);
// Brand color schemes
// Predefined accent colors for custom theme selection
const List<Color> accentColorOptions = [
Color(0xFF604CEC), // Purple (default)
Color(0xFF2196F3), // Blue
Color(0xFF009688), // Teal
Color(0xFFFF5722), // Deep Orange
Color(0xFFE91E63), // Pink
Color(0xFF4CAF50), // Green
];
// Brand color schemes (default)
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.light,
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
background: const Color(0xFF1a1a1a),
);
// Generate color schemes from a custom seed color
ColorScheme generateLightColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
background: const Color(0xFFFAFAFA),
onBackground: const Color(0xFF1a1a1a),
surface: const Color(0xFFFFFFFF),
onSurface: const Color(0xFF1a1a1a),
);
}
ColorScheme generateDarkColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
}
class AppTheme {
// Generate light theme
static ThemeData lightTheme({ColorScheme? colorScheme}) {
+5 -3
View File
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
child: AlbumDetailsScreen(
album: widget.album,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
@@ -147,13 +149,13 @@ class _AlbumCardState extends State<AlbumCard> {
),
),
const SizedBox(height: 8),
// Album title
// Album title with year
Hero(
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.album.name,
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
+110 -68
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
const AlbumRow({
super.key,
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
required this.loadAlbums,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
});
@override
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
}
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
late Future<List<Album>> _albumsFuture;
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
List<Album> _albums = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
_loadAlbumsOnce();
_loadAlbums();
}
void _loadAlbumsOnce() {
if (!_hasLoaded) {
_albumsFuture = widget.loadAlbums().then((albums) {
_cachedAlbums = albums;
return albums;
});
Future<void> _loadAlbums() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedAlbums = widget.getCachedAlbums?.call();
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
if (mounted) {
setState(() {
_albums = cachedAlbums;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
setState(() {
_albums = freshAlbums;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAlbumImages(freshAlbums);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache album images so hero animations are smooth on first tap
void _precacheAlbumImages(List<Album> albums) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final albumsToCache = albums.take(10);
for (final album in albumsToCache) {
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_albums.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
),
),
Expanded(
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+2
View File
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: colorScheme.surfaceVariant,
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
+19 -18
View File
@@ -100,21 +100,16 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular, constrained to be a proper circle
Expanded(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Hero(
// Artist image - circular
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
// before Hero animation starts. This prevents grey icon flash caused by
// dynamic sizing inside the Hero widget.
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: size,
height: size,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
@@ -122,7 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
@@ -131,18 +127,23 @@ class _ArtistCardState extends State<ArtistCard> {
_onImageError();
});
}
return Icon(
return Center(
child: Icon(
Icons.person_rounded,
size: size * 0.55,
size: 64,
color: colorScheme.onSurfaceVariant,
),
);
},
)
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
: Center(
child: Icon(
Icons.person_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
);
},
),
),
),
+110 -68
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Artist>? Function()? getCachedArtists;
const ArtistRow({
super.key,
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
required this.loadArtists,
this.heroTagSuffix,
this.rowHeight,
this.getCachedArtists,
});
@override
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
}
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
late Future<List<Artist>> _artistsFuture;
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
List<Artist> _artists = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
_loadArtistsOnce();
_loadArtists();
}
void _loadArtistsOnce() {
if (!_hasLoaded) {
_artistsFuture = widget.loadArtists().then((artists) {
_cachedArtists = artists;
return artists;
});
Future<void> _loadArtists() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedArtists = widget.getCachedArtists?.call();
if (cachedArtists != null && cachedArtists.isNotEmpty) {
if (mounted) {
setState(() {
_artists = cachedArtists;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshArtists = await widget.loadArtists();
if (mounted && freshArtists.isNotEmpty) {
setState(() {
_artists = freshArtists;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheArtistImages(freshArtists);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache artist images so hero animations are smooth on first tap
void _precacheArtistImages(List<Artist> artists) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final artistsToCache = artists.take(10);
for (final artist in artistsToCache) {
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_artists.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = _artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
),
),
Expanded(
child: FutureBuilder<List<Artist>>(
future: _artistsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
// Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && artists == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (artists == null || artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+291
View File
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart';
class AudiobookRow extends StatefulWidget {
final String title;
final Future<List<Audiobook>> Function() loadAudiobooks;
final String? heroTagSuffix;
final double? rowHeight;
const AudiobookRow({
super.key,
required this.title,
required this.loadAudiobooks,
this.heroTagSuffix,
this.rowHeight,
});
@override
State<AudiobookRow> createState() => _AudiobookRowState();
}
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
late Future<List<Audiobook>> _audiobooksFuture;
List<Audiobook>? _cachedAudiobooks;
bool _hasLoaded = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadAudiobooksOnce();
}
void _loadAudiobooksOnce() {
if (!_hasLoaded) {
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
_cachedAudiobooks = audiobooks;
return audiobooks;
});
_hasLoaded = true;
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('AudiobookRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: FutureBuilder<List<Audiobook>>(
future: _audiobooksFuture,
builder: (context, snapshot) {
final audiobooks = snapshot.data ?? _cachedAudiobooks;
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && audiobooks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (audiobooks == null || audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: audiobooks.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final audiobook = audiobooks[index];
return Container(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _AudiobookCard(
audiobook: audiobook,
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
maProvider: maProvider,
),
);
},
),
);
},
),
),
],
),
),
);
_logger.endBuild('AudiobookRow:${widget.title}');
return result;
}
}
class _AudiobookCard extends StatelessWidget {
final Audiobook audiobook;
final String heroTagSuffix;
final MusicAssistantProvider maProvider;
const _AudiobookCard({
required this.audiobook,
required this.heroTagSuffix,
required this.maProvider,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = maProvider.getImageUrl(audiobook, size: 256);
return GestureDetector(
onTap: () {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: audiobook,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
height: double.infinity,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: Duration.zero for hero-wrapped images
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
// Progress indicator
if (audiobook.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value: audiobook.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 4,
),
),
),
],
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.audiobookTitle + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
child: Material(
color: Colors.transparent,
child: Text(
audiobook.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
audiobook.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+10 -7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A reusable widget for displaying disconnected/offline state.
@@ -21,29 +22,31 @@ class DisconnectedState extends StatelessWidget {
});
/// Simple disconnected state without action button
factory DisconnectedState.simple() => const DisconnectedState(
title: 'Not connected to Music Assistant',
factory DisconnectedState.simple(BuildContext context) => DisconnectedState(
title: S.of(context)!.notConnected,
);
/// Disconnected state with settings button for navigation
factory DisconnectedState.withSettingsAction({
required BuildContext context,
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not connected to Music Assistant',
actionLabel: 'Configure Server',
title: S.of(context)!.notConnected,
actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded,
onAction: onSettings,
);
/// Full disconnected state with title, subtitle, and action (for home screen)
factory DisconnectedState.full({
required BuildContext context,
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not Connected',
subtitle: 'Connect to your Music Assistant server to start listening',
actionLabel: 'Configure Server',
title: S.of(context)!.notConnectedTitle,
subtitle: S.of(context)!.connectHint,
actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded,
onAction: onSettings,
iconSize: 80,
+41 -17
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A reusable empty state widget for displaying when no data is available.
@@ -21,48 +22,66 @@ class EmptyState extends StatelessWidget {
});
/// Creates an empty state for artists
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
factory EmptyState.artists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.person_outline_rounded,
message: 'No artists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
message: s.noArtistsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for albums
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
factory EmptyState.albums({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.album_outlined,
message: 'No albums found',
actionLabel: onRefresh != null ? 'Refresh' : null,
message: s.noAlbumsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for tracks
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
factory EmptyState.tracks({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.music_note_outlined,
message: 'No tracks found',
actionLabel: onRefresh != null ? 'Refresh' : null,
message: s.noTracksFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for playlists
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
factory EmptyState.playlists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.playlist_play_rounded,
message: 'No playlists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
message: s.noPlaylistsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for queue
factory EmptyState.queue() => const EmptyState(
factory EmptyState.queue({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.queue_music,
message: 'Queue is empty',
message: s.queueIsEmpty,
);
}
/// Creates an empty state for search results
factory EmptyState.search() => const EmptyState(
factory EmptyState.search({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.search_off_rounded,
message: 'No results found',
message: s.noResultsFound,
);
}
/// Creates a custom empty state with title and subtitle
factory EmptyState.custom({
@@ -70,12 +89,14 @@ class EmptyState extends StatelessWidget {
required String title,
String? subtitle,
VoidCallback? onRefresh,
required BuildContext context,
}) =>
_CustomEmptyState(
icon: icon,
title: title,
subtitle: subtitle,
onRefresh: onRefresh,
context: context,
);
@override
@@ -126,17 +147,20 @@ class _CustomEmptyState extends EmptyState {
final String title;
final String? subtitle;
final VoidCallback? onRefresh;
final BuildContext context;
const _CustomEmptyState({
required super.icon,
required this.title,
this.subtitle,
this.onRefresh,
required this.context,
}) : super(message: title);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final s = S.of(context)!;
return Center(
child: Padding(
@@ -175,7 +199,7 @@ class _CustomEmptyState extends EmptyState {
ElevatedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
label: Text(s.refresh),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
File diff suppressed because it is too large Load Diff
+467 -20
View File
@@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
@@ -110,13 +116,74 @@ class GlobalPlayerOverlay extends StatefulWidget {
static void showPlayer() {
_overlayStateKey.currentState?._setHidden(false);
}
/// Show the player reveal overlay with bounce animation
static void showPlayerReveal() {
_overlayStateKey.currentState?._showPlayerReveal();
}
/// Hide the player reveal overlay (no animation - used as callback from overlay)
static void hidePlayerReveal() {
_overlayStateKey.currentState?._hidePlayerReveal();
}
/// Dismiss the player reveal overlay with animation (for back gesture)
static void dismissPlayerReveal() {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger single bounce on mini player (called when device selector collapses)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
}
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<double> _slideAnimation;
// Single bounce controller for device selector expand/collapse
late AnimationController _singleBounceController;
// Double bounce controller for hint
late AnimationController _doubleBounceController;
// State for player reveal overlay
bool _isRevealVisible = false;
// State for interactive hint mode (blur backdrop + wait for user action)
bool _isHintModeActive = false;
Timer? _hintBounceTimer;
// Track if player reveal was triggered from onboarding (for showing extra hints)
bool _isOnboardingReveal = false;
// Bounce offset for mini player (used by both single and double bounce)
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
// Hint system state
// NOTE: AppStartup now handles the settings loading gate. It waits for:
// - First-time users: connected + player selected before showing HomeScreen
// - Returning users: connected before showing HomeScreen
// This eliminates the race condition that caused home screen flash.
bool _showHints = true;
bool _hasCompletedOnboarding = false; // First-use welcome screen
bool _hintTriggered = false; // Prevent multiple triggers per session
bool _settingsLoaded = false; // True once settings have been loaded from storage
bool _miniPlayerHintsReady = false; // True once connected with player (can show hints)
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
// Welcome content fade-in animation
late AnimationController _welcomeFadeController;
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@override
void initState() {
super.initState();
@@ -129,11 +196,135 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// Single bounce for device selector expand/collapse
_singleBounceController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_singleBounceController.addListener(() {
final t = Curves.easeOut.transform(_singleBounceController.value);
// Single bounce: up to 10px then back to 0
if (t < 0.5) {
_bounceOffsetNotifier.value = 10.0 * (t * 2); // 0 -> 10
} else {
_bounceOffsetNotifier.value = 10.0 * ((1.0 - t) * 2); // 10 -> 0
}
});
// Hint bounce - single gentle bounce to draw attention
_doubleBounceController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_doubleBounceController.addListener(() {
final t = Curves.easeOut.transform(_doubleBounceController.value);
// Single bounce: up 20px then back down
if (t < 0.5) {
_bounceOffsetNotifier.value = 20.0 * (t * 2); // 0 -> 20
} else {
_bounceOffsetNotifier.value = 20.0 * ((1.0 - t) * 2); // 20 -> 0
}
});
// Welcome content fade-in
_welcomeFadeController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
// Load hint settings and start welcome immediately if needed
_loadHintSettings();
}
Future<void> _loadHintSettings() async {
_showHints = await SettingsService.getShowHints();
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
// If first-time user and already connected (AppStartup waited for us),
// start welcome screen immediately - no race condition possible
if (!_hasCompletedOnboarding && !_hintTriggered) {
final provider = context.read<MusicAssistantProvider>();
if (provider.isConnected && provider.selectedPlayer != null) {
_miniPlayerHintsReady = true;
_startWelcomeScreen();
_startMiniPlayerBounce();
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Check if we should enable mini player hints now that we have connection
_checkConnectionForMiniPlayerHints();
}
/// Check connection state and start welcome screen if appropriate.
/// This is called from multiple places:
/// 1. didChangeDependencies() - when widget first builds
/// 2. _loadHintSettings() - when settings finish loading
/// 3. build() - when provider state changes (via post-frame callback)
void _checkConnectionForMiniPlayerHints() {
if (_hintTriggered || !mounted || !_settingsLoaded) return;
if (_hasCompletedOnboarding) return; // Not first-time user
final provider = context.read<MusicAssistantProvider>();
if (provider.isConnected && provider.selectedPlayer != null) {
// Connected with a player - start welcome screen now
_miniPlayerHintsReady = true;
_startWelcomeScreen();
// Start the bounce animation now that mini player is visible
_startMiniPlayerBounce();
}
}
/// Start the bounce animation for mini player hints
void _startMiniPlayerBounce() {
if (!_isHintModeActive || !mounted) return;
_doubleBounceController.reset();
_doubleBounceController.forward();
// Repeat bounce every 2 seconds
_hintBounceTimer?.cancel();
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (_isHintModeActive && mounted) {
_doubleBounceController.reset();
_doubleBounceController.forward();
}
});
}
/// Start the welcome screen with fade-in animation
void _startWelcomeScreen() {
if (_hintTriggered) return;
_hintTriggered = true;
// Activate hint mode immediately (blur backdrop visible)
setState(() {
_isHintModeActive = true;
});
// Fade in welcome content (logo + title)
_welcomeFadeController.forward();
// Mini player bounce is started separately once connected via _startMiniPlayerBounce()
}
@override
void dispose() {
_hintBounceTimer?.cancel();
_slideController.dispose();
_singleBounceController.dispose();
_doubleBounceController.dispose();
_welcomeFadeController.dispose();
_bounceOffsetNotifier.dispose();
_hintOpacityNotifier.dispose();
super.dispose();
}
@@ -145,12 +336,121 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}
}
void _showPlayerReveal() {
if (_isRevealVisible) return;
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
// Track if coming from hint mode (for onboarding hints in player selector)
final wasInHintMode = _isHintModeActive;
// End hint mode if active (user learned the gesture!)
_hintBounceTimer?.cancel();
_hintBounceTimer = null;
_hintOpacityNotifier.value = 0.0;
_isHintModeActive = false;
// Mark onboarding as completed if coming from welcome screen
if (wasInHintMode) {
SettingsService.setHasCompletedOnboarding(true);
}
// Trigger single bounce on expand
_singleBounceController.reset();
_singleBounceController.forward();
setState(() {
_isRevealVisible = true;
_isOnboardingReveal = wasInHintMode;
});
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_isOnboardingReveal = false;
});
}
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
_revealKey.currentState?.dismiss();
}
/// Trigger single bounce on mini player (called when device selector collapses)
void _triggerBounce() {
_singleBounceController.reset();
_singleBounceController.forward();
}
/// End hint mode (called when user taps skip button)
void _endHintMode() {
if (!_isHintModeActive) return;
_hintBounceTimer?.cancel();
_hintBounceTimer = null;
_hintOpacityNotifier.value = 0.0;
_bounceOffsetNotifier.value = 0.0;
// Mark onboarding as completed (first use only)
SettingsService.setHasCompletedOnboarding(true);
setState(() {
_isHintModeActive = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Stack(
// Watch provider to trigger rebuilds when connection state changes
final provider = context.watch<MusicAssistantProvider>();
// Compute whether this is a first-time user ready for welcome screen.
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
final isFirstTimeUserReady = _settingsLoaded &&
!_hasCompletedOnboarding &&
provider.isConnected &&
provider.selectedPlayer != null;
// Show welcome backdrop if:
// 1. Already in hint mode (_isHintModeActive), OR
// 2. First-time user ready but hint mode not started yet (!_hintTriggered)
// This ensures backdrop appears IMMEDIATELY when HomeScreen renders,
// preventing the flash. Welcome content fades in after via animation.
final shouldShowWelcomeBackdrop = _isHintModeActive ||
(isFirstTimeUserReady && !_hintTriggered);
// Trigger hint mode if conditions are met (for animations, bounce, etc)
if (isFirstTimeUserReady && !_hintTriggered) {
// Schedule for post-frame to avoid setState during build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_hintTriggered) {
_miniPlayerHintsReady = true;
_startWelcomeScreen();
_startMiniPlayerBounce();
}
});
}
// Handle back gesture at top level - dismiss hint mode or device list if visible
return PopScope(
canPop: !_isRevealVisible && !_isHintModeActive,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
if (_isHintModeActive) {
_endHintMode();
} else if (_isRevealVisible) {
_dismissPlayerReveal();
}
}
},
child: Stack(
children: [
// The main app content (Navigator, screens, etc.)
// Add bottom padding to account for bottom nav + mini player
@@ -159,6 +459,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
child: widget.child,
),
// Global persistent bottom navigation bar - positioned at bottom
// Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
@@ -206,26 +510,26 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
@@ -258,32 +562,175 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
},
),
),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
// Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _dismissPlayerReveal,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: colorScheme.surface.withOpacity(0.5),
),
),
),
),
// Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
// Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
// preventing the 1-frame gap where home screen would be visible.
if (shouldShowWelcomeBackdrop)
Positioned.fill(
child: TweenAnimationBuilder<double>(
// Hold solid for 2s, then fade to 0.5 over 1s
tween: Tween<double>(begin: 1.0, end: 0.5),
duration: const Duration(seconds: 3),
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
builder: (context, opacity, child) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
// Use same dark color as connection screen for seamless transition
color: const Color(0xFF1a1a1a).withOpacity(opacity),
),
);
},
),
),
// Welcome message during hint mode - two positioned sections with fade-in
// Top section: Logo and Welcome title (stays fixed at top area)
if (_isHintModeActive)
Positioned(
left: 24,
right: 24,
// Position logo section high up - about 1/3 from top
top: MediaQuery.of(context).size.height * 0.15,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
curve: Curves.easeOut,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Ensemble logo - same as settings screen
Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
const SizedBox(height: 24),
// Welcome title
Text(
S.of(context)!.welcomeToEnsemble,
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
),
// Bottom section: Hint text and Skip button (only show once mini player is visible)
if (_isHintModeActive && _miniPlayerHintsReady)
Positioned(
left: 24,
right: 24,
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
curve: Curves.easeOut,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Hint text
Text(
S.of(context)!.welcomeMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.white,
fontSize: 16,
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Skip button - same gap above as below to mini player
TextButton(
onPressed: _endHintMode,
child: Text(
S.of(context)!.skip,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
),
),
],
),
),
),
// Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
),
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
selector: (_, provider) => (
isConnected: provider.isConnected,
hasPlayer: provider.selectedPlayer != null,
hasTrack: provider.currentTrack != null,
),
builder: (context, state, child) {
// Only show player if connected and has a selected player
if (!state.isConnected || !state.hasPlayer) {
return const SizedBox.shrink();
}
// Combine slide, bounce, and hint animations with ValueListenableBuilders
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
return ValueListenableBuilder<double>(
valueListenable: _bounceOffsetNotifier,
builder: (context, bounceOffset, _) {
return ValueListenableBuilder<double>(
valueListenable: _hintOpacityNotifier,
builder: (context, hintOpacity, _) {
return AnimatedBuilder(
animation: _slideAnimation,
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
builder: (context, staticChild) {
// Only slideOffset changes during animation, player widget stays same
builder: (context, _) {
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: bounceOffset,
onRevealPlayers: _showPlayerReveal,
isDeviceRevealVisible: _isRevealVisible,
isHintVisible: hintOpacity > 0,
);
},
);
},
);
},
);
},
),
],
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' show Audiobook, Chapter;
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
import '../../l10n/app_localizations.dart';
/// Panel that displays audiobook chapters for navigation
class ChaptersPanel extends StatelessWidget {
final MusicAssistantProvider maProvider;
final Audiobook? audiobook;
final Color textColor;
final Color primaryColor;
final Color backgroundColor;
final double topPadding;
final VoidCallback onClose;
const ChaptersPanel({
super.key,
required this.maProvider,
required this.audiobook,
required this.textColor,
required this.primaryColor,
required this.backgroundColor,
required this.topPadding,
required this.onClose,
});
String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds);
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final secs = duration.inSeconds % 60;
if (hours > 0) {
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
return '${minutes}:${secs.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final chapters = audiobook?.chapters ?? [];
final currentChapterIndex = maProvider.getCurrentChapterIndex();
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(
S.of(context)!.chapters,
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Placeholder for symmetry
const SizedBox(width: 48),
],
),
),
// Chapters list
Expanded(
child: chapters.isEmpty
? _buildEmptyState(context)
: _buildChaptersList(chapters, currentChapterIndex),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return EmptyState.custom(
context: context,
icon: Icons.bookmark_outline_rounded,
title: S.of(context)!.noChapters,
subtitle: S.of(context)!.noChapterInfo,
);
}
Widget _buildChaptersList(List<Chapter> chapters, int currentChapterIndex) {
return ListView.builder(
padding: Spacing.paddingH8,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chapters.length,
itemBuilder: (context, index) {
final chapter = chapters[index];
final isCurrentChapter = index == currentChapterIndex;
final isPastChapter = index < currentChapterIndex;
return RepaintBoundary(
child: Opacity(
opacity: isPastChapter ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
decoration: BoxDecoration(
color: isCurrentChapter ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: ListTile(
dense: true,
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isCurrentChapter
? primaryColor.withOpacity(0.2)
: textColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: isCurrentChapter ? primaryColor : textColor.withOpacity(0.6),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
title: Text(
chapter.title,
style: TextStyle(
color: isCurrentChapter ? primaryColor : textColor,
fontSize: 14,
fontWeight: isCurrentChapter ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatDuration(chapter.positionMs ~/ 1000),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 12,
),
),
trailing: isCurrentChapter
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
: null,
onTap: () => _seekToChapter(index),
),
),
),
);
},
);
}
void _seekToChapter(int chapterIndex) {
final player = maProvider.selectedPlayer;
if (player == null || audiobook == null) return;
final chapters = audiobook!.chapters;
if (chapters == null || chapterIndex >= chapters.length) return;
final chapter = chapters[chapterIndex];
// Seek to the chapter's start position (positionMs is in milliseconds)
maProvider.seek(player.playerId, (chapter.positionMs / 1000).round());
}
}
+26 -98
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
import 'mini_player_content.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -34,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
return GestureDetector(
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
@@ -52,50 +55,19 @@ class DeviceSelectorBar extends StatelessWidget {
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(),
_buildPeekContent(context, swipeHint),
// Current player content
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,
),
),
],
),
),
],
),
),
MiniPlayerContent(
primaryText: selectedPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: selectedPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
),
],
),
@@ -106,7 +78,7 @@ class DeviceSelectorBar extends StatelessWidget {
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent() {
Widget _buildPeekContent(BuildContext context, String? swipeHint) {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
@@ -117,61 +89,17 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
peekPlayer.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,
),
),
],
),
),
],
),
child: MiniPlayerContent(
primaryText: peekPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: peekPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: 0, // No additional slide - Transform handles positioning
isHint: swipeHint != null,
),
);
}
/// 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;
}
}
}
+216
View File
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
/// Shared constants for mini player layout
class MiniPlayerLayout {
static const double height = 64.0;
static const double artSize = 64.0;
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 74px
static const double primaryTop = 13.0;
static const double secondaryTop = 33.0;
static const double textRightPadding = 12.0;
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double secondaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
}
/// Unified mini player content widget used by:
/// - DeviceSelectorBar (non-playing state)
/// - ExpandablePlayer collapsed state (playing state)
/// - Peek content during swipe gestures
class MiniPlayerContent extends StatelessWidget {
/// Primary text line (track name or device name)
final String primaryText;
/// Secondary text line (artist name or "Swipe to switch device")
/// If null, primary text will be vertically centered
final String? secondaryText;
/// Album art URL - if null, shows device icon
final String? imageUrl;
/// Player name for icon selection when no image
final String playerName;
/// Background color for the content area
final Color backgroundColor;
/// Text color
final Color textColor;
/// Width of the content area
final double width;
/// Horizontal slide offset for swipe animation (-1 to 1)
final double slideOffset;
/// Whether secondary text is a hint (shows lightbulb icon)
final bool isHint;
/// Whether to show the progress bar overlay (for playing state)
final bool showProgress;
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
const MiniPlayerContent({
super.key,
required this.primaryText,
this.secondaryText,
this.imageUrl,
required this.playerName,
required this.backgroundColor,
required this.textColor,
required this.width,
this.slideOffset = 0.0,
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final slidePixels = slideOffset * width;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Progress bar background (only for playing state)
if (showProgress && progress > 0)
Positioned(
left: MiniPlayerLayout.artSize + slidePixels,
top: 0,
bottom: 0,
width: (width - MiniPlayerLayout.artSize) * progress,
child: Container(color: backgroundColor),
),
// Art / Icon area
Positioned(
left: slidePixels,
top: 0,
child: SizedBox(
width: MiniPlayerLayout.artSize,
height: MiniPlayerLayout.artSize,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
)
: _buildIconArea(iconAreaBackground),
),
),
// Primary text line
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? MiniPlayerLayout.primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Secondary text line (with optional hint icon)
if (hasSecondaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: isHint
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lightbulb_outline,
size: 14,
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
),
const SizedBox(width: 4),
Flexible(
child: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
)
: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// Build the icon area with device-appropriate icon
Widget _buildIconArea(Color background) {
return Container(
color: background,
child: Center(
child: Icon(
_getPlayerIcon(playerName),
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
size: MiniPlayerLayout.iconSize,
),
),
);
}
/// Get appropriate icon based on player name
static IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
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;
}
}
}
+244
View File
@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final bool isGrouped;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
this.isGrouped = false,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onLongPress,
this.onPlayPause,
this.onSkipNext,
this.onPower,
});
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
);
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
return Colors.grey;
}
if (isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}
+11 -13
View File
@@ -55,15 +55,14 @@ class PlayerControls extends StatelessWidget {
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Shuffle (expanded only)
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
// Previous
@@ -90,18 +89,17 @@ class PlayerControls extends StatelessWidget {
),
// Repeat (expanded only)
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: repeatMode != null && repeatMode != 'off'
color: (repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5),
: textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
),
],
);
}
@@ -0,0 +1,377 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart';
import '../../services/settings_service.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
import 'player_card.dart';
/// Overlay that reveals all available players with a staggered slide-up animation.
/// Triggered by swiping down on the mini player or tapping the device button.
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom; // Bottom position of mini player
final double miniPlayerHeight;
final bool showOnboardingHints; // Show additional hints for first-time users
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
this.showOnboardingHints = false,
});
@override
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
}
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
with TickerProviderStateMixin {
late AnimationController _revealController;
late Animation<double> _revealAnimation;
late Animation<double> _backdropAnimation;
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
// Hint system
bool _showHints = true;
@override
void initState() {
super.initState();
// Load hint settings
SettingsService.getShowHints().then((value) {
if (mounted) setState(() => _showHints = value);
});
_revealController = AnimationController(
vsync: this,
);
// Quick smooth animation with subtle overshoot
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_backdropAnimation = CurvedAnimation(
parent: _revealController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
}
});
}
/// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
await maProvider.preloadAllPlayerTracks();
final players = maProvider.availablePlayers;
for (final player in players) {
if (_playerColors.containsKey(player.playerId)) continue;
final track = maProvider.getCachedTrackForPlayer(player.playerId);
if (track == null) continue;
final imageUrl = maProvider.getImageUrl(track, size: 128);
if (imageUrl == null) continue;
try {
final imageProvider = NetworkImage(imageUrl);
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
if (mounted && schemes != null) {
final colorScheme = isDark ? schemes.$2 : schemes.$1;
setState(() {
_playerColors[player.playerId] = colorScheme;
});
}
} catch (_) {
// Ignore color extraction errors
}
}
}
@override
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// Animate reveal (called from parent)
void reveal() {
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
}
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
final defaultBgColor = colorScheme.primaryContainer;
final defaultTextColor = colorScheme.onPrimaryContainer;
// Calculate total stack height for animation
const cardHeight = 64.0;
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
final t = _revealAnimation.value;
return Stack(
children: [
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
),
// Player cards - positioned above mini player
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Hint above player list - different for onboarding vs regular use
// Onboarding: show first-time hint (how to select)
// Regular: show sync hint (tap and hold to sync)
if (players.isNotEmpty && (_showHints || widget.showOnboardingHints))
Builder(
builder: (context) {
// Calculate slide offset to match the topmost card
const baseOffset = 80.0;
final hintDistanceToTravel = baseOffset + (players.length * (cardHeight + cardSpacing));
final hintSlideOffset = hintDistanceToTravel * (1.0 - t);
// Show different hint for onboarding vs regular use
final isOnboarding = widget.showOnboardingHints;
final hintText = isOnboarding
? S.of(context)!.selectPlayerHint
: S.of(context)!.holdToSync;
return Transform.translate(
offset: Offset(0, hintSlideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
type: MaterialType.transparency,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
size: 18,
color: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 6),
Text(
hintText,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
},
),
// Build player cards - all slide from behind mini player
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
maProvider.togglePlayerSync(player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
),
),
);
}),
],
),
),
),
],
);
},
);
},
);
}
}
+1
View File
@@ -1,6 +1,7 @@
/// Player widget components extracted from expandable_player.dart
library player_widgets;
export 'chapters_panel.dart';
export 'device_selector_bar.dart';
export 'player_controls.dart';
export 'queue_panel.dart';
+8 -3
View File
@@ -70,7 +70,7 @@ class QueuePanel extends StatelessWidget {
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState()
? _buildEmptyState(context)
: _buildQueueList(),
),
],
@@ -78,8 +78,8 @@ class QueuePanel extends StatelessWidget {
);
}
Widget _buildEmptyState() {
return EmptyState.queue();
Widget _buildEmptyState(BuildContext context) {
return EmptyState.queue(context: context);
}
Widget _buildQueueList() {
@@ -118,6 +118,11 @@ class QueuePanel extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
+101 -34
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
@@ -17,8 +19,27 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
final selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Badge(
isLabelVisible: playingCount > 0,
backgroundColor: colorScheme.tertiary,
textColor: colorScheme.onTertiary,
label: Text(
playingCount.toString(),
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 11,
color: colorScheme.onTertiary,
),
),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
@@ -55,6 +76,7 @@ class PlayerSelector extends StatelessWidget {
),
),
),
),
);
}
@@ -63,27 +85,8 @@ class PlayerSelector extends StatelessWidget {
MusicAssistantProvider provider,
List players,
) {
// Collapse player if expanded before showing sheet
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
// Show the bottom sheet
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true,
builder: (context) {
return _PlayerSelectorSheet();
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
}
@@ -182,7 +185,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
),
)
@@ -199,23 +202,34 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// but still have track info - treat as "has content" for display
final isIdle = player.state == 'idle';
// Check for external source (optical, Spotify, etc.)
final isExternalSource = player.isExternalSource;
// Get track info - use current track for selected player, cache for others
final playerTrack = isSelected
// Skip track info for external sources
final playerTrack = isExternalSource
? null
: (isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId);
: maProvider.getCachedTrackForPlayer(player.playerId));
// Player has content if playing, paused, or idle with cached track
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
// Player has content if playing, paused, or idle with cached track (but not external source)
final hasContent = !isExternalSource && (isPlaying || isPaused || (isIdle && playerTrack != null));
// Get album art for any player with content
// Get album art for any player with content (skip for external source)
String? albumArtUrl;
if (playerTrack != null && isOn && hasContent) {
if (!isExternalSource && playerTrack != null && isOn && hasContent) {
albumArtUrl = maProvider.getImageUrl(
playerTrack.album ?? playerTrack,
size: 128,
);
}
// Check if this player is part of a sync group
final isGrouped = player.isGrouped;
// Pastel yellow for grouped players
const groupBorderColor = Color(0xFFFFF59D);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
@@ -223,6 +237,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
maProvider.selectPlayer(player);
Navigator.pop(context);
},
onLongPress: () {
// Haptic feedback for sync action
HapticFeedback.mediumImpact();
// Show snackbar for visual feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
// Long-press to sync/unsync player
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
maProvider.togglePlayerSync(player.playerId);
},
borderRadius: BorderRadius.circular(16),
child: Container(
height: 72,
@@ -231,6 +262,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.primary.withOpacity(0.15)
: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
),
child: Row(
children: [
@@ -293,6 +327,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.onSurface.withOpacity(0.24)
: !isOn
? colorScheme.onSurface.withOpacity(0.3)
: isExternalSource
? Colors.cyan // external source (optical, Spotify, etc.)
: isPlaying
? Colors.green
: hasContent
@@ -304,12 +340,14 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
Expanded(
child: Text(
!player.available
? 'Unavailable'
? S.of(context)!.playerStateUnavailable
: !isOn
? 'Off'
? S.of(context)!.playerStateOff
: isExternalSource
? S.of(context)!.playerStateExternalSource
: hasContent && playerTrack != null
? playerTrack.name
: 'Idle',
: S.of(context)!.playerStateIdle,
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
@@ -320,6 +358,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
overflow: TextOverflow.ellipsis,
),
),
if (isGrouped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: groupBorderColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.link_rounded,
size: 10,
color: Colors.amber.shade800,
),
const SizedBox(width: 3),
Text(
'Synced',
style: TextStyle(
color: Colors.amber.shade800,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
if (isSelected) ...[
const SizedBox(width: 8),
Container(
@@ -329,7 +396,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Selected',
S.of(context)!.playerSelected,
style: TextStyle(
color: colorScheme.primary,
fontSize: 10,
@@ -343,8 +410,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
],
),
),
// Play/Pause button
if (player.available && isOn)
// Play/Pause button (hidden for external sources - controls don't work)
if (player.available && isOn && !isExternalSource)
IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
+496
View File
@@ -0,0 +1,496 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../screens/audiobook_series_screen.dart';
import '../utils/page_transitions.dart';
class SeriesRow extends StatefulWidget {
final String title;
final Future<List<AudiobookSeries>> Function() loadSeries;
final double? rowHeight;
const SeriesRow({
super.key,
required this.title,
required this.loadSeries,
this.rowHeight,
});
@override
State<SeriesRow> createState() => _SeriesRowState();
}
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
late Future<List<AudiobookSeries>> _seriesFuture;
List<AudiobookSeries>? _cachedSeries;
bool _hasLoaded = false;
// Cache for series cover images
final Map<String, List<String>> _seriesCovers = {};
final Set<String> _loadingCovers = {};
// Cache for series book counts
final Map<String, int> _seriesBookCounts = {};
// Cache for extracted colors from covers
final Map<String, List<Color>> _seriesExtractedColors = {};
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadSeriesOnce();
}
void _loadSeriesOnce() {
if (!_hasLoaded) {
_seriesFuture = widget.loadSeries().then((series) {
_cachedSeries = series;
return series;
});
_hasLoaded = true;
}
}
Future<void> _loadSeriesCovers(String seriesId, MusicAssistantProvider maProvider) async {
if (_loadingCovers.contains(seriesId) || _seriesCovers.containsKey(seriesId)) {
return;
}
_loadingCovers.add(seriesId);
try {
final books = await maProvider.api?.getSeriesAudiobooks(seriesId);
if (books != null && books.isNotEmpty && mounted) {
final covers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
covers.add(imageUrl);
}
}
if (mounted) {
setState(() {
_seriesCovers[seriesId] = covers;
_seriesBookCounts[seriesId] = books.length;
});
// Extract colors asynchronously
_extractSeriesColors(seriesId, covers);
}
}
} finally {
_loadingCovers.remove(seriesId);
}
}
/// Extract colors from series book covers for empty cell backgrounds
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
final extractedColors = <Color>[];
for (final url in covers.take(4)) {
try {
final palette = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(url),
maximumColorCount: 8,
);
if (palette.darkMutedColor != null) {
extractedColors.add(palette.darkMutedColor!.color);
}
if (palette.mutedColor != null) {
extractedColors.add(palette.mutedColor!.color);
}
if (palette.darkVibrantColor != null) {
extractedColors.add(palette.darkVibrantColor!.color);
}
if (palette.dominantColor != null) {
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
}
} catch (e) {
_logger.log('📚 Error extracting colors: $e');
}
}
if (extractedColors.isNotEmpty && mounted) {
setState(() {
_seriesExtractedColors[seriesId] = extractedColors;
});
}
}
static final _logger = DebugLogger();
@override
Widget build(BuildContext context) {
_logger.startBuild('SeriesRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: FutureBuilder<List<AudiobookSeries>>(
future: _seriesFuture,
builder: (context, snapshot) {
final series = snapshot.data ?? _cachedSeries;
if (series == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && series == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (series == null || series.isEmpty) {
return Center(
child: Text(
'No series found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: series.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final s = series[index];
// Load covers if not cached
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSeriesCovers(s.id, maProvider);
});
}
return Container(
key: ValueKey(s.id),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _SeriesCard(
series: s,
covers: _seriesCovers[s.id],
cachedBookCount: _seriesBookCounts[s.id],
extractedColors: _seriesExtractedColors[s.id],
colorScheme: colorScheme,
textTheme: textTheme,
),
);
},
),
);
},
),
),
],
),
),
);
_logger.endBuild('SeriesRow:${widget.title}');
return result;
}
}
class _SeriesCard extends StatelessWidget {
final AudiobookSeries series;
final List<String>? covers;
final int? cachedBookCount;
final List<Color>? extractedColors;
final ColorScheme colorScheme;
final TextTheme textTheme;
// Fallback colors when no extracted colors available
static const _fallbackColors = [
Color(0xFF2D3436), // Dark slate
Color(0xFF34495E), // Dark blue-grey
Color(0xFF4A3728), // Dark brown
Color(0xFF2C3E50), // Midnight blue
Color(0xFF3D3D3D), // Charcoal
Color(0xFF4A4458), // Dark purple-grey
Color(0xFF3E4A47), // Dark teal-grey
Color(0xFF4A3F35), // Dark warm grey
];
const _SeriesCard({
required this.series,
required this.covers,
this.cachedBookCount,
this.extractedColors,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
final heroTag = 'series_cover_home_${series.id}';
return GestureDetector(
onTap: () {
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Use AspectRatio to guarantee square cover
Hero(
tag: heroTag,
// PERF: Use flightShuttleBuilder to avoid animating complex grid
// during hero flight. Show cached first cover image instead.
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Use first cover as simple placeholder during flight
final heroChild = toContext.widget as Hero;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return ClipRRect(
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
child: child,
);
},
child: covers != null && covers!.isNotEmpty
? CachedNetworkImage(
imageUrl: covers!.first,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
),
);
},
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildCoverGrid(),
),
),
),
const SizedBox(height: 8),
Text(
series.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Builder(
builder: (context) {
final count = series.bookCount ?? cachedBookCount;
if (count == null) return const SizedBox.shrink();
return Text(
'$count ${count == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
);
}
Widget _buildCoverGrid() {
if (covers == null || covers!.isEmpty) {
// Static placeholder
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
);
}
// Determine grid size
int gridSize;
if (covers!.length == 1) {
gridSize = 1;
} else if (covers!.length <= 4) {
gridSize = 2;
} else {
gridSize = 3;
}
// Pad covers to fill grid
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
for (var i = 0; i < covers!.length && i < displayCovers.length; i++) {
displayCovers[i] = covers![i];
}
// Use series ID to pick consistent colors
final colorSeed = series.id.hashCode;
// Get colors for empty cells
final emptyColors = (extractedColors != null && extractedColors!.isNotEmpty)
? extractedColors!
: _fallbackColors;
// Build grid using Column/Row (parent AspectRatio ensures square)
return Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
children: List.generate(gridSize, (col) {
final index = row * gridSize + col;
final coverUrl = displayCovers[index];
return Expanded(
child: coverUrl != null
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: _buildEmptyCell(colorSeed, index, emptyColors),
);
}),
),
);
}),
);
}
/// Builds an empty cell with either a solid color or a nested grid
Widget _buildEmptyCell(int colorSeed, int cellIndex, List<Color> emptyColors) {
// Tone down colors
final colors = emptyColors.map((c) {
final hsl = HSLColor.fromColor(c);
return hsl
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
.toColor();
}).toList();
// Use combined seed for deterministic but varied patterns
final seed = colorSeed + cellIndex * 17;
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
final sizeRoll = seed.abs() % 100;
int nestedSize;
if (sizeRoll < 50) {
nestedSize = 1;
} else if (sizeRoll < 80) {
nestedSize = 2;
} else {
nestedSize = 3;
}
if (nestedSize == 1) {
final colorIndex = seed.abs() % colors.length;
return Container(color: colors[colorIndex]);
}
// Build nested grid (seamless)
return Column(
children: List.generate(nestedSize, (row) {
return Expanded(
child: Row(
children: List.generate(nestedSize, (col) {
final nestedIndex = row * nestedSize + col;
final nestedSeed = seed + nestedIndex * 7;
final colorIndex = nestedSeed.abs() % colors.length;
return Expanded(
child: Container(color: colors[colorIndex]),
);
}),
),
);
}),
);
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+3 -1
View File
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
// PERF: Duration.zero for smooth scrolling
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.music_note_rounded,
+15 -2
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 1.9.2+13
version: 2.7.0-beta+30
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -9,6 +9,9 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
# UI
material_design_icons_flutter: ^7.0.7296
@@ -25,12 +28,16 @@ dependencies:
# Storage
shared_preferences: ^2.2.2
drift: ^2.22.0
drift_flutter: 0.2.0
path_provider: ^2.1.5
sqlite3_flutter_libs: ^0.5.28
# Utilities
uuid: ^4.2.1
package_info_plus: ^8.0.0
device_info_plus: ^10.1.0
share_plus: ^9.0.0
share_plus: ^10.1.4
# Audio - Local Playback
just_audio: ^0.9.36
@@ -38,13 +45,19 @@ dependencies:
audio_session: ^0.1.18
flutter_volume_controller: ^1.3.2
# Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter_launcher_icons: ^0.13.1
build_runner: ^2.4.13
drift_dev: ^2.22.1
flutter:
generate: true
uses-material-design: true
assets: