Compare commits

..
Author SHA1 Message Date
Your Name 8f64a75206 Bump version to 2.8.0-beta+37 2026-01-04 13:53:48 +00:00
Your NameandClaude Opus 4.5 cbb67eacce Fix artist grid view hero animation
Add missing heroTag to ArtistAvatar in grid view - list view had it but grid view was missing it entirely, causing fade instead of hero animation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:41 +00:00
Your NameandClaude Opus 4.5 74f1b0f16f Persist podcast cover cache for smooth heroes + high-res
- Add SharedPreferences persistence for iTunes podcast cover URLs
- Load cached URLs on app start (before UI renders) for instant high-res
- Save new iTunes URLs as they're fetched for future launches
- Change iTunes artwork from 1400x1400 to 800x800 (still crisp, smaller payload)
- Remove useStableUrl workaround (no longer needed with persistence)

This ensures:
- Smooth hero animations (same URL in library and detail)
- High-res podcast covers (iTunes 800x800)
- Instant display on repeat launches (URLs loaded from storage)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:15:24 +00:00
Your NameandClaude Opus 4.5 9c293a6d64 Fix podcast hero: use stable URLs to prevent mid-session URL changes
ROOT CAUSE IDENTIFIED:
Albums/artists/audiobooks use getImageUrl() which ALWAYS returns the same URL.
Podcasts use getPodcastImageUrl() which can return DIFFERENT URLs:
- Before iTunes lookup: imageproxy URL
- After iTunes lookup: iTunes 1400px URL

This causes hero animation failures because:
1. Library displays imageproxy URL image
2. iTunes lookup completes asynchronously
3. User taps - getPodcastImageUrl now returns iTunes URL
4. Detail screen gets different URL than library is showing
5. Cache miss during hero animation = choppy

FIX:
- Added useStableUrl parameter to getPodcastImageUrl()
- When true, bypasses iTunes cache and always returns imageproxy URL
- Library now uses useStableUrl: true for all podcast images
- Precaching also uses useStableUrl: true
- This ensures SAME URL in library, precache, and detail screen

Also removed memCacheWidth from library (decode at native resolution).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:41:24 +00:00
Your NameandClaude Opus 4.5 4cba2ba889 Fix podcast hero: ensure all views have identical widget structure
Critical fix: List view had different structure from grid/detail:
- List: ClipRRect(8) → CachedNetworkImage (no Container wrapper)
- Grid: ClipRRect(16) → Container → CachedNetworkImage
- Detail: ClipRRect(16) → Container → CachedNetworkImage

Hero animations require IDENTICAL widget structures to morph smoothly.

Now all three match:
- borderRadius: 16 (was 8 in list view)
- Container wrapper with surfaceContainerHighest color
- CachedNetworkImage with fadeIn/fadeOut Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:37:40 +00:00
Your NameandClaude Opus 4.5 3b4d1add01 Fix podcast hero animations - match audiobook pattern exactly
ROOT CAUSE FOUND:
1. URL mismatch: getPodcastImageUrl() returns different URLs at different
   times (imageproxy vs iTunes) causing cache misses during hero animation
2. Widget structure mismatch: Library used ClipRRect but detail used
   BoxDecoration, causing hero to morph between incompatible widgets

FIXES APPLIED:

1. Use initialImageUrl FIRST (not as fallback):
   - Library passes exact URL it uses for display
   - Detail screen now uses that same URL for hero animation
   - Prevents iTunes cache from changing URL mid-flight

2. Match audiobook widget structure exactly:
   - Changed from: Container + BoxDecoration + DecorationImage
   - Changed to: ClipRRect + Container + CachedNetworkImage
   - Same structure as library = smooth hero morphing

3. Added proper CachedNetworkImage properties:
   - fadeInDuration: Duration.zero
   - fadeOutDuration: Duration.zero
   - placeholder and errorWidget handlers

Widget structure comparison (now matching):
  Library:  Hero → ClipRRect(16) → Container → CachedNetworkImage
  Detail:   Hero → ClipRRect(16) → Container → CachedNetworkImage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:24:20 +00:00
Your NameandClaude Opus 4.5 5a0b3e4f61 Fix podcast hero animations and further improve search ranking
Hero Animation Fixes (matching album pattern exactly):
- Increase podcast detail cover size from 0.6/280px to 0.7/320px (matches albums)
- Use DecorationImage with CachedNetworkImageProvider instead of CachedNetworkImage
  widget (matches album detail screen exactly)
- Remove memCacheWidth/Height constraints for full resolution display
- Add box shadow to match album detail screen aesthetic
- Update border radius to 16px in both library grid and detail screen

Search Ranking Improvements:
- Calculate "prominence" of query in podcast name (query length / name length)
- Higher boost (+15) when query is >50% of podcast name
- Medium boost (+12) when query is 30-50% of name
- Base boost (+8) for other matches

Example: "Louis Theroux Interviews" with query "Louis Theroux"
- Query is 14/23 = 60% of name -> +15 boost
- Combined with word boundary match (60) = 75 points total
- Should rank above tracks with artist match (55 points max)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:05:23 +00:00
Your NameandClaude Opus 4.5 081afb3948 Fix podcast hero animations and search relevance
Hero Animation Fixes:
- Change getPodcastImageUrl default size from 1024px to 256px to match
  albums/artists and ensure cache consistency for smooth hero transitions
- Add podcast image pre-caching in library screen (matches album pattern)

Search Relevance Fixes:
- Fix _matchesWordBoundary() to handle multi-word queries like "Louis Theroux"
- Add podcast metadata checks for owner/creator fields (not just author/publisher)
- Increase podcast description bonus from +3 to +5 (matches other media types)
- Add fallback boost (+10) for podcasts when multi-word query matches in name
  but no creator metadata exists (podcast names often include host name)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:43:48 +00:00
Your NameandClaude Opus 4.5 3a668348a2 Improve hero animations and search relevance scoring
Hero Animation Fixes:
- Align memCacheWidth/Height to 256 across library, search, and detail screens
- Ensures same cached image is used for hero source and destination

Search Relevance Improvements:
- Add author/narrator scoring for Audiobooks (+15 exact, +8 contains)
- Add author/publisher/description scoring for Podcasts
- Podcasts matching creator name now rank higher (fixes Louis Theroux issue)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:20:45 +00:00
Your NameandClaude Opus 4.5 5e0277ad9f Fix type pills and hero animation improvements
- Add podcastSingular localization key
- Use singular labels in type pills (Podcast not Podcasts)
- Fix pill stretching for artist/radio/podcast with mainAxisSize.min
- Increase pill size (8x3 padding, 11px font)
- Align memCacheWidth/Height to 400 across library and detail screens
  for smoother hero animations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:02:49 +00:00
Your NameandClaude Opus 4.5 90805c522a Fix: use 'podcasts' localization key instead of 'podcast'
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:46:40 +00:00
Your NameandClaude Opus 4.5 1b2fedb544 Add podcasts to search results with colored type pills
- Add searchPodcasts API method and include podcasts in search results
- Add podcast tile with navigation to podcast detail screen
- Add colored type pills to distinguish media types in "All" view:
  - Track (blue), Album (purple), Artist (pink), Playlist (teal)
  - Audiobook (orange), Radio (red), Podcast (green)
- Fix choppy hero animations for podcast covers by adding memCacheWidth/memCacheHeight constraints

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:35:40 +00:00
Your NameandClaude Opus 4.5 7deb5b7fda Podcast improvements with iTunes high-res artwork
Episode list redesign:
- 72x72 episode covers (each can have unique artwork)
- Duration + release date on same line
- Long-press to expand: full description + action buttons
- Action buttons match album track style (44x44 icon-only)

Podcast cover quality fix:
- Fetch high-res artwork from iTunes API (1400x1400)
- Works for any podcast listed in iTunes (most podcasts)
- Correct podcast artwork, not episode covers
- Cached in background after podcast list loads

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:16:06 +00:00
Your Name 7cdf303116 Fix Track import - it's in media_item.dart not separate file 2026-01-04 09:44:46 +00:00
Your NameandClaude Opus 4.5 c898ca51a9 Smart podcast cover selection based on image source comparison
Compare podcast and episode image URLs to determine if they're the
same source image. If same source, use episode version for higher
quality. If different sources (unique episode covers), keep the
podcast's own cover to avoid incorrect artwork.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:44:03 +00:00
Your NameandClaude Opus 4.5 22b3526497 Only use episode cover as fallback when podcast has no cover
Previously we always overwrote podcast covers with episode covers,
which was wrong for podcasts where each episode has a unique cover.

Now we check if the podcast has its own image first and only fall
back to episode covers when the podcast itself has no cover image.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:41:58 +00:00
Your NameandClaude Opus 4.5 eea675ba53 Redesign podcast episode list with covers, dates, and expandable actions
- Replace play icon with actual episode covers (72x72, rounded)
- Each episode can have its own unique cover art
- Add release date display alongside episode duration
- Long-press to expand episode showing full description
- Expanded state reveals action buttons: Play, Add to Queue, Play On...
- Add debug logging for episode metadata to discover date field names

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:36:07 +00:00
Your NameandClaude Opus 4.5 e74f59e931 Use high-quality episode covers on podcast detail screen
Updated podcast_detail_screen.dart to use getPodcastImageUrl()
for both the cover display and color extraction.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:49:33 +00:00
Your NameandClaude Opus 4.5 8584353d65 Use episode covers for higher quality podcast library images
Music Assistant sometimes returns low-res images for podcasts. Added:
- Podcast cover cache populated with episode covers in background
- getPodcastImageUrl() method that checks cache first
- Library screen now uses getPodcastImageUrl for podcasts

Covers are loaded progressively after initial podcast list loads,
so the UI is not blocked while fetching episode data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:37:59 +00:00
Your NameandClaude Opus 4.5 0495e0c418 Fix podcast name detection for episodes with split titles
When podcast episode titles contain " - " (like "Show 73 - Mania for
Subjugation III"), the title was being split and "Show 73" became the
artist. The caching logic then kept the "existing" track thinking it
had better data, but this caused the album (podcast name) from the
player_updated event to be lost.

Fix: For podcast URIs, if the new track has album info but existing
doesn't, either prefer the new track or merge the album data into
the existing track. Also update _currentTrack when we have better
album data for podcasts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:34:26 +00:00
Your NameandClaude Opus 4.5 87a0b8c04c Add podcast field extraction and detailed logging
- Log all currentMedia fields for podcast episodes
- Extract and store podcast field in metadata
- Add helper method for episode cover fallback
- Helps diagnose podcast name detection issues

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:13:13 +00:00
Your NameandClaude Opus 4.5 ef384fa41f Fix duplicate podcast name and add debug logging
- Hide album line for podcasts (was duplicating podcast name)
- Add debug logging to understand podcast data structure
- Helps diagnose why some podcasts show "Podcasts" fallback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:07:51 +00:00
Your NameandClaude Opus 4.5 b2d39404b6 Fix localization: use podcasts instead of podcast
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:50:15 +00:00
Your NameandClaude Opus 4.5 c655f79b71 Add podcast skip controls and fix artist display in expanded player
- Add isPlayingPodcast getter to detect podcast episodes by URI pattern
- Add currentPodcastName getter to show podcast name instead of artist
- Use audiobook-style skip 30s controls for podcasts in expanded player
- Display podcast name in artist line instead of "Unknown Artist"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:41:11 +00:00
Your NameandClaude Opus 4.5 0bec855d7b Use correct endpoint: music/podcasts/podcast_episodes
Found the actual API endpoint from Music Assistant documentation.
Simplified the code to use the proper endpoint.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:09 +00:00
Your NameandClaude Opus 4.5 752dd9ac26 Try multiple approaches for podcast episodes like audiobooks
- Check metadata for episodes array
- Use provider_mappings to fetch from source provider (Spotify)
- Log total_episodes and metadata keys for debugging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:15:57 +00:00
Your NameandClaude Opus 4.5 aa6d616741 Use podcast's actual URI for browsing episodes
- Use the podcast's uri from API response instead of constructed path
- Filter out folder navigation items (like "..")
- Add _parseBrowseEpisodes helper method
- Include duration parsing for episodes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:13:32 +00:00
Your NameandClaude Opus 4.5 8476798093 Fix MediaType parsing in podcast episode browse
- Parse media_type string to MediaType enum properly
- Default to MediaType.track if parsing fails

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:57:03 +00:00
Your NameandClaude Opus 4.5 a2327e5183 Add detailed logging for podcast episode parsing
- Log browse item structure (keys, name, label, uri, path, media_type)
- Parse browse items with fallback for name/label fields
- Add URI logging to play method for debugging
- Better handling of browse response format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:47:39 +00:00
Your NameandClaude Opus 4.5 0fad37147f Fix podcast episodes and image resolution
Episodes:
- Add multiple fallback methods for fetching podcast episodes
- Try music/podcasts/get with various episode keys
- Try browse endpoint with podcast URI
- Try podcast_episodes library endpoint
- Add comprehensive debug logging

Images:
- Increase podcast image size from 512 to 1024
- Increase memory cache size for better quality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:34:21 +00:00
Your NameandClaude Opus 4.5 3667df5b5c Fix podcast detail screen compilation errors
- Use CachedNetworkImageProvider for color extraction
- Change _formatDuration to accept Duration instead of int
- Replace non-existent podcastOff icon with microphoneOff
- Fix duration comparison to use Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:14:24 +00:00
Your NameandClaude Opus 4.5 4521318686 Add podcast details screen with episodes list
- Create PodcastDetailScreen with podcast cover, title, author
- Load and display episode list with play functionality
- Add hero animations between library and details screens
- Add expandable description section
- Add podcastCover hero tag constant
- Pass imageUrl parameter to navigation for adaptive colors

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:03:32 +00:00
Your NameandClaude Opus 4.5 29d14d9c13 Fix null safety for S.of(context) in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:40:46 +00:00
Your NameandClaude Opus 4.5 250d79bd39 Fix AppLocalizations reference in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:31:41 +00:00
Your NameandClaude Opus 4.5 c1837f5227 Add basic podcast support
- Add getPodcasts, getPodcastEpisodes, and playPodcastEpisode API methods
- Add podcast state (podcasts list, isLoadingPodcasts) to provider
- Replace "Coming soon" placeholder with functional podcasts tab
- Add list and grid view modes for podcasts (2-column and 3-column)
- Add view mode toggle support for podcasts
- Persist podcast view mode preference in settings
- Add localization strings for podcasts (noPodcasts, addPodcastsHint, episodes)
- Tapping a podcast shows placeholder message (details screen coming later)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:21:54 +00:00
Your NameandClaude Opus 4.5 a59946cc69 Fix radio stations layout and add grid view support
- Add proper spacing between radio station items in list view
- Add grid view support (2-column and 3-column layouts)
- Add view mode toggle button functionality for radio tab
- Persist radio view mode preference in settings
- Add pull-to-refresh support
- Optimize image caching based on view mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:15:04 +00:00
Your NameandClaude Opus 4.5 6e05a948f1 Add global radio station search via Music Assistant API
- Add searchRadioStations() method that searches with media_types=['radio']
- Combine library radio filtering with global search results
- Deduplicate results by name, preferring library items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:52:55 +00:00
Your NameandClaude Opus 4.5 80a7848204 Filter radio stations from loaded list during search
The Music Assistant search API doesn't return radios, so filter
from the provider's pre-loaded radioStations list instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:31:26 +00:00
Your NameandClaude Opus 4.5 0d09fb3625 Add radio stations to search results
- Parse radios from search API response
- Add radio filter tab to search screen
- Add radio tile with play functionality
- Add playingRadioStation localization

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:14:35 +00:00
Your NameandClaude Opus 4.5 cff1edcc02 Optimize list scroll performance
- Add 200ms delay before fetching fallback images to avoid
  network requests during fast scrolling
- Use dynamic image cache sizes based on view mode:
  - List view: 128px (smaller thumbnails)
  - 3-col grid: 200px
  - 2-col grid: 300px
- Increase ListView cacheExtent from 500px to 1000px to
  pre-build more offscreen items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:53:59 +00:00
Your NameandClaude Opus 4.5 300f92a327 Add fast settling physics for horizontal page swipes
Adds _FastPageScrollPhysics to both library and search screens
to reduce the time PageView animations take to settle. This makes
vertical scrolling within tabs more responsive after swiping
horizontally between pages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:41:55 +00:00
Your NameandClaude Opus 4.5 c600029202 Use PageView.builder to only build visible tabs
Previously all 3-4 tabs were built upfront with PageView(children:).
Now only the current tab is built, with adjacent tabs for smooth swiping.

This should significantly improve tab swipe performance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:01:09 +00:00
Your NameandClaude Opus 4.5 c703eef343 Fix RestorableInt/ValueListenable type mismatch
RestorableInt doesn't implement ValueListenable, so add separate
ValueNotifier for UI updates while keeping RestorableInt for state
restoration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:43:45 +00:00
Your NameandClaude Opus 4.5 48ffe0c826 Debounce color extraction to avoid blocking UI during scroll
- Queue color extraction requests with 300ms debounce
- Process extractions in batch after scroll settles
- Add 50ms yield between extractions for UI responsiveness
- Add mounted checks to prevent work after disposal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:33:34 +00:00
Your NameandClaude Opus 4.5 8986c697c3 perf: throttle letter scrollbar updates to reduce rebuilds
Only update scroll fraction when it changes by more than 1%, reducing
the number of setState calls and widget rebuilds during scrolling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:29:19 +00:00
Your NameandClaude Opus 4.5 948599bf3e perf: pre-sort lists on load instead of every build
- Add cached sorted lists for playlists, tracks, audiobooks, authors, series
- Sort once when data is loaded, not on every widget rebuild
- Pre-group audiobooks by author when loaded
- Re-sort audiobooks when sort order changes (alpha/year)

This eliminates O(n log n) sorting operations during scroll/swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:28:42 +00:00
Your NameandClaude Opus 4.5 11d3dec72d perf: eliminate full screen rebuilds during PageView swipe
- Remove setState from _onPageChanged in both library and search screens
- Use ValueNotifier + ValueListenableBuilder for filter chip updates
- Only filter chips rebuild during swipe, not entire screen
- Remove redundant _selectedCategoryIndex in library screen

This significantly reduces widget rebuilds during swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:23:26 +00:00
Your NameandClaude Opus 4.5 d85407bee2 style: add Spotify-style fade gradient under filter bars
Content now fades out as it scrolls under the filter bar instead of
abruptly meeting it. Uses a 24px gradient from background color to
transparent on both library and search screens.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:55:53 +00:00
Your NameandClaude Opus 4.5 891c8948e0 fix: smooth letter scrollbar thumb motion
Use AnimatedPositioned with 50ms duration to smooth out the thumb
tracking during scroll, eliminating jerky/stuttery motion.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:30:55 +00:00
Your NameandClaude Opus 4.5 b7da11847a style: add visual gap under filter bars when content scrolls
Increased top padding on list/grid views in library and search screens
so there's a visible gap when content scrolls under the tab bars.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:25:47 +00:00
Your NameandClaude Opus 4.5 6eef5ff9dc fix: letter scrollbar thumb tracking and grab area
- Thumb now tracks scroll position instead of staying centered
- Added onDragStateChanged callback to prevent tab bar hiding during drag
- Made grab area wider (32px) for easier interaction
- Adjusted letter popup position for wider grab area

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:15:54 +00:00
Your Name 7405f0728e style: increase gap between library type and filter bars 2026-01-03 17:02:03 +00:00
Your NameandClaude Opus 4.5 317ec426be fix: filter tab vertical alignment and scroll-to-hide behavior
- Fix vertical alignment of text in filter tab row (center aligned)
- Only hide library type bar when scrolling, keep filter tab always visible
- Use Container with Alignment.center for proper text centering

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:01:37 +00:00
Your NameandClaude Opus 4.5 9902a9f0c5 feat: add letter scrollbar for fast list navigation
- Create LetterScrollbar widget that shows a letter popup when dragging
- Apply to library screen lists: Artists, Albums, Playlists, Authors, Audiobooks, Series
- Dragging the scroll thumb shows the current letter in a popup bubble
- Jump to items starting with that letter for quick navigation
- Closes #36

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:53:23 +00:00
Your NameandClaude Opus 4.5 5f6451153a refactor: improve filter bar spacing and add scroll-to-hide
Library screen:
- Reduce filter row height from 44px to 36px
- Add 8px spacing between library type and filter rows
- Remove bottom border line from filter bar
- Add scroll-to-hide behavior (like search screen)

Search screen:
- Reduce filter bar height from 44px to 36px
- Adjust padding to match smaller height

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:47:02 +00:00
Your NameandClaude Opus 4.5 fdefa32bef refactor: improve filter bar layout and consistency
Library screen:
- Justify media type chips across full width (spaceBetween)
- Reduce vertical spacing between rows (fixed 44px height)
- Removed redundant padding

Search screen:
- Filter bar now matches search bar width with rounded corners (12px)
- Content clips and scrolls within the rounded container
- Same 44px height as library screen for consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:29:47 +00:00
Your NameandClaude Opus 4.5 00d1763c99 refactor: improve library filter layout per feedback
- Remove full-width background from media type row
- Show icons only when media type is selected (saves horizontal space)
- Use tertiaryContainer for selected state (complementary color)
- Move action buttons (favorites, view mode) inline with sub-category row
- Revert search screen to inline library toggle in search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:08:40 +00:00
Your NameandClaude Opus 4.5 3e1c2f9da1 refactor: two-row filter layout with floating FABs
- Library screen: replace dropdown with two-row chip layout
  - Row 1: Media type chips (Music, Audiobooks, Podcasts, Radio) with secondaryContainer background and icons
  - Row 2: Sub-category chips (Artists, Albums, Playlists, etc.)
- Move favorites and view mode toggles to floating FABs in bottom-right above mini player
- Search screen: move library-only toggle from search bar to floating FAB
- Remove old dropdown-based media type selector

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 15:46:44 +00:00
Your NameandClaude Opus 4.5 2a583d28ef Fix build errors in library redesign
- Replace _tabController.index with _selectedCategoryIndex
- Fix metadata access to use Map syntax (metadata?['description'])
- Add playRadioStation method to API
- Use maProvider.api?.playRadioStation instead of undefined playMedia

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:57:30 +00:00
Your NameandClaude Opus 4.5 009af477ad Redesign library screen with dropdown + chips layout and add radio support
- Replace media type icon buttons with dropdown selector
- Replace TabBar with horizontal scrollable chips (matching search screen)
- Remove PlayerSelector from library AppBar (redundant with mini player)
- Add Radio as a new library media type
- Add getRadioStations API endpoint
- Add loadRadioStations to provider
- Add radio stations tab with list view
- Add localization strings for radio, stations, selectLibrary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:45:05 +00:00
Your Name 91def41c9f Raise secondary line in 3-line mini player by 1px 2026-01-02 09:45:23 +00:00
Your Name ede34224f4 Fix 3-line mini player: restore 16px font, raise primary line 2026-01-02 09:26:11 +00:00
Your Name 676848779f Center power button vertically 2026-01-02 09:20:21 +00:00
Your NameandClaude Opus 4.5 c7dd415169 Fix power button style, reduce album gap, adjust mini player spacing
- Power button now matches PlayerCard style (IconButton, no background)
- Album details: reduce bottom padding from 24px to 12px
- Mini player: raise secondary line from y=42 to y=40

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 09:06:52 +00:00
Your NameandClaude Opus 4.5 b0baaecb1f Add power button and improve mini player 2-line layout
- Add power toggle button on right side (2-line off state only)
- Increase primary font size from 16px to 18px in 2-line mode
- Evenly space the two text lines vertically (14px and 42px)
- Power button shows powered state with opacity change

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:50:10 +00:00
Your NameandClaude Opus 4.5 30a4a432f1 Fix row height clipping and increase detail screen artwork
- Add 8px total to row height formula for 4th row behind mini player
- Add clipBehavior: Clip.none to album row for descender overflow
- Add 4px bottom padding to album cards for descender room (y, g, j, q)
- Tighten artist card line height to 1.15 for 2-line names
- Increase detail screen artwork from 40-50% to 70% on all screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:48:11 +00:00
24 changed files with 3763 additions and 635 deletions
+3
View File
@@ -25,4 +25,7 @@ class HeroTags {
/// Audiobook author image hero tag prefix
static const String authorImage = 'author_image_';
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
}
+6
View File
@@ -68,6 +68,12 @@
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"music": "Musik",
"radio": "Radio",
"stations": "Sender",
"selectLibrary": "Bibliothek auswählen",
"noRadioStations": "Keine Radiosender",
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
+49
View File
@@ -316,6 +316,36 @@
"description": "Audiobooks category"
},
"music": "Music",
"@music": {
"description": "Music category"
},
"radio": "Radio",
"@radio": {
"description": "Radio category"
},
"stations": "Stations",
"@stations": {
"description": "Radio stations subcategory"
},
"selectLibrary": "Select Library",
"@selectLibrary": {
"description": "Title for library type selection bottom sheet"
},
"noRadioStations": "No radio stations",
"@noRadioStations": {
"description": "Empty state when no radio stations are available"
},
"addRadioStationsHint": "Add radio stations in Music Assistant",
"@addRadioStationsHint": {
"description": "Hint for adding radio stations"
},
"searchFailed": "Search failed. Please check your connection.",
"@searchFailed": {
"description": "Search error message"
@@ -630,6 +660,16 @@
"description": "Radio button label"
},
"playingRadioStation": "Playing {name}",
"@playingRadioStation": {
"description": "Status when playing radio station",
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
@@ -1036,6 +1076,10 @@
"shows": "Shows",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast support coming soon",
"noPodcasts": "No podcasts",
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
"episodes": "Episodes",
"episode": "Episode",
"playlist": "Playlist",
"connectionError": "Connection Error",
"twoColumnGrid": "2-column grid",
@@ -1168,6 +1212,11 @@
"description": "Singular form of track for search type indicator"
},
"podcastSingular": "Podcast",
"@podcastSingular": {
"description": "Singular form of podcast for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
+6
View File
@@ -66,6 +66,12 @@
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"music": "Música",
"radio": "Radio",
"stations": "Emisoras",
"selectLibrary": "Seleccionar Biblioteca",
"noRadioStations": "No hay emisoras de radio",
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
+215 -4
View File
@@ -52,7 +52,11 @@ class MusicAssistantProvider with ChangeNotifier {
List<Album> _albums = [];
List<Track> _tracks = [];
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
List<MediaItem> _radioStations = [];
List<MediaItem> _podcasts = [];
bool _isLoading = false;
bool _isLoadingRadio = false;
bool _isLoadingPodcasts = false;
// Player state
Player? _selectedPlayer;
@@ -95,6 +99,10 @@ class MusicAssistantProvider with ChangeNotifier {
'tracks': [],
};
// Podcast cover cache: podcastId -> best available cover URL
// This is populated with episode covers when podcast covers are low-res
final Map<String, String> _podcastCoverCache = {};
// ============================================================================
// GETTERS
// ============================================================================
@@ -108,7 +116,11 @@ class MusicAssistantProvider with ChangeNotifier {
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
List<MediaItem> get radioStations => _radioStations;
List<MediaItem> get podcasts => _podcasts;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
/// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing;
@@ -207,6 +219,66 @@ class MusicAssistantProvider with ChangeNotifier {
/// Whether we're currently playing an audiobook
bool get isPlayingAudiobook => _currentAudiobook != null;
/// Whether we're currently playing a podcast episode
/// Detected by checking if the current track's URI contains podcast_episode
bool get isPlayingPodcast {
final uri = _currentTrack?.uri;
if (uri == null) return false;
return uri.contains('podcast_episode') || uri.contains('podcast/');
}
/// Get the podcast name when playing a podcast episode
/// Returns the podcast name from metadata, album name, or artist name
String? get currentPodcastName {
if (!isPlayingPodcast || _currentTrack == null) return null;
// Debug logging to understand podcast data structure
_logger.log('🎙️ Getting podcast name for: ${_currentTrack!.name}');
_logger.log('🎙️ Track URI: ${_currentTrack!.uri}');
_logger.log('🎙️ Album: ${_currentTrack!.album?.name}');
_logger.log('🎙️ Artists: ${_currentTrack!.artists?.map((a) => a.name).join(", ")}');
_logger.log('🎙️ Metadata keys: ${_currentTrack!.metadata?.keys.toList()}');
// Try metadata first (if episode has parent podcast info)
final metadata = _currentTrack!.metadata;
if (metadata != null) {
// Log all metadata for debugging
_logger.log('🎙️ Full metadata: $metadata');
// Check for podcast_name or podcast.name in metadata
if (metadata['podcast_name'] != null) {
_logger.log('🎙️ Found podcast_name in metadata');
return metadata['podcast_name'] as String;
}
if (metadata['podcast'] is Map) {
final podcast = metadata['podcast'] as Map;
if (podcast['name'] != null) {
_logger.log('🎙️ Found podcast.name in metadata');
return podcast['name'] as String;
}
}
// Check for 'title' in metadata (some providers put podcast name here)
if (metadata['title'] != null && metadata['title'] != _currentTrack!.name) {
_logger.log('🎙️ Found title in metadata: ${metadata['title']}');
}
}
// Fall back to album name (often contains podcast name)
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
_logger.log('🎙️ Using album name as podcast name');
return _currentTrack!.album!.name;
}
// Fall back to artist (sometimes the podcast name is set as artist)
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
final artistName = _currentTrack!.artists!.first.name;
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
_logger.log('🎙️ Using artist name as podcast name');
return artistName;
}
}
_logger.log('🎙️ No podcast name found, returning null');
return null;
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
@@ -362,6 +434,9 @@ class MusicAssistantProvider with ChangeNotifier {
// Load cached home rows from database for instant discover/recent display
await _cacheService.loadHomeRowsFromDatabase();
// Load podcast cover cache (iTunes URLs) for instant high-res display
await _loadPodcastCoverCache();
// Initialize offline action queue
await OfflineActionQueue.instance.initialize();
@@ -476,6 +551,19 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Load podcast cover cache (iTunes URLs) from storage for instant high-res display
Future<void> _loadPodcastCoverCache() async {
try {
final cache = await SettingsService.getPodcastCoverCache();
if (cache.isNotEmpty) {
_podcastCoverCache.addAll(cache);
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
}
} catch (e) {
_logger.log('⚠️ Error loading podcast cover cache: $e');
}
}
/// Persist current playback state to database (fire-and-forget)
void _persistPlaybackState() {
if (_selectedPlayer == null) return;
@@ -1738,6 +1826,12 @@ class MusicAssistantProvider with ChangeNotifier {
final mediaType = currentMedia['media_type'] as String?;
final uri = currentMedia['uri'] as String?;
// Debug: Log all currentMedia fields for podcast episodes
if (uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'))) {
_logger.log('🎙️ Podcast currentMedia keys: ${currentMedia.keys.toList()}');
_logger.log('🎙️ Podcast currentMedia: $currentMedia');
}
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
bool isExternalSource = false;
if (uri != null) {
@@ -1805,6 +1899,14 @@ class MusicAssistantProvider with ChangeNotifier {
};
}
// Extract podcast info if available (for podcast episodes)
final podcastData = currentMedia['podcast'];
if (podcastData != null) {
metadata ??= {};
metadata['podcast'] = podcastData;
_logger.log('🎙️ Found podcast in currentMedia: $podcastData');
}
// Parse artist from title if artist is missing but title contains " - "
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
var artistName = currentMedia['artist'] as String?;
@@ -1836,9 +1938,17 @@ class MusicAssistantProvider with ChangeNotifier {
!existingTrack.name.contains(' - ');
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
final isPodcastUri = uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'));
// Keep existing if it has proper artist OR has image that new one lacks
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
// BUT for podcasts, if new has album and existing doesn't, we need that album data
final keepExisting = (existingHasProperArtist || (existingHasImage && !newHasImage))
&& !(isPodcastUri && newHasAlbum && !existingHasAlbum);
if (!keepExisting) {
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
@@ -1855,14 +1965,32 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent);
_logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId');
}
} else if (isPodcastUri && newHasAlbum && existingTrack != null && !existingHasAlbum) {
// Merge: keep existing but add album from new track
final mergedTrack = Track(
itemId: existingTrack.itemId,
provider: existingTrack.provider,
name: existingTrack.name,
uri: existingTrack.uri,
duration: existingTrack.duration,
artists: existingTrack.artists,
album: trackFromEvent.album, // Take album from new track
metadata: existingTrack.metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
} else {
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
// Only update _currentTrack here if we don't have it yet (initial load)
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
_currentTrack = trackFromEvent;
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
if (shouldUpdateCurrentTrack) {
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
}
notifyListeners();
@@ -3601,6 +3729,76 @@ class MusicAssistantProvider with ChangeNotifier {
await syncService.syncFromApi(_api!);
}
/// Load radio stations from the library
Future<void> loadRadioStations() async {
if (!isConnected || _api == null) return;
try {
_isLoadingRadio = true;
notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100);
_logger.log('📻 Loaded ${_radioStations.length} radio stations');
_isLoadingRadio = false;
notifyListeners();
} catch (e) {
_logger.log('⚠️ Failed to load radio stations: $e');
_isLoadingRadio = false;
notifyListeners();
}
}
Future<void> loadPodcasts() async {
if (!isConnected || _api == null) return;
try {
_isLoadingPodcasts = true;
notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
_isLoadingPodcasts = false;
notifyListeners();
// Fetch episode covers in background for podcasts with low-res images
_loadPodcastCoversInBackground();
} catch (e) {
_logger.log('⚠️ Failed to load podcasts: $e');
_isLoadingPodcasts = false;
notifyListeners();
}
}
/// Load high-resolution podcast covers from iTunes in background
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
Future<void> _loadPodcastCoversInBackground() async {
if (_api == null) return;
for (final podcast in _podcasts) {
try {
// Skip if already cached (either in memory or loaded from persistence)
if (_podcastCoverCache.containsKey(podcast.itemId)) continue;
// Try iTunes for high-res artwork
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
if (itunesArtwork != null) {
_podcastCoverCache[podcast.itemId] = itunesArtwork;
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
// Persist to storage for instant display on next launch
SettingsService.addPodcastCoverToCache(podcast.itemId, itunesArtwork);
notifyListeners();
}
} catch (e) {
_logger.log('⚠️ Failed to load cover for ${podcast.name}: $e');
}
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (!isConnected) return;
@@ -3687,6 +3885,19 @@ class MusicAssistantProvider with ChangeNotifier {
return _api?.getImageUrl(item, size: size);
}
/// Get best available podcast cover URL
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy
/// The cache is persisted to storage and loaded on app start for instant high-res display
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
// Return cached iTunes URL if available (persisted across app launches)
final cachedUrl = _podcastCoverCache[podcast.itemId];
if (cachedUrl != null) {
return cachedUrl;
}
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
return _api?.getImageUrl(podcast, size: size);
}
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
/// Returns a Future since fallback requires async API calls
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
+3 -3
View File
@@ -706,8 +706,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
@@ -769,7 +769,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+2 -2
View File
@@ -553,8 +553,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
+2 -2
View File
@@ -429,8 +429,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
+2 -2
View File
@@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
+1 -1
View File
@@ -246,7 +246,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
final rowHeight = (availableHeight + 8) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
File diff suppressed because it is too large Load Diff
+685
View File
@@ -0,0 +1,685 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastDetailScreen({
super.key,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
}
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
List<MediaItem> _episodes = [];
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
_loadEpisodes();
});
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
}
}
}
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
});
}
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
}
void _playEpisode(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu(context, episode);
}
}
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes} min';
}
/// Format release date from episode metadata
/// Tries various common field names for publication date
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
// Try various common field names for publication date
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
// Unix timestamp
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
// Format as "Jan 15, 2024"
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('🎙️ Error parsing release date: $e');
}
return null;
}
/// Add episode to queue on current player
Future<void> _addToQueue(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Convert MediaItem to Track for queue addition
final track = Track(
itemId: episode.itemId,
provider: episode.provider,
name: episode.name,
uri: episode.uri,
duration: episode.duration,
metadata: episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
// CRITICAL: Use initialImageUrl for hero animation consistency
// The library passes the same URL it uses for display, ensuring cache hit
// Only fall back to provider URL if initialImageUrl is null
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.podcast.metadata?['description'] as String?;
final author = widget.podcast.metadata?['author'] as String?;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Podcast title
Text(
widget.podcast.name,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
if (author != null) ...[
const SizedBox(height: 4),
Text(
author,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Description
if (description != null && description.isNotEmpty) ...[
GestureDetector(
onTap: () {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
},
child: AnimatedCrossFade(
firstChild: Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
secondChild: Text(
description,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
crossFadeState: _isDescriptionExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
const SizedBox(height: 16),
],
// Episodes header
Row(
children: [
Text(
S.of(context)!.episodes,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 8),
if (_episodes.isNotEmpty)
Text(
'(${_episodes.length})',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
),
// Episodes list
if (_isLoadingEpisodes)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: CircularProgressIndicator(color: colorScheme.primary),
),
),
)
else if (_episodes.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
MdiIcons.microphoneOff,
size: 48,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = maProvider.getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
],
),
),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
);
},
childCount: _episodes.length,
),
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
},
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+368 -3
View File
@@ -498,6 +498,253 @@ class MusicAssistantAPI {
}
}
Future<List<MediaItem>> getRadioStations({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
final response = await _sendCommand(
'music/radios/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting radio stations: $e');
return [];
}
}
/// Search for radio stations globally (across all providers like TuneIn)
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
try {
_logger.log('📻 Searching radio stations for: $query');
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['radio'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) {
_logger.log('📻 Radio search: no result');
return [];
}
// Radio results might be under 'radios' or 'radio' key
final radios = (result['radios'] as List<dynamic>?) ??
(result['radio'] as List<dynamic>?) ??
[];
_logger.log('📻 Radio search found ${radios.length} results');
return radios
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('📻 Error searching radio stations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
Future<List<MediaItem>> getPodcasts({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
_logger.log('🎙️ Getting podcasts...');
final response = await _sendCommand(
'music/podcasts/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('🎙️ Podcasts: result is null');
return [];
}
_logger.log('🎙️ Found ${items.length} podcasts');
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('🎙️ Error getting podcasts: $e');
return [];
}
}
/// Get episodes for a specific podcast
Future<List<MediaItem>> getPodcastEpisodes(String podcastId, {
String? provider,
int? limit,
int? offset,
}) async {
try {
_logger.log('🎙️ Getting episodes for podcast: $podcastId, provider: $provider');
// Use the correct endpoint: music/podcasts/podcast_episodes
final response = await _sendCommand(
'music/podcasts/podcast_episodes',
args: {
'item_id': podcastId,
if (provider != null) 'provider_instance_id_or_domain': provider,
},
);
_logger.log('🎙️ Response keys: ${response.keys.toList()}');
// Response should be a list of episodes
final episodes = response['result'] as List<dynamic>?;
if (episodes != null && episodes.isNotEmpty) {
_logger.log('🎙️ Found ${episodes.length} episodes');
return episodes
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
}
_logger.log('🎙️ No episodes found for podcast');
return [];
} catch (e) {
_logger.log('🎙️ Error getting podcast episodes: $e');
return [];
}
}
/// Get best available image URL for a podcast
/// Falls back to first episode's image if podcast image is unavailable or low quality
Future<String?> getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async {
// First try the podcast's own image
final podcastImage = getImageUrl(podcast, size: size);
// Check if podcast has images in metadata
final images = podcast.metadata?['images'] as List<dynamic>?;
if (images != null && images.isNotEmpty) {
// Podcast has images, use them
return podcastImage;
}
// No podcast image, try to get first episode's image
try {
_logger.log('🎙️ No podcast cover, fetching episode cover for: ${podcast.name}');
final episodes = await getPodcastEpisodes(
podcast.itemId,
provider: podcast.provider,
);
if (episodes.isNotEmpty) {
final firstEpisode = episodes.first;
final episodeImage = getImageUrl(firstEpisode, size: size);
if (episodeImage != null) {
_logger.log('🎙️ Using episode cover for podcast: ${podcast.name}');
return episodeImage;
}
}
} catch (e) {
_logger.log('🎙️ Error fetching episode cover: $e');
}
// Fall back to podcast image even if it might be low quality
return podcastImage;
}
/// Parse browse results into MediaItem episodes
List<MediaItem> _parseBrowseEpisodes(List<dynamic> items) {
final episodes = <MediaItem>[];
for (final item in items) {
if (item is Map<String, dynamic>) {
final name = item['name'] as String? ??
item['label'] as String? ??
'Episode';
final uri = item['uri'] as String? ?? item['path'] as String?;
final itemId = item['item_id'] as String? ?? uri ?? '';
final provider = item['provider'] as String? ?? 'library';
// Parse media type from string
final mediaTypeStr = item['media_type'] as String?;
MediaType mediaType = MediaType.track;
if (mediaTypeStr != null) {
mediaType = MediaType.values.firstWhere(
(e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(),
orElse: () => MediaType.track,
);
}
// Get duration if available
Duration? duration;
final durationVal = item['duration'];
if (durationVal is int) {
duration = Duration(seconds: durationVal);
} else if (durationVal is double) {
duration = Duration(seconds: durationVal.toInt());
}
episodes.add(MediaItem(
itemId: itemId,
provider: provider,
name: name,
uri: uri,
metadata: item['metadata'] as Map<String, dynamic>?,
mediaType: mediaType,
duration: duration,
));
}
}
return episodes;
}
/// Play a podcast episode
Future<void> playPodcastEpisode(String playerId, MediaItem episode) async {
try {
// Try episode.uri first, then construct from itemId
String uri;
if (episode.uri != null && episode.uri!.isNotEmpty) {
uri = episode.uri!;
} else {
// Construct URI based on media type
uri = 'library://podcast_episode/${episode.itemId}';
}
_logger.log('🎙️ Playing podcast episode: ${episode.name}');
_logger.log('🎙️ Episode URI: $uri');
_logger.log('🎙️ Episode itemId: ${episode.itemId}');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('🎙️ Error playing podcast episode: $e');
rethrow;
}
}
Future<List<Audiobook>> getAudiobooks({
int? limit,
int? offset,
@@ -691,6 +938,26 @@ class MusicAssistantAPI {
}
}
/// Play a radio station
Future<void> playRadioStation(String playerId, MediaItem station) async {
try {
final uri = station.uri ?? 'library://radio/${station.itemId}';
_logger.log('📻 Playing radio station: ${station.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing radio station: $e');
rethrow;
}
}
/// Get full audiobook details including chapters
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
try {
@@ -1399,7 +1666,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []};
}
// Parse results from the search response
@@ -1428,6 +1695,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final radios = (result['radios'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final podcasts = (result['podcasts'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -1437,18 +1714,55 @@ class MusicAssistantAPI {
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
'radios': _deduplicateResults(radios),
'podcasts': _deduplicateResults(podcasts),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
});
}
/// Search for podcasts globally (across all providers)
Future<List<MediaItem>> searchPodcasts(String query, {int limit = 25}) async {
try {
_logger.log('🎙️ Searching podcasts for: $query');
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['podcast'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) {
_logger.log('🎙️ Podcast search: no result');
return [];
}
// Podcast results might be under 'podcasts' or 'podcast' key
final podcasts = (result['podcasts'] as List<dynamic>?) ??
(result['podcast'] as List<dynamic>?) ??
[];
_logger.log('🎙️ Podcast search found ${podcasts.length} results');
return podcasts
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('🎙️ Error searching podcasts: $e');
return [];
}
}
/// Deduplicate search results by name (case-insensitive)
/// Prefers library items (provider='library') over provider-specific items
/// since library items have complete provider mappings
@@ -2620,6 +2934,57 @@ class MusicAssistantAPI {
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
}
// ==================== iTunes Artwork Lookup ====================
/// Search iTunes for a podcast and return high-res artwork URL
/// Returns null if not found or on error
Future<String?> getITunesPodcastArtwork(String podcastName) async {
try {
final searchTerm = Uri.encodeComponent(podcastName);
final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1';
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 10);
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) {
_logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}');
return null;
}
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null || results.isEmpty) {
_logger.log('🎨 No iTunes results for "$podcastName"');
return null;
}
final podcast = results.first as Map<String, dynamic>;
var artworkUrl = podcast['artworkUrl600'] as String?;
if (artworkUrl == null) {
artworkUrl = podcast['artworkUrl100'] as String?;
}
if (artworkUrl != null) {
// Use 800x800 - good quality without being excessive (was 1400x1400)
artworkUrl = artworkUrl
.replaceAll('600x600', '800x800')
.replaceAll('100x100', '800x800');
_logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl');
}
return artworkUrl;
} catch (e) {
_logger.log('🎨 iTunes lookup error for "$podcastName": $e');
return null;
}
}
// ==================== Authentication Methods ====================
/// Authenticate the WebSocket session with a token
+54
View File
@@ -66,6 +66,8 @@ class SettingsService {
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'
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
@@ -81,6 +83,9 @@ class SettingsService {
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyServerUrl);
@@ -621,6 +626,26 @@ class SettingsService {
await prefs.setString(_keyLibrarySeriesViewMode, mode);
}
static Future<String> getLibraryRadioViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
}
static Future<void> setLibraryRadioViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryRadioViewMode, mode);
}
static Future<String> getLibraryPodcastsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
}
static Future<void> setLibraryPodcastsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
}
// View Mode Settings - Series Audiobooks (series detail screen)
static Future<String> getSeriesAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
@@ -786,4 +811,33 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
// Podcast Cover Cache (iTunes URLs for high-res artwork)
// Stored as JSON: {"podcastId": "itunesUrl", ...}
/// Get cached podcast cover URLs (iTunes high-res)
static Future<Map<String, String>> getPodcastCoverCache() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyPodcastCoverCache);
if (json == null) return {};
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as String));
} catch (_) {
return {};
}
}
/// Save podcast cover cache
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
}
/// Add a single podcast cover to cache (for incremental updates)
static Future<void> addPodcastCoverToCache(String podcastId, String url) async {
final cache = await getPodcastCoverCache();
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
}
+31 -3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget {
final Album album;
final VoidCallback? onTap;
final String? heroTagSuffix;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
@@ -30,6 +35,10 @@ class _AlbumCardState extends State<AlbumCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -37,6 +46,12 @@ class _AlbumCardState extends State<AlbumCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// Check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -47,6 +62,16 @@ class _AlbumCardState extends State<AlbumCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -69,7 +94,7 @@ class _AlbumCardState extends State<AlbumCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -86,6 +111,9 @@ class _AlbumCardState extends State<AlbumCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -118,8 +146,8 @@ class _AlbumCardState extends State<AlbumCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
+11 -7
View File
@@ -126,6 +126,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
@@ -135,13 +136,16 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
},
+29 -6
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
@@ -32,6 +33,10 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -39,6 +44,12 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
_loadImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
Future<void> _loadImage() async {
final provider = context.read<MusicAssistantProvider>();
@@ -56,14 +67,23 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
return;
}
// Fallback to external sources (MA returned null)
_fetchFallbackImage();
// Fallback to external sources (MA returned null) - with delay
_scheduleFallbackFetch();
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
if (_triedFallback) return;
_triedFallback = true;
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
}
Future<void> _fetchFallbackImage() async {
if (_triedFallback) return;
_triedFallback = true;
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
_fallbackImageUrl = fallbackUrl;
@@ -79,7 +99,7 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
// When MA image fails to load, try Deezer fallback
if (!_maImageFailed) {
_maImageFailed = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -100,6 +120,9 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
// PERF: Use imageSize for memory cache to reduce decode overhead
memCacheWidth: widget.imageSize,
memCacheHeight: widget.imageSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
+34 -7
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
final double? imageSize;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
this.heroTagSuffix,
this.imageSize,
this.imageCacheSize,
});
@override
@@ -34,6 +37,10 @@ class _ArtistCardState extends State<ArtistCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -42,6 +49,12 @@ class _ArtistCardState extends State<ArtistCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// We'll check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -52,7 +65,17 @@ class _ArtistCardState extends State<ArtistCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -63,8 +86,8 @@ class _ArtistCardState extends State<ArtistCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
}
@@ -81,6 +104,9 @@ class _ArtistCardState extends State<ArtistCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -115,8 +141,8 @@ class _ArtistCardState extends State<ArtistCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
@@ -163,6 +189,7 @@ class _ArtistCardState extends State<ArtistCard> {
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
+9 -4
View File
@@ -1143,6 +1143,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
},
onPowerToggle: () => maProvider.togglePower(selectedPlayer.playerId),
),
),
);
@@ -1694,10 +1695,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox(
width: titleWidth,
child: Text(
// Always show artist/author (was showing "Now Playing" when device reveal visible)
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
: maProvider.isPlayingPodcast
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
@@ -1734,7 +1737,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Album name OR Chapter name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget
// For audiobooks: show current chapter; for music: show album
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook))
// For podcasts: hide album line since podcast name is already shown in artist position
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast)
Positioned(
left: contentPadding,
right: contentPadding,
@@ -1883,11 +1887,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Playback controls - with slide animation when collapsed
// Use skip 30s controls for audiobooks and podcasts
Positioned(
left: t > 0.5 ? 0 : null,
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop,
child: maProvider.isPlayingAudiobook
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
? _buildAudiobookControls(
maProvider: maProvider,
selectedPlayer: selectedPlayer,
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.)
final Widget child;
/// The scroll controller for the child
final ScrollController controller;
/// List of items to extract letters from (must be sorted alphabetically)
/// Each item's first character is used to determine the letter
final List<String> items;
/// Callback when user taps/drags to a specific index
final void Function(int index)? onScrollToIndex;
/// Callback when drag state changes (for disabling scroll-to-hide)
final void Function(bool isDragging)? onDragStateChanged;
const LetterScrollbar({
super.key,
required this.child,
required this.controller,
required this.items,
this.onScrollToIndex,
this.onDragStateChanged,
});
@override
State<LetterScrollbar> createState() => _LetterScrollbarState();
}
class _LetterScrollbarState extends State<LetterScrollbar> {
bool _isDragging = false;
String _currentLetter = '';
double _dragPosition = 0;
double _scrollFraction = 0;
@override
void initState() {
super.initState();
widget.controller.addListener(_onScrollChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onScrollChanged);
super.dispose();
}
void _onScrollChanged() {
if (!_isDragging && widget.controller.hasClients) {
final position = widget.controller.position;
if (position.maxScrollExtent > 0) {
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() {
_scrollFraction = newFraction;
});
}
}
}
}
// Build a map of letter -> first index for that letter
Map<String, int> get _letterIndexMap {
final map = <String, int>{};
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
if (item.isNotEmpty) {
final letter = item[0].toUpperCase();
if (!map.containsKey(letter)) {
map[letter] = i;
}
}
}
return map;
}
String _getLetterAtPosition(double position, double maxHeight) {
if (widget.items.isEmpty || maxHeight <= 0) return '';
// Calculate which item index corresponds to this position
final fraction = (position / maxHeight).clamp(0.0, 1.0);
final index = (fraction * (widget.items.length - 1)).round();
if (index >= 0 && index < widget.items.length) {
final item = widget.items[index];
if (item.isNotEmpty) {
return item[0].toUpperCase();
}
}
return '';
}
void _scrollToLetter(String letter) {
final letterMap = _letterIndexMap;
if (letterMap.containsKey(letter)) {
final index = letterMap[letter]!;
if (widget.onScrollToIndex != null) {
widget.onScrollToIndex!(index);
} else {
// Estimate scroll position based on index
// This is approximate - works better with fixed height items
final scrollController = widget.controller;
if (scrollController.hasClients) {
final maxScroll = scrollController.position.maxScrollExtent;
final fraction = index / widget.items.length;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
// Update scroll fraction immediately for smooth thumb tracking
setState(() {
_scrollFraction = fraction;
});
scrollController.jumpTo(targetScroll);
}
}
}
}
void _handleDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPosition = details.localPosition.dy;
});
widget.onDragStateChanged?.call(true);
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
_dragPosition = details.localPosition.dy;
});
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragEnd(DragEndDetails details) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
}
void _updateLetterAndScroll(double position) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
final letter = _getLetterAtPosition(position, height);
if (letter.isNotEmpty && letter != _currentLetter) {
setState(() {
_currentLetter = letter;
});
_scrollToLetter(letter);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Stack(
children: [
// The scrollable content
widget.child,
// The draggable scrollbar area (right edge) - wider grab area
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 32, // Wider grab area
child: GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
onTapDown: (details) {
_handleDragStart(DragStartDetails(
localPosition: details.localPosition,
globalPosition: details.globalPosition,
));
},
onTapUp: (_) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
},
behavior: HitTestBehavior.translucent,
child: LayoutBuilder(
builder: (context, constraints) {
final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction;
return Stack(
children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
AnimatedPositioned(
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
top: thumbTop,
right: 4,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: _isDragging ? 6 : 4,
height: thumbHeight,
decoration: BoxDecoration(
color: _isDragging
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.3),
borderRadius: BorderRadius.circular(3),
),
),
),
],
);
},
),
),
),
// The letter popup bubble
if (_isDragging && _currentLetter.isNotEmpty)
Positioned(
right: 48,
top: _dragPosition - 28,
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
}
@@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget {
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragUpdateCallback? onHorizontalDragUpdate;
final GestureDragEndCallback? onHorizontalDragEnd;
final VoidCallback? onPowerToggle;
const DeviceSelectorBar({
super.key,
@@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget {
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
this.onPowerToggle,
});
@override
@@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget {
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
onPowerToggle: onPowerToggle,
isPoweredOn: selectedPlayer.powered,
),
],
),
+53 -12
View File
@@ -8,17 +8,20 @@ class MiniPlayerLayout {
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 82px
// 3-line layout: track, artist, player
static const double primaryTop = 7.0;
static const double secondaryTop = 27.0;
static const double primaryTop = 5.0; // raised slightly from 7
static const double secondaryTop = 26.0; // raised by 1
static const double tertiaryTop = 46.0;
// 2-line layout (centered): player name, hint
static const double primaryTop2Line = 16.0;
static const double secondaryTop2Line = 36.0;
// 2-line layout (evenly spaced): player name, hint
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
static const double secondaryTop2Line = 40.0; // raised slightly from 42
static const double textRightPadding = 12.0;
static const double powerButtonSize = 40.0; // Power button tap area
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double primaryFontSize = 16.0; // 3-line playing mode
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
static const double secondaryFontSize = 14.0;
static const double tertiaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
@@ -67,6 +70,12 @@ class MiniPlayerContent extends StatelessWidget {
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
/// Callback for power button tap (only shown in 2-line mode when provided)
final VoidCallback? onPowerToggle;
/// Whether the player is currently powered on
final bool isPoweredOn;
const MiniPlayerContent({
super.key,
required this.primaryText,
@@ -81,24 +90,35 @@ class MiniPlayerContent extends StatelessWidget {
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
this.onPowerToggle,
this.isPoweredOn = true,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
final is2LineMode = !hasTertiaryLine;
final showPowerButton = is2LineMode && onPowerToggle != null;
final slidePixels = slideOffset * width;
// Use 3-line layout when tertiary exists, 2-line centered otherwise
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Calculate right padding (extra space for power button in 2-line mode)
final rightPadding = showPowerButton
? MiniPlayerLayout.powerButtonSize + 8.0
: MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
// Font size for primary text (larger in 2-line mode)
final primaryFontSize = is2LineMode
? MiniPlayerLayout.primaryFontSize2Line
: MiniPlayerLayout.primaryFontSize;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
@@ -142,13 +162,13 @@ class MiniPlayerContent extends StatelessWidget {
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
: (MiniPlayerLayout.height - primaryFontSize) / 2,
right: rightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontSize: primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
@@ -161,7 +181,7 @@ class MiniPlayerContent extends StatelessWidget {
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
right: rightPadding - slidePixels,
child: isHint
? Row(
mainAxisSize: MainAxisSize.min,
@@ -212,6 +232,27 @@ class MiniPlayerContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// Power button (only for 2-line mode when callback provided)
// Matches PlayerCard power button style
if (showPowerButton)
Positioned(
right: 8.0 - slidePixels,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed: onPowerToggle,
icon: Icon(
Icons.power_settings_new_rounded,
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
),
],
),
);
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.7.4-beta+36
version: 2.8.0-beta+37
environment:
sdk: '>=3.0.0 <4.0.0'