Compare commits

..
Author SHA1 Message Date
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
41 changed files with 8914 additions and 232 deletions
+6
View File
@@ -4,10 +4,13 @@ on:
push: push:
branches: branches:
- main - main
- master
- 'claude/**' - 'claude/**'
- 'feature/**'
pull_request: pull_request:
branches: branches:
- main - main
- master
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -35,6 +38,9 @@ jobs:
- name: Get dependencies - name: Get dependencies
run: flutter pub get run: flutter pub get
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate launcher icons - name: Generate launcher icons
run: flutter pub run flutter_launcher_icons run: flutter pub run flutter_launcher_icons
+21 -13
View File
@@ -44,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
- **Playlist Support** - Browse and play your playlists - **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library - **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 ### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost - **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 - **Hero Animations** - Smooth transitions between screens
### Theming ### Theming
@@ -57,15 +67,14 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250"> <img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png" alt="Home Screen" width="250"> <img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png" alt="Album Details" width="250"> <img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
</div> <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">
<div align="center"> <img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/4.png" alt="Now Playing" width="250"> <img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/5.png" alt="Device Selection" width="250"> <img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/6.png" alt="Settings" width="250">
</div> </div>
## Download ## Download
@@ -94,11 +103,10 @@ Ensemble supports multiple authentication methods:
## Requirements ## 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 - Network connectivity to your Music Assistant server
- Android device (Android 5.0+) - Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
> **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.
## License ## License
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
```
+12
View File
@@ -13,4 +13,16 @@ class HeroTags {
/// Artist image hero tag prefix /// Artist image hero tag prefix
static const String artistImage = 'artist_image_'; 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_';
} }
+330
View File
@@ -0,0 +1,330 @@
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};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Future migrations will go here
},
);
}
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;
}
}
+14
View File
@@ -8,6 +8,9 @@ import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'services/settings_service.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/audio/massiv_audio_handler.dart';
import 'services/auth/auth_manager.dart'; import 'services/auth/auth_manager.dart';
import 'services/debug_logger.dart'; import 'services/debug_logger.dart';
@@ -27,6 +30,17 @@ final _logger = DebugLogger();
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); 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 // Create auth manager for streaming headers
final authManager = AuthManager(); final authManager = AuthManager();
+411
View File
@@ -4,6 +4,10 @@ enum MediaType {
track, track,
playlist, playlist,
radio, radio,
audiobook,
chapter,
podcast,
podcastEpisode,
} }
class ProviderMapping { class ProviderMapping {
@@ -140,11 +144,15 @@ class Artist extends MediaItem {
favorite: item.favorite, favorite: item.favorite,
); );
} }
@override
Map<String, dynamic> toJson() => super.toJson();
} }
class Album extends MediaItem { class Album extends MediaItem {
final List<Artist>? artists; final List<Artist>? artists;
final String? albumType; final String? albumType;
final int? year;
Album({ Album({
required super.itemId, required super.itemId,
@@ -152,6 +160,7 @@ class Album extends MediaItem {
required super.name, required super.name,
this.artists, this.artists,
this.albumType, this.albumType,
this.year,
super.sortName, super.sortName,
super.uri, super.uri,
super.providerMappings, super.providerMappings,
@@ -161,6 +170,15 @@ class Album extends MediaItem {
factory Album.fromJson(Map<String, dynamic> json) { factory Album.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(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( return Album(
itemId: item.itemId, itemId: item.itemId,
provider: item.provider, provider: item.provider,
@@ -169,6 +187,7 @@ class Album extends MediaItem {
?.map((e) => Artist.fromJson(e as Map<String, dynamic>)) ?.map((e) => Artist.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
albumType: json['album_type'] as String?, albumType: json['album_type'] as String?,
year: year,
sortName: item.sortName, sortName: item.sortName,
uri: item.uri, uri: item.uri,
providerMappings: item.providerMappings, providerMappings: item.providerMappings,
@@ -179,6 +198,28 @@ class Album extends MediaItem {
String get artistsString => String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist'; 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 { class Track extends MediaItem {
@@ -224,6 +265,16 @@ class Track extends MediaItem {
String get artistsString => String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist'; 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 { class Playlist extends MediaItem {
@@ -262,3 +313,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?,
);
}
}
+12 -1
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/error_handler.dart'; import '../services/error_handler.dart';
import '../services/auth/auth_manager.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 { Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return; if (_api == null) return;
@@ -132,6 +134,15 @@ class ConnectionProvider with ChangeNotifier {
if (profileName != null && profileName.isNotEmpty) { if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName); await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $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 { } else {
_logger.log('⚠️ No valid name in profile'); _logger.log('⚠️ No valid name in profile');
} }
+289 -12
View File
@@ -13,6 +13,8 @@ import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart'; import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart'; import '../services/device_id_service.dart';
import '../services/cache_service.dart'; import '../services/cache_service.dart';
import '../services/recently_played_service.dart';
import '../services/sync_service.dart';
import '../services/local_player_service.dart'; import '../services/local_player_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/position_tracker.dart'; import '../services/position_tracker.dart';
@@ -52,6 +54,7 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer; Player? _selectedPlayer;
List<Player> _availablePlayers = []; List<Player> _availablePlayers = [];
Track? _currentTrack; Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
Timer? _playerStateTimer; Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players Timer? _notificationPositionTimer; // Updates notification position every second for remote players
@@ -100,10 +103,22 @@ class MusicAssistantProvider with ChangeNotifier {
List<Track> get tracks => _tracks; List<Track> get tracks => _tracks;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
/// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing;
/// Current sync status
SyncStatus get syncStatus => SyncService.instance.status;
Player? get selectedPlayer => _selectedPlayer; Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers; List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack; Track? get currentTrack => _currentTrack;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
/// Whether we're currently playing an audiobook
bool get isPlayingAudiobook => _currentAudiobook != null;
String get lastSearchQuery => _lastSearchQuery; String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults; Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
@@ -1162,6 +1177,18 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================ // ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async { Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
// Try local database first for instant results
if (!forceRefresh) {
final localAlbums = await RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
if (localAlbums.isNotEmpty) {
_logger.log('📦 Using ${localAlbums.length} local recently played albums');
return localAlbums;
}
}
// Fall back to memory cache
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) { if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums'); _logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!; return _cacheService.getCachedRecentAlbums()!;
@@ -1170,7 +1197,7 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? []; if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try { try {
_logger.log('🔄 Fetching fresh recent albums...'); _logger.log('🔄 Fetching fresh recent albums from MA...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit); final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums); _cacheService.setCachedRecentAlbums(albums);
return albums; return albums;
@@ -1222,6 +1249,28 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache(); _cacheService.invalidateHomeCache();
} }
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) return;
_logger.log('🔄 Forcing full library sync...');
await SyncService.instance.forceSync(_api!);
// Update local lists from sync result
_albums = SyncService.instance.cachedAlbums;
_artists = SyncService.instance.cachedArtists;
// Also refresh tracks from API
try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
} catch (e) {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
notifyListeners();
_logger.log('✅ Force sync complete');
}
// ============================================================================ // ============================================================================
// FAVORITES FOR HOME SCREEN // FAVORITES FOR HOME SCREEN
// ============================================================================ // ============================================================================
@@ -1244,6 +1293,64 @@ class MusicAssistantProvider with ChangeNotifier {
return _tracks.where((t) => t.favorite == true).toList(); return _tracks.where((t) => t.favorite == true).toList();
} }
// ============================================================================
// AUDIOBOOK HOME SCREEN ROWS
// ============================================================================
/// Get audiobooks that have progress (continue listening)
Future<List<Audiobook>> getInProgressAudiobooks() async {
if (_api == null) return [];
try {
_logger.log('📚 Fetching in-progress audiobooks...');
final allAudiobooks = await _api!.getAudiobooks();
// Filter to only those with progress, sorted by most recent/highest progress
final inProgress = allAudiobooks
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
.toList()
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
_logger.log('📚 Found ${inProgress.length} in-progress audiobooks');
return inProgress.take(20).toList(); // Limit to 20 for home row
} catch (e) {
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
return [];
}
}
/// Get random audiobooks for discovery
Future<List<Audiobook>> getDiscoverAudiobooks() async {
if (_api == null) return [];
try {
_logger.log('📚 Fetching discover audiobooks...');
final allAudiobooks = await _api!.getAudiobooks();
// Shuffle and take a subset
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
return shuffled.take(20).toList();
} catch (e) {
_logger.log('❌ Failed to fetch discover audiobooks: $e');
return [];
}
}
/// Get random series for discovery
Future<List<AudiobookSeries>> getDiscoverSeries() async {
if (_api == null) return [];
try {
_logger.log('📚 Fetching discover series...');
final allSeries = await _api!.getAudiobookSeries();
// Shuffle and take a subset
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
return shuffled.take(20).toList();
} catch (e) {
_logger.log('❌ Failed to fetch discover series: $e');
return [];
}
}
// ============================================================================ // ============================================================================
// DETAIL SCREEN CACHING // DETAIL SCREEN CACHING
// ============================================================================ // ============================================================================
@@ -1423,8 +1530,17 @@ class MusicAssistantProvider with ChangeNotifier {
return false; 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_')
_logger.log('🚫 Filtering out MA Web UI player: ${player.name}'); // 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_')) {
_logger.log('🚫 Filtering out MA Web UI player: ${player.name} (provider: ${player.provider}, id: ${player.playerId})');
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
_logger.log('🚫 Filtering out "This Device" player: ${player.name}');
filteredCount++; filteredCount++;
return false; return false;
} }
@@ -1950,6 +2066,12 @@ class MusicAssistantProvider with ChangeNotifier {
_currentTrack = null; _currentTrack = null;
stateChanged = true; stateChanged = true;
} }
// Clear audiobook context when playback stops
if (_currentAudiobook != null) {
_logger.log('📚 Playback stopped, clearing audiobook context');
_currentAudiobook = null;
stateChanged = true;
}
if (stateChanged) { if (stateChanged) {
notifyListeners(); notifyListeners();
@@ -1967,6 +2089,19 @@ class MusicAssistantProvider with ChangeNotifier {
if (trackChanged) { if (trackChanged) {
_currentTrack = queue.currentItem!.track; _currentTrack = queue.currentItem!.track;
stateChanged = true; stateChanged = true;
// Clear audiobook context if switched to a different media item
// Check if the new track is from the same audiobook (by comparing URIs)
if (_currentAudiobook != null) {
final currentUri = _currentTrack!.uri ?? '';
final audiobookUri = _currentAudiobook!.uri ?? 'library://audiobook/${_currentAudiobook!.itemId}';
// The audiobook chapter URIs should contain the audiobook URI pattern
if (!currentUri.contains(_currentAudiobook!.itemId) &&
!currentUri.contains(audiobookUri)) {
_logger.log('📚 Track changed to non-audiobook, clearing context');
_currentAudiobook = null;
}
}
} }
// Update notification for ALL players // Update notification for ALL players
@@ -2135,20 +2270,33 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
notifyListeners();
final results = await Future.wait([ // Load from database cache first (instant)
_api!.getArtists(limit: LibraryConstants.maxLibraryItems), final syncService = SyncService.instance;
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems), if (syncService.hasCache) {
_api!.getTracks(limit: LibraryConstants.maxLibraryItems), _albums = syncService.cachedAlbums;
]); _artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
notifyListeners();
}
_artists = results[0] as List<Artist>; // Fetch tracks from API (not cached - too many items)
_albums = results[1] as List<Album>; if (_api != null) {
_tracks = results[2] as List<Track>; try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
}
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
// Trigger background sync (non-blocking)
if (_api != null) {
_syncLibraryInBackground();
}
} catch (e) { } catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library'); final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage; _error = errorInfo.userMessage;
@@ -2157,6 +2305,27 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
/// Sync library data in background without blocking UI
Future<void> _syncLibraryInBackground() async {
if (_api == null) return;
final syncService = SyncService.instance;
// Listen for sync completion to update our lists
void onSyncComplete() {
if (syncService.status == SyncStatus.completed) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
notifyListeners();
}
syncService.removeListener(onSyncComplete);
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!);
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async { Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (!isConnected) return; if (!isConnected) return;
@@ -2495,6 +2664,114 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
/// Seek relative to current position (e.g., +30 or -30 seconds)
Future<void> seekRelative(String playerId, int deltaSeconds) async {
try {
final currentPosition = _positionTracker.currentPosition.inSeconds;
final totalDuration = _currentTrack?.duration?.inSeconds ?? 0;
final newPosition = (currentPosition + deltaSeconds).clamp(0, totalDuration);
await seek(playerId, newPosition);
} catch (e) {
ErrorHandler.logError('Seek relative', e);
rethrow;
}
}
// ============================================================================
// AUDIOBOOK CONTEXT
// ============================================================================
/// Set the currently playing audiobook context (with chapters)
void setCurrentAudiobook(Audiobook audiobook) {
_currentAudiobook = audiobook;
_logger.log('📚 Set current audiobook: ${audiobook.name}, chapters: ${audiobook.chapters?.length ?? 0}');
notifyListeners();
}
/// Clear the audiobook context
void clearCurrentAudiobook() {
if (_currentAudiobook != null) {
_logger.log('📚 Cleared audiobook context');
_currentAudiobook = null;
notifyListeners();
}
}
/// Get the current chapter based on playback position
Chapter? getCurrentChapter() {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return null;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return null;
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
// Find the chapter that contains the current position
for (int i = chapters.length - 1; i >= 0; i--) {
if (currentPositionMs >= chapters[i].positionMs) {
return chapters[i];
}
}
return chapters.first;
}
/// Get the index of the current chapter
int getCurrentChapterIndex() {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return -1;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return -1;
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
for (int i = chapters.length - 1; i >= 0; i--) {
if (currentPositionMs >= chapters[i].positionMs) {
return i;
}
}
return 0;
}
/// Seek to the next chapter
Future<void> seekToNextChapter(String playerId) async {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return;
final currentIndex = getCurrentChapterIndex();
if (currentIndex < chapters.length - 1) {
final nextChapter = chapters[currentIndex + 1];
final positionSeconds = nextChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to next chapter: ${nextChapter.title}');
}
}
/// Seek to the previous chapter (or start of current chapter if > 3 seconds in)
Future<void> seekToPreviousChapter(String playerId) async {
if (_currentAudiobook == null || _currentAudiobook!.chapters == null) return;
final chapters = _currentAudiobook!.chapters!;
if (chapters.isEmpty) return;
final currentIndex = getCurrentChapterIndex();
if (currentIndex < 0) return;
final currentChapter = chapters[currentIndex];
final currentPositionMs = _positionTracker.currentPosition.inMilliseconds;
final chapterProgressMs = currentPositionMs - currentChapter.positionMs;
// If more than 3 seconds into current chapter, go to start of current chapter
// Otherwise, go to previous chapter
if (chapterProgressMs > 3000 || currentIndex == 0) {
final positionSeconds = currentChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to start of chapter: ${currentChapter.title}');
} else {
final previousChapter = chapters[currentIndex - 1];
final positionSeconds = previousChapter.positionMs ~/ 1000;
await seek(playerId, positionSeconds);
_logger.log('📚 Jumped to previous chapter: ${previousChapter.title}');
}
}
Future<void> toggleShuffle(String queueId) async { Future<void> toggleShuffle(String queueId) async {
try { try {
await _api?.toggleShuffle(queueId); await _api?.toggleShuffle(queueId);
+9 -2
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
@@ -506,7 +505,15 @@ class PlayerProvider with ChangeNotifier {
return false; 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++; filteredCount++;
return false; return false;
} }
+5 -1
View File
@@ -8,6 +8,7 @@ import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
@@ -308,6 +309,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Queue all tracks via Music Assistant // Queue all tracks via Music Assistant
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0); await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
_logger.log('Album queued on ${player.name}'); _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 // Stay on album page - mini player will appear
} catch (e) { } catch (e) {
_logger.log('Error playing album: $e'); _logger.log('Error playing album: $e');
@@ -697,7 +701,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Text( child: Text(
widget.album.name, widget.album.nameWithYear,
style: textTheme.headlineMedium?.copyWith( style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
+266 -55
View File
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
@@ -40,6 +41,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
String? _artistDescription; String? _artistDescription;
String? _artistImageUrl; String? _artistImageUrl;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override @override
@@ -48,6 +53,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isFavorite = widget.artist.favorite ?? false; _isFavorite = widget.artist.favorite ?? false;
// Use initial image URL immediately for smooth hero animation // Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl; _artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
_loadArtistAlbums(); _loadArtistAlbums();
_loadArtistDescription(); _loadArtistDescription();
_refreshFavoriteStatus(); _refreshFavoriteStatus();
@@ -64,6 +70,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 { Future<void> _refreshFavoriteStatus() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return; if (maProvider.api == null) return;
@@ -364,9 +430,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name); final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
if (mounted) { if (mounted) {
// Separate library albums from provider-only albums
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
setState(() { setState(() {
_albums = allAlbums; _albums = libraryAlbums;
_providerAlbums = []; // All albums are merged in the cache method _providerAlbums = providerOnlyAlbums;
_sortAlbums(); // Apply saved sort order
_isLoading = false; _isLoading = false;
}); });
} }
@@ -607,72 +678,117 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
) )
else ...[ else ...[
// Library Albums Section // Library Albums Section with inline controls
if (_albums.isNotEmpty) ...[ if (_albums.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Text( child: Row(
'In Library', children: [
style: textTheme.titleLarge?.copyWith( Text(
color: colorScheme.onBackground, 'In Library',
fontWeight: FontWeight.bold, style: textTheme.titleLarge?.copyWith(
), color: colorScheme.onBackground,
), fontWeight: FontWeight.bold,
), ),
), ),
SliverPadding( const Spacer(),
padding: const EdgeInsets.symmetric(horizontal: 12), // Sort toggle
sliver: SliverGrid( IconButton(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( icon: Icon(
crossAxisCount: 2, _sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
childAspectRatio: 0.75, color: colorScheme.primary,
crossAxisSpacing: 12, size: 20,
mainAxisSpacing: 12, ),
), tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
delegate: SliverChildBuilderDelegate( onPressed: _toggleSortOrder,
(context, index) { visualDensity: VisualDensity.compact,
final album = _albums[index]; padding: EdgeInsets.zero,
return _buildAlbumCard(album); constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
}, ),
childCount: _albums.length, // 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'
? '3-column grid'
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
), ),
), ),
), ),
_buildAlbumSliver(_albums),
], ],
// Provider Albums Section // Provider Albums Section
if (_providerAlbums.isNotEmpty) ...[ if (_providerAlbums.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0), padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
child: Text( child: Row(
'From Providers', children: [
style: textTheme.titleLarge?.copyWith( Text(
color: colorScheme.onBackground, 'From Providers',
fontWeight: FontWeight.bold, style: textTheme.titleLarge?.copyWith(
), color: colorScheme.onBackground,
), fontWeight: FontWeight.bold,
), ),
), ),
SliverPadding( // Only show controls here if no library albums
padding: const EdgeInsets.symmetric(horizontal: 12), if (_albums.isEmpty) ...[
sliver: SliverGrid( const Spacer(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( IconButton(
crossAxisCount: 2, icon: Icon(
childAspectRatio: 0.75, _sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
crossAxisSpacing: 12, color: colorScheme.primary,
mainAxisSpacing: 12, size: 20,
), ),
delegate: SliverChildBuilderDelegate( tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
(context, index) { onPressed: _toggleSortOrder,
final album = _providerAlbums[index]; visualDensity: VisualDensity.compact,
return _buildAlbumCard(album); padding: EdgeInsets.zero,
}, constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
childCount: _providerAlbums.length, ),
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? '3-column grid'
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
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 const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
], ],
], ],
@@ -707,7 +823,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( AspectRatio(
aspectRatio: 1.0, // Square album art
child: Hero( child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix', tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect( child: ClipRRect(
@@ -745,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Text( child: Text(
album.name, album.nameWithYear,
style: textTheme.titleSmall?.copyWith( style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -773,4 +890,98 @@ 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
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => 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',
),
),
);
},
);
}
} }
+534
View File
@@ -0,0 +1,534 @@
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 'audiobook_detail_screen.dart';
class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
final List<Audiobook> audiobooks;
final String? heroTagSuffix;
const AudiobookAuthorScreen({
super.key,
required this.authorName,
required this.audiobooks,
this.heroTagSuffix,
});
@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);
_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,
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(
'${_audiobooks.length} ${_audiobooks.length == 1 ? 'audiobook' : 'audiobooks'}',
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(
'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' ? 'Sort by year' : 'Sort alphabetically',
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'
? '3-column grid'
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
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),
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,
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 != 'Unknown Narrator')
Text(
'Narrated by ${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,
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 != 'Unknown Narrator'
? 'Narrated by ${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),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
),
),
);
}
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
+675
View File
@@ -0,0 +1,675 @@
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' 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';
class AudiobookSeriesScreen extends StatefulWidget {
final AudiobookSeries series;
final String? heroTag;
const AudiobookSeriesScreen({
super.key,
required this.series,
this.heroTag,
});
@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'
@override
void initState() {
super.initState();
_loadViewPreferences();
// Defer loading until after first frame to ensure UI renders first
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_loadSeriesBooks();
}
});
}
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 = 'Not connected to Music Assistant';
_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) {
setState(() {
_audiobooks = books;
_sortAudiobooks(); // Sort inside setState so UI updates
_isLoading = false;
});
// Debug: Log sorted order
_logger.log('📚 Sorted order ($_sortOrder):');
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
}
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
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
? 'Loading...'
: '${_audiobooks.length} ${_audiobooks.length == 1 ? 'book' : 'books'}',
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(
'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' ? 'Sort alphabetically' : 'Sort by series order',
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'
? '3-column grid'
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
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: const Text('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(
'No books found in this series',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
),
),
)
else
_buildAudiobookSliver(maProvider),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
);
}
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
// If still loading or no books, show placeholder
if (_isLoading || _audiobooks.isEmpty) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 64,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
);
}
// Get cover URLs from loaded books
final covers = <String>[];
for (final book in _audiobooks.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
if (imageUrl != null) {
covers.add(imageUrl);
}
}
if (covers.isEmpty) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 64,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
);
}
// Always use 3x3 grid for series detail
const int gridSize = 3;
// Pad covers to fill grid, using empty strings for missing slots
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: Container(
margin: EdgeInsets.only(
right: col < gridSize - 1 ? 1 : 0,
bottom: row < gridSize - 1 ? 1 : 0,
),
child: coverUrl != null
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.book,
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
size: 20,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
),
),
);
}),
),
);
}),
);
}
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),
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,
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 != 'Unknown Author')
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,
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 != 'Unknown Author'
? 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),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
),
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
}
+106 -2
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart'; import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart'; import '../widgets/debug/debug_console.dart';
@@ -30,6 +32,8 @@ class _LoginScreenState extends State<LoginScreen> {
String? _error; String? _error;
bool _showDebug = false; bool _showDebug = false;
List<String> _debugLogs = []; 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 @override
void initState() { void initState() {
@@ -94,10 +98,27 @@ class _LoginScreenState extends State<LoginScreen> {
return 'http://$url'; 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:// // For domain names, default to https://
return 'https://$url'; 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() { String _buildServerUrl() {
// Normalize the base URL // Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim()); var url = _normalizeServerUrl(_serverUrlController.text.trim());
@@ -147,6 +168,8 @@ class _LoginScreenState extends State<LoginScreen> {
_error = null; _error = null;
_detectedAuthStrategy = null; _detectedAuthStrategy = null;
_detectedAuthType = null; _detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear(); _debugLogs.clear();
}); });
@@ -160,6 +183,13 @@ class _LoginScreenState extends State<LoginScreen> {
// Auto-detect authentication requirements // Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl); 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'}'); _addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) { if (strategy == null) {
@@ -173,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() { setState(() {
_detectedAuthStrategy = strategy; _detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name); _detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false; _isDetectingAuth = false;
}); });
@@ -250,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { 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(); final port = _portController.text.trim();
// Validate port (if provided) // Validate port (if provided)
@@ -269,7 +302,13 @@ class _LoginScreenState extends State<LoginScreen> {
// Save owner name (if provided) and port to settings // Save owner name (if provided) and port to settings
// For MA auth, the owner name will be set from user profile after login // For MA auth, the owner name will be set from user profile after login
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) { 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); await SettingsService.setWebSocketPort(portNum);
@@ -627,6 +666,71 @@ 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)
? 'Tailscale VPN Connection'
: 'Unencrypted Connection',
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)
? 'Using HTTP over Tailscale (encrypted tunnel)'
: _usedHttpFallback
? 'HTTPS failed, using HTTP fallback'
: 'HTTP connection - data is not encrypted',
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer).withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Authentication fields (shown only if auth required) // Authentication fields (shown only if auth required)
if (needsAuth) ...[ if (needsAuth) ...[
// Auth Server URL (only for Authelia) // Auth Server URL (only for Authelia)
+112 -8
View File
@@ -4,11 +4,14 @@ import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_selector.dart'; import '../widgets/player_selector.dart';
import '../widgets/album_row.dart'; import '../widgets/album_row.dart';
import '../widgets/artist_row.dart'; import '../widgets/artist_row.dart';
import '../widgets/track_row.dart'; import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -31,6 +34,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Random order for favorites (generated once per session) // Random order for favorites (generated once per session)
late List<int> _favoritesOrder; late List<int> _favoritesOrder;
@@ -67,6 +74,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
@@ -75,6 +85,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
}); });
} }
} }
@@ -84,6 +97,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
provider.invalidateHomeCache(); provider.invalidateHomeCache();
// Force full library sync from MA API
await provider.forceLibrarySync();
// Reload settings in case they changed // Reload settings in case they changed
await _loadSettings(); await _loadSettings();
@@ -127,6 +143,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0, titleSpacing: 0,
centerTitle: false, centerTitle: false,
actions: [ 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(), const PlayerSelector(),
], ],
), ),
@@ -135,19 +173,58 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
selector: (_, p) => p.isConnected, selector: (_, p) => p.isConnected,
builder: (context, isConnected, child) { builder: (context, isConnected, child) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
return !isConnected final syncService = SyncService.instance;
? DisconnectedState.full(
onSettings: () => Navigator.push( // Show cached data even when not connected (if we have cache)
context, // Only show disconnected state if we have no cached data at all
MaterialPageRoute(builder: (context) => const SettingsScreen()), if (!isConnected && !syncService.hasCache) {
return DisconnectedState.full(
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
);
}
return Column(
children: [
// Connecting banner when showing cached data
if (!isConnected && syncService.hasCache)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.primaryContainer.withOpacity(0.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'Connecting...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
],
), ),
) ),
: RefreshIndicator( Expanded(
child: RefreshIndicator(
onRefresh: _onRefresh, onRefresh: _onRefresh,
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
child: _buildConnectedView(context, maProvider), child: _buildConnectedView(context, maProvider),
); ),
),
],
);
}, },
), ),
), ),
@@ -220,6 +297,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight, rowHeight: rowHeight,
), ),
// Continue Listening Audiobooks (optional)
if (_showContinueListeningAudiobooks)
AudiobookRow(
key: const ValueKey('continue-listening-audiobooks'),
title: 'Continue Listening',
loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight,
),
// Discover Audiobooks (optional)
if (_showDiscoverAudiobooks)
AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: 'Discover Audiobooks',
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight,
),
// Discover Series (optional)
if (_showDiscoverSeries)
SeriesRow(
key: const ValueKey('discover-series'),
title: 'Discover Series',
loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight,
),
// Favorites rows in random order // Favorites rows in random order
...favoritesWidgets, ...favoritesWidgets,
], ],
File diff suppressed because it is too large Load Diff
+4
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
final _playlistLogger = DebugLogger(); final _playlistLogger = DebugLogger();
@@ -70,6 +71,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0); await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
_playlistLogger.info('Playlist queued successfully', context: 'Playlist'); _playlistLogger.info('Playlist queued successfully', context: 'Playlist');
// Record to local recently played (per-profile)
RecentlyPlayedService.instance.recordPlaylistPlayed(widget.playlist);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+1 -1
View File
@@ -472,7 +472,7 @@ class SearchScreenState extends State<SearchScreen> {
: null, : null,
), ),
title: Text( title: Text(
album.name, album.nameWithYear,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
+174
View File
@@ -26,6 +26,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
@override @override
void initState() { void initState() {
@@ -51,6 +58,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); 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 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);
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
@@ -59,6 +82,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
}); });
} }
} }
@@ -493,6 +521,82 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 16),
Text(
'Audiobooks',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 8),
// Audiobook home 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(
title: Text(
'Continue Listening',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show audiobooks in progress',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showContinueListeningAudiobooks,
onChanged: (value) {
setState(() => _showContinueListeningAudiobooks = value);
SettingsService.setShowContinueListeningAudiobooks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Audiobooks',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random audiobooks to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverAudiobooks,
onChanged: (value) {
setState(() => _showDiscoverAudiobooks = value);
SettingsService.setShowDiscoverAudiobooks(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
SwitchListTile(
title: Text(
'Discover Series',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Show random audiobook series to discover',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _showDiscoverSeries,
onChanged: (value) {
setState(() => _showDiscoverSeries = value);
SettingsService.setShowDiscoverSeries(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
],
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
@@ -585,6 +689,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[
Text(
'Audiobook Libraries',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Choose which Audiobookshelf libraries to include',
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'] ?? 'Unknown Library';
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(
const SnackBar(
content: Text('Pull to refresh the library to apply changes'),
duration: 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( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 50,
+46 -4
View File
@@ -31,11 +31,42 @@ class AuthManager {
/// Auto-detect authentication requirements for a server /// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown /// 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 { Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl'); _logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL // 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://')) { if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains // Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') || if (baseUrl.startsWith('192.') ||
@@ -44,13 +75,22 @@ class AuthManager {
baseUrl == 'localhost' || baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) { baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl'; baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else { } else {
baseUrl = 'https://$baseUrl'; baseUrl = 'https://$baseUrl';
} }
} }
return baseUrl;
}
/// 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 // 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); final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) { if (maAuthResult != null) {
if (maAuthResult == 'required') { if (maAuthResult == 'required') {
@@ -124,7 +164,7 @@ class AuthManager {
} }
} catch (e) { } catch (e) {
_logger.log('✗ Auth detection error: $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'); _logger.log('⚠️ Could not determine auth method');
@@ -136,10 +176,12 @@ class AuthManager {
Future<String?> _checkMusicAssistantAuth(String baseUrl) async { Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try { try {
final uri = Uri.parse(baseUrl); 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( final apiUrl = Uri(
scheme: uri.scheme, scheme: uri.scheme,
host: uri.host, host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095), port: uri.hasPort ? uri.port : null,
path: '/api', path: '/api',
); );
+159
View File
@@ -0,0 +1,159 @@
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();
}
+145
View File
@@ -254,6 +254,9 @@ class MetadataService {
// Cache for album images // Cache for album images
static final Map<String, String?> _albumImageCache = {}; 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) /// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise /// Returns the image URL if found, null otherwise
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async { static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
@@ -323,10 +326,152 @@ class MetadataService {
return null; 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 /// Clears the metadata cache
static void clearCache() { static void clearCache() {
_cache.clear(); _cache.clear();
_artistImageCache.clear(); _artistImageCache.clear();
_albumImageCache.clear(); _albumImageCache.clear();
_authorImageCache.clear();
} }
} }
+538
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 /// Get recently played albums
/// Gets recently played tracks, then fetches full track details to extract album info /// Gets recently played tracks, then fetches full track details to extract album info
/// Optimized: batches track lookups to avoid N+1 query problem /// Optimized: batches track lookups to avoid N+1 query problem
+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;
}
}
+243
View File
@@ -0,0 +1,243 @@
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 {
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
name: album.name,
artistName: album.artistsString,
metadata: {
'provider': album.provider,
'uri': album.uri,
},
);
_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 (_) {}
}
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,
));
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');
}
}
+307
View File
@@ -24,10 +24,39 @@ class SettingsService {
static const String _keyShowRecentAlbums = 'show_recent_albums'; static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists'; static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums'; 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 _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists'; static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks'; static const String _keyShowFavoriteTracks = 'show_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}
static Future<String?> getServerUrl() async { static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyServerUrl); return prefs.getString(_keyServerUrl);
@@ -351,6 +380,37 @@ class SettingsService {
await prefs.setBool(_keyShowDiscoverAlbums, show); 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) // Home Screen Favorites Settings (default off)
static Future<bool> getShowFavoriteAlbums() async { static Future<bool> getShowFavoriteAlbums() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -381,4 +441,251 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteTracks, show); await prefs.setBool(_keyShowFavoriteTracks, show);
} }
// 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);
}
} }
+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;
}
+2 -2
View File
@@ -147,13 +147,13 @@ class _AlbumCardState extends State<AlbumCard> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Album title // Album title with year
Hero( Hero(
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix, tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Text( child: Text(
widget.album.name, widget.album.nameWithYear,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith( style: textTheme.titleSmall?.copyWith(
+285
View File
@@ -0,0 +1,285 @@
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,
),
),
);
},
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,
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,
);
}
}
+181 -29
View File
@@ -286,8 +286,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
String _formatDuration(int seconds) { String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds); final duration = Duration(seconds: seconds);
final minutes = duration.inMinutes; final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final secs = duration.inSeconds % 60; final secs = duration.inSeconds % 60;
// For audiobooks and long content (>= 1 hour), show hours
if (hours > 0) {
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(1, '0')}:${secs.toString().padLeft(2, '0')}'; return '${minutes.toString().padLeft(1, '0')}:${secs.toString().padLeft(2, '0')}';
} }
@@ -1303,7 +1309,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
// Artist name - with slide animation when collapsed // Artist/Author name - with slide animation when collapsed
// For audiobooks: show author from audiobook context
// Hidden during transition to prevent flash // Hidden during transition to prevent flash
// FALLBACK: Show if transition active but no peek content available // FALLBACK: Show if transition active but no peek content available
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer // GPU PERF: Use conditional instead of Opacity to avoid saveLayer
@@ -1314,7 +1321,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
currentTrack.artistsString, maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? 'Unknown Author')
: currentTrack.artistsString,
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6), color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
fontSize: artistFontSize, fontSize: artistFontSize,
@@ -1327,24 +1336,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
// Album name (expanded only) // Album name OR Chapter name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget // GPU PERF: Use color alpha instead of Opacity widget
if (currentTrack.album != null && t > 0.3) // For audiobooks: show current chapter; for music: show album
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook))
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t), top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
child: Text( child: maProvider.isPlayingAudiobook
currentTrack.album!.name, ? _buildChapterInfo(maProvider, textColor, t)
style: TextStyle( : Text(
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)), currentTrack.album!.name,
fontSize: 15, style: TextStyle(
fontWeight: FontWeight.w300, color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
), fontSize: 15,
textAlign: TextAlign.center, fontWeight: FontWeight.w300,
maxLines: 1, ),
overflow: TextOverflow.ellipsis, textAlign: TextAlign.center,
), maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
// Audio source/format info (expanded only, when Sendspin active) // Audio source/format info (expanded only, when Sendspin active)
@@ -1480,7 +1492,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
left: t > 0.5 ? 0 : null, left: t > 0.5 ? 0 : null,
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset, right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop, top: controlsTop,
child: Row( child: maProvider.isPlayingAudiobook
? _buildAudiobookControls(
maProvider: maProvider,
selectedPlayer: selectedPlayer,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
skipButtonSize: skipButtonSize,
playButtonSize: playButtonSize,
playButtonContainerSize: playButtonContainerSize,
t: t,
expandedElementsOpacity: expandedElementsOpacity,
)
: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [ children: [
@@ -1636,7 +1661,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
), ),
// Queue panel (slides in from right) // Queue/Chapters panel (slides in from right)
// For audiobooks: show chapters panel
// For music: show queue panel
if (t > 0.5 && queueT > 0) if (t > 0.5 && queueT > 0)
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
@@ -1645,17 +1672,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
begin: const Offset(1, 0), begin: const Offset(1, 0),
end: Offset.zero, end: Offset.zero,
).animate(_queuePanelAnimation), ).animate(_queuePanelAnimation),
child: QueuePanel( child: maProvider.isPlayingAudiobook
maProvider: maProvider, ? ChaptersPanel(
queue: _queue, maProvider: maProvider,
isLoading: _isLoadingQueue, audiobook: maProvider.currentAudiobook,
textColor: textColor, textColor: textColor,
primaryColor: primaryColor, primaryColor: primaryColor,
backgroundColor: expandedBg, backgroundColor: expandedBg,
topPadding: topPadding, topPadding: topPadding,
onClose: _toggleQueuePanel, onClose: _toggleQueuePanel,
onRefresh: _loadQueue, )
), : QueuePanel(
maProvider: maProvider,
queue: _queue,
isLoading: _isLoadingQueue,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: expandedBg,
topPadding: topPadding,
onClose: _toggleQueuePanel,
onRefresh: _loadQueue,
),
), ),
), ),
), ),
@@ -1881,6 +1918,121 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
); );
} }
/// Build chapter info display for audiobooks
Widget _buildChapterInfo(MusicAssistantProvider maProvider, Color textColor, double t) {
final chapter = maProvider.getCurrentChapter();
final chapterIndex = maProvider.getCurrentChapterIndex();
final totalChapters = maProvider.currentAudiobook?.chapters?.length ?? 0;
if (chapter == null) {
return const SizedBox.shrink();
}
final chapterText = totalChapters > 0
? 'Chapter ${chapterIndex + 1}/$totalChapters: ${chapter.title}'
: chapter.title;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_outline_rounded,
size: 14,
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
),
const SizedBox(width: 6),
Flexible(
child: Text(
chapterText,
style: TextStyle(
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
fontSize: 14,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// Build audiobook-specific playback controls
/// Layout: [Prev Chapter] [-30s] [Play/Pause] [+30s] [Next Chapter]
Widget _buildAudiobookControls({
required MusicAssistantProvider maProvider,
required dynamic selectedPlayer,
required Color textColor,
required Color primaryColor,
required Color backgroundColor,
required double skipButtonSize,
required double playButtonSize,
required double playButtonContainerSize,
required double t,
required double expandedElementsOpacity,
}) {
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Previous Chapter (expanded only, if chapters available)
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton(
icon: Icons.skip_previous_rounded,
color: textColor.withOpacity(expandedElementsOpacity),
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
),
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
// Rewind 30 seconds
_buildControlButton(
icon: Icons.replay_30_rounded,
color: textColor,
size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
useAnimation: t > 0.5,
),
SizedBox(width: _lerpDouble(0, 20, t)),
// Play/Pause
_buildPlayButton(
isPlaying: selectedPlayer.isPlaying,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
size: playButtonSize,
containerSize: playButtonContainerSize,
progress: t,
onPressed: () => maProvider.playPauseSelectedPlayer(),
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
),
SizedBox(width: _lerpDouble(0, 20, t)),
// Forward 30 seconds
_buildControlButton(
icon: Icons.forward_30_rounded,
color: textColor,
size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
useAnimation: t > 0.5,
),
// Next Chapter (expanded only, if chapters available)
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton(
icon: Icons.skip_next_rounded,
color: textColor.withOpacity(expandedElementsOpacity),
onPressed: () => maProvider.seekToNextChapter(selectedPlayer.playerId),
),
],
);
}
Widget _buildControlButton({ Widget _buildControlButton({
required IconData icon, required IconData icon,
required Color color, required Color color,
+177
View File
@@ -0,0 +1,177 @@
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';
/// 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(
'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()
: _buildChaptersList(chapters, currentChapterIndex),
),
],
),
);
}
Widget _buildEmptyState() {
return EmptyState.custom(
icon: Icons.bookmark_outline_rounded,
title: 'No chapters',
subtitle: 'This audiobook has no chapter information',
);
}
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());
}
}
+1
View File
@@ -1,6 +1,7 @@
/// Player widget components extracted from expandable_player.dart /// Player widget components extracted from expandable_player.dart
library player_widgets; library player_widgets;
export 'chapters_panel.dart';
export 'device_selector_bar.dart'; export 'device_selector_bar.dart';
export 'player_controls.dart'; export 'player_controls.dart';
export 'queue_panel.dart'; export 'queue_panel.dart';
+311
View File
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../screens/audiobook_series_screen.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 = {};
@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;
});
}
}
} finally {
_loadingCovers.remove(seriesId);
}
}
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],
colorScheme: colorScheme,
textTheme: textTheme,
),
);
},
),
);
},
),
),
],
),
),
);
_logger.endBuild('SeriesRow:${widget.title}');
return result;
}
}
class _SeriesCard extends StatelessWidget {
final AudiobookSeries series;
final List<String>? covers;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _SeriesCard({
required this.series,
required this.covers,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(series: series),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
height: double.infinity,
color: colorScheme.surfaceVariant,
child: _buildCoverGrid(),
),
),
),
const SizedBox(height: 8),
Text(
series.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (series.bookCount != null)
Text(
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildCoverGrid() {
if (covers == null || covers!.isEmpty) {
// Static placeholder - no animation
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;
}
final displayCovers = covers!.take(gridSize * gridSize).toList();
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: gridSize,
childAspectRatio: 1,
crossAxisSpacing: 1,
mainAxisSpacing: 1,
),
itemCount: displayCovers.length,
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: displayCovers[index],
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
);
},
);
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+8 -2
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.0.0-beta+15 version: 2.1.2-beta+18
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -25,12 +25,16 @@ dependencies:
# Storage # Storage
shared_preferences: ^2.2.2 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 # Utilities
uuid: ^4.2.1 uuid: ^4.2.1
package_info_plus: ^8.0.0 package_info_plus: ^8.0.0
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
share_plus: ^9.0.0 share_plus: ^10.1.4
# Audio - Local Playback # Audio - Local Playback
just_audio: ^0.9.36 just_audio: ^0.9.36
@@ -46,6 +50,8 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.1 flutter_lints: ^3.0.1
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
build_runner: ^2.4.13
drift_dev: ^2.22.1
flutter: flutter:
uses-material-design: true uses-material-design: true