Compare commits

...
52 Commits
Author SHA1 Message Date
ChrisandClaude Opus 4.5 0342caaf5c chore: Bump version to 2.8.8-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 09:51:22 +00:00
Chris LaycockandClaude Opus 4.5 d85dc9e592 fix: Improve queue transfer behavior and dropdown styling
- Change transfer dropdown background to lighter shade of queue panel
  background instead of dark album highlight color
- Add forceCollapse() to cleanly collapse player after transfer
  (closes queue panel instantly + collapses in one motion)
- Track queue's player ID to detect player changes and reload correct queue
- Fix transfer dropdown to use selected player ID for correct filtering
- Clear source player's track cache after transfer to prevent stale data
  in player selector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:33:50 +00:00
Your NameandClaude Opus 4.5 9338828ac7 feat: Add queue transfer between players
- Add transferQueue API method to move playback queue between players
- Add transfer queue button to queue screen with player picker
- Refactor queue panel with improved controls
- Minor library screen cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:04:04 +00:00
Your NameandClaude Opus 4.5 d920abd241 fix: Player list swipe dismiss now matches tap/back behavior
- Remove separate _dragOffset variable that caused cards to "shoot out"
  below mini player during swipe dismiss
- Directly control animation controller during drag for seamless handoff
- Fix bounce timing when user drags past bounce threshold before release

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:07:33 +00:00
Your NameandClaude Opus 4.5 02f7d501ff fix: Handle keystore signature changes gracefully
- Add decryption error handling in SecureStorageService
- Clear corrupted credentials when BadPaddingException occurs
- Add android:appCategory="audio" to manifest for proper app categorization
- Fix letter scrollbar proportional scrolling and bottom padding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:39:01 +00:00
Your NameandClaude Opus 4.5 5f72cca25e Fix author image fetching from Audnexus and Open Library
Two bugs were causing author photos to fail for well-known authors like
J.R.R. Tolkien and J.K. Rowling:

1. Audnexus API requires two-step fetch: The search endpoint only returns
   ASIN and name, not the image. Now correctly fetches author details by
   ASIN after search to get the image URL.

2. Open Library returns 302 redirects: The HEAD request check was only
   accepting 200 status codes, but Open Library redirects to archive.org.
   Now accepts 301/302/307/308 redirect responses as valid.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:00:23 +00:00
Your NameandClaude Opus 4.5 c2f75797e1 Fix hero animations for album grid views
Use consistent 256px image cache size for album grid cards to match
the detail screen cache size. This ensures smooth Hero animations
when navigating from grid view (both 2-column and 3-column) to the
album detail screen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:05:20 +00:00
Your NameandClaude Opus 4.5 3d982892de Improve tracks tab layout in library
- Make track name the primary line (title) instead of artist
- Move artist to subtitle line
- Add track duration on the right side
- Keep favorite icon (now before duration) for favorited tracks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:44:56 +00:00
Your NameandClaude Opus 4.5 c48f9d19c4 Improve search screen UI layout
- Add magnifying glass icon inside search bar
- Move library toggle outside search bar with circle background
- Style library toggle like library screen buttons (44x44, CircleBorder)
- Toggle highlights with primary color when active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:23 +00:00
Your NameandClaude Opus 4.5 e4f24766d2 Fix adaptive colors for library screen navigation
Add updateAdaptiveColorsFromImage calls before navigation to detail
screens in library tab. This ensures bottom nav colors update
immediately when navigating from:
- Artist tiles (list and grid views)
- Author navigation
- Audiobook/book navigation
- Series navigation (list and grid views)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:36:35 +00:00
Your NameandClaude Opus 4.5 d7ac8befac Add Wikidata source and fix author detail screen icon
1. Fix missing icon in author detail screen:
   - Use Stack pattern (icon underneath + image overlay) like library list
   - Ensures icon always visible when no image
   - Consistent Hero animation between screens

2. Add Wikidata as author image source:
   - Structured data from Wikipedia, better for exact person matching
   - Uses P18 (image) property to get Wikimedia Commons photos
   - Searches top 3 results to find one with an image
   - Added between Open Library and Wikipedia in priority

Source priority:
1. Audnexus (audiobook-focused)
2. Open Library (book database)
3. Wikidata (structured Wikipedia data) - NEW
4. Wikipedia (search-based fallback)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:16:56 +00:00
Your NameandClaude Opus 4.5 8a2a3b8a14 Make comma-separated author detection safer
- Check for common suffixes (Jr., Sr., III, PhD, etc.)
- Check if second part looks like initials (J.R.R., etc.)
- Require BOTH parts to have multiple words for multi-author split
- Don't cache null on failure - fall through to try full name

This prevents incorrectly splitting "Name, Jr." or "Lastname, First"
while still handling "Neil Gaiman, Dirk Maggs".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:01:41 +00:00
Your NameandClaude Opus 4.5 ea1133c4d6 Revert Wikipedia multi-query - caused worse results
The multiple search queries (exact name, then with suffixes) was
finding wrong pages. Reverting to original "name writer author"
search which was tuned to find author pages.

Keep the comma-separated author handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:00:02 +00:00
Your NameandClaude Opus 4.5 cb2595e36a Improve author image lookup for combined names and Wikipedia
1. Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
   - Detects multi-author format vs "Lastname, Firstname" format
   - Tries to get image for each author until one succeeds

2. Improve Wikipedia search strategy
   - Try exact name first (works best for famous authors)
   - Then try with "author", "writer", "novelist" suffixes
   - This helps find authors whose pages might not rank for generic search

Should help find: Anne Rice, Philip Pullman, Paulo Coelho, P.G. Wodehouse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:43:55 +00:00
Your NameandClaude Opus 4.5 70e983de51 Add periods to standalone single-letter initials
After normalizing "J. R.R Tolkien" to "J. R. R Tolkien", the final
"R" lacks a period and won't be detected as an initial.

Now adds periods to standalone uppercase letters between spaces:
"J. R. R Tolkien" -> "J. R. R. Tolkien"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:26 +00:00
Your NameandClaude Opus 4.5 251579d32a Handle inconsistent initial formatting like "J. R.R Tolkien"
The metadata sometimes has messy formats with inconsistent spacing
around initials. Now handles:
- "J. R.R Tolkien" -> "J. R. R. Tolkien"
- "J.R.R. Tolkien" -> "J. R. R. Tolkien"
- "J.K. Rowling" -> "J. K. Rowling"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:19:48 +00:00
Your NameandClaude Opus 4.5 9d5e8abc11 Improve author image lookup for names with initials
Problems:
- "J.R.R. Tolkien" wasn't being parsed correctly - the condensed
  initials "J.R.R." weren't detected as initials
- Wikipedia only tried the first name variation

Fixes:
1. Expand condensed initials: "J.R.R." -> "J. R. R." for better splitting
2. Better initial detection that handles multiple initials
3. Generate compact variations: "JRR Tolkien", "JK Rowling"
4. Wikipedia now tries top 3 variations (best source for famous authors)

Example variations for "J.R.R. Tolkien":
- J.R.R. Tolkien (original)
- J. R. R. Tolkien (expanded)
- Tolkien (last name only)
- JRR Tolkien (compact initials)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:18:29 +00:00
Your NameandClaude Opus 4.5 225a1bd010 Fix author image aspect ratio distortion
Remove memCacheHeight from author images - setting both memCacheWidth
and memCacheHeight to equal values forces the image to be cached as
a square in memory, which distorts non-square source images BEFORE
BoxFit.cover can properly scale and crop.

By only setting memCacheWidth, height scales proportionally to
preserve the original aspect ratio. BoxFit.cover then handles
filling the circular container correctly.

Fixed in:
- Library grid author cards
- Library list author tiles
- Author detail screen header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:54:24 +00:00
Your NameandClaude Opus 4.5 ef5f6b6483 Fix author image sizing issues
1. Library grid: Wrap author image ClipOval in SizedBox for proper
   sizing constraints (prevents squished images on initial load)

2. Author detail screen: Use responsive sizing like artist detail:
   - coverSize = (screenWidth * 0.7).clamp(160, 280)
   - expandedHeight = coverSize + 100
   - Icon size scales with coverSize * 0.5
   - Added memCacheWidth/Height for better performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:42:08 +00:00
Your NameandClaude Opus 4.5 4a369fa5f5 Update audit report with Phase 4 status
Completed:
- SettingsService generic helpers (~15% reduction)
- Removed unused providers (1,615 lines dead code)

Deferred (risk outweighs benefit):
- MusicAssistantProvider split - evolved with Sendspin, ID mapping
- Library screen tab extraction - tight state coupling
- API domain split - shared WebSocket connection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:26:32 +00:00
Your NameandClaude Opus 4.5 52489fa97a Remove unused LibraryProvider and ConnectionProvider
These providers were early attempts at splitting MusicAssistantProvider
that were never completed or integrated. Like PlayerProvider (removed in
previous commit), the code has evolved significantly and these files
would need substantial updates to be useful.

Also fixes stale comment reference to library_provider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:19:20 +00:00
Your NameandClaude Opus 4.5 5c52ef2738 Remove unused PlayerProvider
The standalone PlayerProvider was created early but never integrated.
MusicAssistantProvider has since evolved with:
- Sendspin support (MA 2.7.0b20+)
- Cast-to-Sendspin ID mapping with DB persistence
- Position tracker integration
- Audio handler mode switching
- Complex player filtering

Keeping player code in MusicAssistantProvider as it's working
and would require ~1600 lines of sync work to update PlayerProvider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:13:28 +00:00
Your Name 447b8c8a93 Fix nullable return types for view mode getters 2026-01-14 09:41:36 +00:00
Your NameandClaude Opus 4.5 eb34663d3f Phase 4: Extract SettingsService generic helpers
Added generic helper methods to reduce boilerplate:
- _getString(key, {defaultValue}) / _setString(key, value, {removeIfEmpty})
- _getBool(key, {defaultValue}) / _setBool(key, value)
- _getInt(key, {defaultValue}) / _setInt(key, value)
- _remove(key)

Refactored ~40 getter/setter pairs to use helpers:
- Theme settings (themeMode, materialTheme, adaptiveTheme, customColor)
- Locale, API keys, player settings
- Home screen row visibility settings (12 toggles)
- View mode settings

Reduced file from 1,031 to 874 lines (~15% reduction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:36:40 +00:00
Your NameandClaude Opus 4.5 2d359db25c Update AUDIT_REPORT.md with comprehensive audit findings
Updated report with:
- Full codebase audit (104 Dart files, 18 screens)
- Critical/High/Medium severity categorization
- Refactoring roadmap (Phase 1-4)
- Quick wins list
- Files by priority

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:32:31 +00:00
Your NameandClaude Opus 4.5 0845d6d29a Phase 3: Replace Colors.red with StatusColors.favorite for favorites icons
Updated favorites heart icons to use semantic StatusColors.favorite:
- new_library_screen.dart: playlist and track favorites
- library_playlists_screen.dart: playlist favorites

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:29:10 +00:00
Your NameandClaude Opus 4.5 c80a86abe8 Phase 3: Replace hardcoded SizedBox gaps with Spacing tokens
Replaced const SizedBox(height: 8/16) with Spacing.vGap8/vGap16 in:
- album_details_screen.dart (7 replacements)
- artist_details_screen.dart (8 replacements)
- audiobook_detail_screen.dart (4 replacements)
- playlist_details_screen.dart (6 replacements)
- search_screen.dart (3 replacements)
- new_library_screen.dart (8 replacements)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:26:36 +00:00
Your NameandClaude Opus 4.5 8358274f73 Phase 3: Add design tokens and standardize values
Design tokens added:
- ButtonSizes: sm/md/lg/xl/xxl for touch targets, iconSm/iconMd/iconLg/iconXl
- StatusColors: success/error/warning/info with light/dark variants, favorite

Standardization:
- Library filter buttons now use ButtonSizes.md (36) and ButtonSizes.iconMd (18)
- Favorites button uses StatusColors.favorite instead of Colors.red
- All BorderRadius.circular(22) and circular(25) → Radii.xxl (24)

Files updated:
- album_details_screen, artist_details_screen, audiobook_detail_screen
- playlist_details_screen, search_screen, new_library_screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:25:27 +00:00
Your NameandClaude Opus 4.5 9200af705a Add 6px spacing between library filter buttons
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:06:17 +00:00
Your NameandClaude Opus 4.5 1eed678deb Fix Phase 2 build errors - undefined provider variables
- global_player_overlay.dart: Use isConnected/hasSelectedPlayer from
  context.select() instead of removed provider variable
- expandable_player.dart: Add themeProvider via context.read() for
  methods that need full ThemeProvider object

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:56:57 +00:00
Your NameandClaude Opus 4.5 4ab31c11df Adjust filter row button sizes to 36x36 with 18px icons
Reverts the oversized 44x44 buttons from Phase 1 audit fix to a more
balanced 36x36 size that fits better visually while still being
easily tappable.

- _filterRowHeight: 48 → 36
- buttonSize: 44 → 36
- iconSize: 20 → 18

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:53:42 +00:00
Your NameandClaude Opus 4.5 4c7ecfbf88 Optimize Provider selectors with context.select() (Phase 2 audit fixes)
- global_player_overlay: Use select() for isConnected/selectedPlayer check
- new_library_screen: Use select() for podcasts and radio tabs
- expandable_player: Use select() for adaptiveTheme boolean
- player_reveal_overlay: Remove unused ThemeProvider watch

Performance impact: Reduces unnecessary rebuilds when unrelated
provider state changes, improving scroll performance in library tabs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:34:04 +00:00
Your NameandClaude Opus 4.5 d376fd3725 Cache SharedPreferences and add migration skip flag (Phase 2 audit fixes)
- Add SettingsService.initialize() to cache SharedPreferences at startup
- Replace 130+ getInstance() calls with cached _getPrefs() helper
- Add migration completed flag to SecureStorageService
- Skip SecureStorage migration on subsequent app launches

Performance impact: Eliminates redundant SharedPreferences lookups
and saves ~100-300ms on startup after first run.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:32:17 +00:00
Your NameandClaude Opus 4.5 a7893e6ad9 Add navigation debounce and error boundaries (Phase 1 audit fixes)
- Add navigationDebounce timing constant (500ms)
- Convert card widgets to StatefulWidget for debounce state tracking
- Prevent double-tap navigation on AlbumCard, ArtistCard, PlaylistCard,
  PodcastCard, RadioStationCard
- Add FlutterError.onError handler for widget build errors
- Add PlatformDispatcher.onError for platform channel errors
- Wrap runApp in runZonedGuarded for async error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:30:45 +00:00
Your NameandClaude Opus 4.5 d3474a2665 Fix touch targets and bottom spacing (Phase 1 audit fixes)
- Increase filter row touch targets from 32x32 to 44x44 (Material Design minimum)
- Replace hardcoded bottom spacing (100, 140, 164) with BottomSpacing.withMiniPlayer
- Affected screens: album_details, artist_details, audiobook_detail,
  audiobook_author, playlist_details, podcast_detail, new_library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:27:36 +00:00
Your Name 5bfa91c566 Revert "Increase library action buttons to 40x40 with 20px icons"
This reverts commit 884b457139.
2026-01-14 07:05:46 +00:00
Your Name 884b457139 Increase library action buttons to 40x40 with 20px icons
- Sort, favorites, and view mode buttons now larger and easier to tap
- Increased filter row height from 36px to 44px to accommodate
2026-01-14 06:58:46 +00:00
Your Name 5e2599bdb8 Fix author avatar icon visibility using Stack approach
- Icon is always rendered underneath in a Stack
- Image covers icon when loaded successfully
- No widget tree switching = no layout glitches
- Placeholder/error show SizedBox.shrink() to reveal icon beneath
2026-01-14 06:49:14 +00:00
Your NameandClaude Opus 4.5 4dec8f43a2 Fix author icon rendering by using builder function
Use a builder function to create fresh Icon widgets for each use
instead of reusing a single widget instance. Also wrap placeholder
and error widgets in properly sized Containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:32:09 +00:00
Your NameandClaude Opus 4.5 ee7a08a859 Add fuzzy name matching for author image lookups
Name variations tried in order:
- Original name
- "Lastname, Firstname" -> "Firstname Lastname" conversion
- Remove titles (Dr., Mr., Mrs., Prof., Sir, Dame, etc.)
- Remove suffixes (Jr., Sr., III, PhD, etc.)
- First + Last name only (skip middle names/initials)
- Last name only (when first part is an initial)
- Handle double initials like "J.K. Rowling" -> try "Rowling"

Also refactored API calls into separate helper methods for cleaner code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:15:39 +00:00
Your NameandClaude Opus 4.5 4351a32cde Fix author avatar fallback to use icon instead of text
- Use Icons.person_rounded for fallback instead of text initials
- Properly center the icon in both list tile and card views
- Fix sizing to prevent visual jumps when images load
- Simplify widget structure for consistent rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:11:38 +00:00
Your NameandClaude Opus 4.5 587115dc2b Fix tracks favorites, font size, and bottom padding
- Fix favorites toggle not showing tracks by using _sortedFavoriteTracks
  instead of filtering _sortedAllTracks (which has stale favorite flags)
- Fix tracks list font size to use titleMedium like other tabs
- Fix all tabs to use BottomSpacing.withMiniPlayer to account for
  mini player height, preventing bottom items from being cut off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:59:19 +00:00
Your NameandClaude Opus 4.5 862c9d5ef3 Fix series folder detection for abbreviated paths
The browse API returns abbreviated paths like /s for series instead of
/series. Update detection to check for path.endsWith('/s') in addition
to checking for 'series' in the name or path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:44:26 +00:00
Your NameandClaude Opus 4.5 ed7613e6ad Fix audiobook bugs and UI inconsistencies
- Fix audiobook favorites toggle not reloading books when toggled
- Fix series loading by adding 'label' fallback for 'name' field
- Add consistent padding to View Mode button
- Add sort direction indicator with rotated icon for descending sorts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:29:52 +00:00
Your Name 74d2b1b1eb Increase filter chip padding to 12px 2026-01-13 21:57:05 +00:00
Your Name 951c8a68ea Fix highlight/content mismatch when swiping between media types
- Add flag to ignore onPageChanged during media type transitions
- Handle page jump directly in _changeMediaType instead of _resetCategoryIndex
- Clear flag after short delay to ensure all page change events have fired
2026-01-13 21:54:15 +00:00
Your Name c24dec51c5 Use TextPainter for accurate text measurement + fixed padding for consistent spacing 2026-01-13 21:48:28 +00:00
Your Name 11ed7e7d34 Fix swipe direction: go to last tab when swiping right-to-left between media types 2026-01-13 21:41:55 +00:00
Your Name 40f72f8db9 Fix inconsistent spacing in category chips - use same formula for flex and width 2026-01-13 21:38:28 +00:00
Your Name 5e2fb6ff32 Reduce filter row spacing and use consistent faded circle buttons 2026-01-13 21:29:26 +00:00
Your Name 47894e0bb9 Fix null safety for album artists sorting 2026-01-13 21:16:48 +00:00
Your NameandClaude Opus 4.5 c9f7321bec Add permanent Tracks tab, sort button, and extend favorites to all views
- Make Tracks tab always visible in Music (not just when favorites on)
- Add lazy loading for tracks (initial 200, +100 on scroll)
- Add sort button with popup menu next to favorites toggle
- Sort icon changes to reflect current sort order
- Extend favorites filtering to Radio and Podcasts tabs
- Add sort options for all categories:
  - Artists: A-Z, Z-A
  - Albums: A-Z, Z-A, Year (Newest/Oldest), By Artist
  - Tracks: A-Z, By Artist, By Album, Duration
  - Playlists: A-Z, Z-A, Track Count
  - Radio/Podcasts: A-Z, Z-A
- Persist sort preferences per category in settings
- Add localization strings for new empty states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:07:27 +00:00
56 changed files with 14620 additions and 2887 deletions
+216 -95
View File
@@ -1,136 +1,257 @@
# Ensemble Flutter App - Code Audit Report
# Ensemble Codebase Audit Report
**Date:** January 4, 2026
**Branch:** `audit/code-quality-review`
**Files Audited:** 93 Dart files
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Category | Issues Found | Priority |
|----------|-------------|----------|
| Deprecated APIs | 20 instances | HIGH |
| Missing const constructors | 100+ instances | MEDIUM |
| Image cache parameters | 7 locations | MEDIUM |
| Code duplication | 8 view mode functions | LOW |
| Color constants | 3 files with duplicates | LOW |
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## 1. CRITICAL: Deprecated API Usage
## Critical Issues
### `colorScheme.background` (20 instances)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
**Files affected:**
- lib/main.dart:335, 336
- lib/screens/library_artists_screen.dart:21
- lib/screens/new_home_screen.dart:121
- lib/screens/artist_details_screen.dart:708, 720
- lib/screens/library_playlists_screen.dart:53
- lib/screens/library_tracks_screen.dart:19
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
- lib/screens/album_details_screen.dart:807, 819
- lib/screens/library_albums_screen.dart:19
- lib/screens/search_screen.dart:427, 738, 739
- lib/screens/settings_screen.dart:245
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## 2. HIGH: Missing const Constructors
## High Severity Issues
### EdgeInsets.zero (64 instances)
High-impact optimization - used extensively in button padding.
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
**Key files:**
- lib/screens/album_details_screen.dart (12 instances)
- lib/screens/artist_details_screen.dart (8 instances)
- lib/screens/audiobook_detail_screen.dart (3 instances)
- lib/screens/audiobook_series_screen.dart (2 instances)
- lib/screens/audiobook_author_screen.dart (2 instances)
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### BorderRadius.circular (40+ instances)
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
## Medium Severity Issues
### CachedNetworkImage without memCacheWidth/Height
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
**lib/screens/new_library_screen.dart:**
- Line 1395-1402: Author image (48x48) - missing cache params
- Line 1480-1486: Author card image - missing cache params
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
- Line 1740-1745: Audiobook grid cover - missing cache params
- Line 1959-1969: Playlist cover (56x56) - missing cache params
- Line 2143-2152: Playlist grid covers - missing cache params
- Line 2973-2981: Album cover (56x56) - missing cache params
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## 4. LOW: Code Duplication
## Quick Wins (High Impact, Low Effort)
### View Mode Cycling Functions (8 nearly identical functions)
**File:** lib/screens/new_library_screen.dart
Functions that follow identical switch/case pattern:
- `_cycleArtistsViewMode()` (line 184)
- `_cycleAlbumsViewMode()` (line 200)
- `_cyclePlaylistsViewMode()` (line 216)
- `_cycleAuthorsViewMode()` (line 232)
- `_cycleAudiobooksViewMode()` (line 248)
- `_cycleSeriesViewMode()` (line 286)
- `_cycleRadioViewMode()` (line 303)
- `_cyclePodcastsViewMode()` (line 319)
**Recommendation:** Create generic utility function.
### Duplicated Color Constants (3 files)
Series fallback colors defined identically in:
- lib/widgets/series_row.dart:246-253
- lib/screens/audiobook_series_screen.dart:466-473
- lib/screens/new_library_screen.dart:2112-2119
**Recommendation:** Extract to lib/constants/colors.dart
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## 5. POSITIVE FINDINGS (Already Optimized)
## Refactoring Roadmap
- **RepaintBoundary**: Properly implemented in all list item widgets
- **ListView.builder**: Used consistently with cacheExtent: 1000
- **ScrollController disposal**: All 8+ controllers properly disposed
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
- **Debounced operations**: Color extraction debounced to 300ms
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Recommended Action Plan
## Positive Findings
### Phase 1: Quick Wins (Low Risk)
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
The codebase already demonstrates many good practices:
### Phase 2: Image Optimization
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
### Phase 3: Code Cleanup (Optional)
1. Extract view mode cycling to utility function
2. Centralize color constants
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Complexity
## Files by Priority
| File | Lines | Status |
|------|-------|--------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+1
View File
@@ -11,6 +11,7 @@
android:label="Ensemble"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:appCategory="audio"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+2 -1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_background_color">#1a1a1a</color>
</resources>
<color name="ic_launcher_background">#1a1a1a</color>
</resources>
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
+3
View File
@@ -31,6 +31,9 @@ class Timings {
/// Search debounce delay
static const Duration searchDebounce = Duration(milliseconds: 500);
/// Navigation debounce to prevent double-tap navigation
static const Duration navigationDebounce = Duration(milliseconds: 500);
/// Delay after track change before updating state
static const Duration trackChangeDelay = Duration(milliseconds: 500);
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -1401,5 +1401,35 @@
"type": "String"
}
}
},
"noTracks": "No tracks",
"@noTracks": {
"description": "Empty state title when no tracks exist in library"
},
"addTracksHint": "Add some music to your library to see tracks here",
"@addTracksHint": {
"description": "Hint for adding tracks to library"
},
"noFavoriteRadioStations": "No favorite radio stations",
"@noFavoriteRadioStations": {
"description": "Empty state when no favorite radio stations"
},
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
"@longPressRadioHint": {
"description": "Hint for favoriting radio stations"
},
"noFavoritePodcasts": "No favorite podcasts",
"@noFavoritePodcasts": {
"description": "Empty state when no favorite podcasts"
},
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
"@longPressPodcastHint": {
"description": "Hint for favoriting podcasts"
}
}
File diff suppressed because it is too large Load Diff
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class SDe extends S {
SDe([String locale = 'de']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Verbinde...';
@override
String get connected => 'Verbunden';
@override
String get disconnected => 'Getrennt';
@override
String get serverAddress => 'Serveradresse';
@override
String get serverAddressHint => 'z.B. music.example.com oder 192.168.1.100';
@override
String get yourName => 'Dein Name';
@override
String get yourFirstName => 'Dein Vorname';
@override
String get portOptional => 'Port (Optional)';
@override
String get portHint => 'z.B. 8095 oder leer lassen';
@override
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
@override
String get username => 'Benutzername';
@override
String get password => 'Passwort';
@override
String get detectAndConnect => 'Erkennen & Verbinden';
@override
String get connect => 'Verbinden';
@override
String get disconnect => 'Trennen';
@override
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@override
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
@override
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
@override
String detectedAuthType(String authType) {
return 'Erkannt: $authType';
}
@override
String get serverRequiresAuth => 'Dieser Server erfordert Authentifizierung';
@override
String get tailscaleVpnConnection => 'Tailscale VPN-Verbindung';
@override
String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
@override
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
@override
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
@override
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override
String get detectingAuth => 'Erkenne Authentifizierung...';
@override
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override
String get noAuthentication => 'Keine Authentifizierung';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Music Assistant Anmeldung';
@override
String get pressBackToMinimize => 'Erneut drucken zum Minimieren';
@override
String get recentlyPlayed => 'Zuletzt gespielt';
@override
String get discoverArtists => 'Kunstler entdecken';
@override
String get discoverAlbums => 'Alben entdecken';
@override
String get continueListening => 'Weiterhoren';
@override
String get discoverAudiobooks => 'Horbücher entdecken';
@override
String get discoverSeries => 'Serien entdecken';
@override
String get favoriteAlbums => 'Lieblingsalben';
@override
String get favoriteArtists => 'Lieblingskünstler';
@override
String get favoriteTracks => 'Lieblingssongs';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Musik suchen...';
@override
String get searchForContent => 'Nach Künstlern, Alben oder Songs suchen';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Wiederholen';
@override
String get all => 'Alle';
@override
String get artists => 'Künstler';
@override
String get albums => 'Alben';
@override
String get tracks => 'Songs';
@override
String get playlists => 'Wiedergabelisten';
@override
String get audiobooks => 'Hörbücher';
@override
String get music => 'Musik';
@override
String get radio => 'Radio';
@override
String get stations => 'Sender';
@override
String get selectLibrary => 'Bibliothek auswählen';
@override
String get noRadioStations => 'Keine Radiosender';
@override
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
@override
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override
String get queue => 'Warteschlange';
@override
String playerQueue(String playerName) {
return '$playerName Warteschlange';
}
@override
String get noPlayerSelected => 'Kein Player ausgewählt';
@override
String get undo => 'Rückgängig';
@override
String removedItem(String itemName) {
return '$itemName entfernt';
}
@override
String get settings => 'Einstellungen';
@override
String get debugLogs => 'Debug-Protokolle';
@override
String get themeMode => 'Design';
@override
String get light => 'Hell';
@override
String get dark => 'Dunkel';
@override
String get system => 'System';
@override
String get pullToRefresh => 'Ziehe zum Aktualisieren der Bibliothek';
@override
String get viewDebugLogs => 'Debug-Protokolle anzeigen';
@override
String get viewAllPlayers => 'Alle Player anzeigen';
@override
String get copyLogs => 'Protokolle kopieren';
@override
String get clearLogs => 'Protokolle löschen';
@override
String get copyList => 'Liste kopieren';
@override
String get close => 'Schließen';
@override
String allPlayersCount(int count) {
return 'Alle Player ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Fehler beim Laden der Player: $error';
}
@override
String get logsCopied => 'Protokolle in Zwischenablage kopiert';
@override
String get logsCleared => 'Protokolle gelöscht';
@override
String get playerListCopied => 'Playerliste in Zwischenablage kopiert!';
@override
String get noLogsYet => 'Noch keine Protokolle';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Warnungen';
@override
String get errors => 'Fehler';
@override
String showingEntries(int filtered, int total) {
return 'Zeige $filtered von $total Einträgen';
}
@override
String get thisDevice => 'Dieses Gerät';
@override
String get ghostPlayer => 'Geist-Player (Duplikat)';
@override
String get unavailableCorrupt => 'Nicht verfügbar/Beschädigt';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Verfügbar: $available | Anbieter: $provider';
}
@override
String get shareBugReport => 'Fehlerbericht teilen';
@override
String get moreOptions => 'Weitere Optionen';
@override
String failedToUpdateFavorite(String error) {
return 'Favorit konnte nicht aktualisiert werden: $error';
}
@override
String get noPlayersAvailable => 'Keine Player verfügbar';
@override
String get albumAddedToQueue => 'Album zur Warteschlange hinzugefügt';
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get play => 'Abspielen';
@override
String get noPlayersFound => 'Keine Player gefunden';
@override
String startingRadio(String name) {
return 'Starte $name Radio';
}
@override
String failedToStartRadio(String error) {
return 'Radio konnte nicht gestartet werden: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Starte $name Radio auf $playerName';
}
@override
String addedRadioToQueue(String name) {
return '$name Radio zur Warteschlange hinzugefügt';
}
@override
String failedToAddToQueue(String error) {
return 'Konnte nicht zur Warteschlange hinzufügen: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'In Bibliothek';
@override
String get noAlbumsFound => 'Keine Alben gefunden';
@override
String playing(String name) {
return 'Spiele $name';
}
@override
String get playingTrack => 'Spiele Song';
@override
String get nowPlaying => 'Wird gespielt';
@override
String markedAsFinished(String name) {
return '$name als beendet markiert';
}
@override
String failedToMarkFinished(String error) {
return 'Konnte nicht als beendet markieren: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name als ungespielt markiert';
}
@override
String failedToMarkUnplayed(String error) {
return 'Konnte nicht als ungespielt markieren: $error';
}
@override
String failedToPlay(String error) {
return 'Abspielen fehlgeschlagen: $error';
}
@override
String get markAsFinished => 'Als beendet markieren';
@override
String get markAsUnplayed => 'Als ungespielt markieren';
@override
String byAuthor(String author) {
return 'Von $author';
}
@override
String audiobookCount(int count) {
return '$count Hörbuch/Hörbücher';
}
@override
String get loading => 'Laden...';
@override
String bookCount(int count) {
return '$count Buch/Bücher';
}
@override
String get books => 'Bücher';
@override
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override
String get noAudiobooks => 'Keine Hörbücher';
@override
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override
String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override
String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override
String get noFavoriteTracks => 'Keine Lieblingssongs';
@override
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override
String get loadSeries => 'Serien laden';
@override
String get notConnected => 'Nicht mit Music Assistant verbunden';
@override
String get notConnectedTitle => 'Nicht verbunden';
@override
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
@override
String get configureServer => 'Server konfigurieren';
@override
String get noArtistsFound => 'Keine Künstler gefunden';
@override
String get noTracksFound => 'Keine Songs gefunden';
@override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get queueIsEmpty => 'Warteschlange ist leer';
@override
String get noResultsFound => 'Keine Ergebnisse gefunden';
@override
String get refresh => 'Aktualisieren';
@override
String get debugConsole => 'Debug-Konsole';
@override
String get copy => 'Kopieren';
@override
String get clear => 'Löschen';
@override
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override
String get showDebug => 'Debug anzeigen';
@override
String get hideDebug => 'Debug ausblenden';
@override
String get chapters => 'Kapitel';
@override
String get noChapters => 'Keine Kapitel';
@override
String get noChapterInfo => 'Dieses Hörbuch hat keine Kapitelinformationen';
@override
String errorSeeking(String error) {
return 'Fehler beim Spulen: $error';
}
@override
String error(String error) {
return 'Fehler: $error';
}
@override
String get home => 'Start';
@override
String get library => 'Bibliothek';
@override
String get homeScreen => 'Startbildschirm';
@override
String get metadataApis => 'Metadaten-APIs';
@override
String get theme => 'Design';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Adaptives Design';
@override
String get language => 'Sprache';
@override
String get english => 'Englisch';
@override
String get german => 'Deutsch';
@override
String get spanish => 'Spanisch';
@override
String get french => 'Französisch';
@override
String get noTracksInPlaylist => 'Keine Songs in Wiedergabeliste';
@override
String get sortAlphabetically => 'Alphabetisch sortieren';
@override
String get sortByYear => 'Nach Jahr sortieren';
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get listView => 'Listenansicht';
@override
String get gridView => 'Rasteransicht';
@override
String get noBooksInSeries => 'Keine Bücher in dieser Serie gefunden';
@override
String get artist => 'Künstler';
@override
String get showRecentlyPlayedAlbums => 'Kürzlich gespielte Alben anzeigen';
@override
String get showRandomArtists => 'Zufällige Künstler zum Entdecken anzeigen';
@override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
@override
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@override
String get showFavoriteArtists => 'Lieblingskünstler anzeigen';
@override
String get showFavoriteTracks => 'Lieblingssongs anzeigen';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
@override
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@override
String get removedFromFavorites => 'Aus Favoriten entfernt';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unbekannt';
@override
String get noUpcomingTracks => 'Keine weiteren Songs';
@override
String get showAll => 'Alle anzeigen';
@override
String get showFavoritesOnly => 'Nur Favoriten anzeigen';
@override
String get changeView => 'Ansicht ändern';
@override
String get authors => 'Autoren';
@override
String get series => 'Serien';
@override
String get shows => 'Sendungen';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Podcast-Unterstützung kommt bald';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Wiedergabeliste';
@override
String get connectionError => 'Verbindungsfehler';
@override
String get twoColumnGrid => '2-Spalten-Raster';
@override
String get threeColumnGrid => '3-Spalten-Raster';
@override
String get fromProviders => 'Von Anbietern';
@override
String get resume => 'Fortsetzen';
@override
String get about => 'Über';
@override
String get inProgress => 'In Bearbeitung';
@override
String narratedBy(String narrators) {
return 'Gesprochen von $narrators';
}
@override
String get unknownNarrator => 'Unbekannter Sprecher';
@override
String get unknownAuthor => 'Unbekannter Autor';
@override
String get loadingChapters => 'Lade Kapitel...';
@override
String get noChapterInfoAvailable => 'Keine Kapitelinformationen verfügbar';
@override
String percentComplete(int percent) {
return '$percent% abgeschlossen';
}
@override
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@override
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
@override
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override
String get unknownLibrary => 'Unbekannte Bibliothek';
@override
String get noSeriesFound => 'Keine Serien gefunden';
@override
String get ensembleBugReport => 'Ensemble Fehlerbericht';
@override
String byOwner(String owner) {
return 'Von $owner';
}
@override
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
@override
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
@override
String get search => 'Suche';
@override
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override
String get lastFmApiKey => 'Last.fm API-Schlüssel';
@override
String get lastFmApiKeyHint => 'Kostenlosen Schlüssel auf last.fm/api holen';
@override
String get swipeToSwitchDevice => 'Wischen um Gerät zu wechseln';
@override
String chapterNumber(int number) {
return 'Kapitel $number';
}
@override
String get pcmAudio => 'PCM-Audio';
@override
String get playOn => 'Abspielen auf...';
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
}
@override
String get book => 'Buch';
@override
String get audiobookSingular => 'Hörbuch';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Songs',
one: 'Song',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Systemfarben verwenden (Android 12+)';
@override
String get accentColor => 'Akzentfarbe';
@override
String get players => 'Geräte';
@override
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@override
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
@override
String get smartSortPlayers => 'Intelligente Sortierung';
@override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@override
String get playerStateOff => 'Aus';
@override
String get playerStateIdle => 'Inaktiv';
@override
String get playerStateExternalSource => 'Externe Quelle';
@override
String get playerSelected => 'Ausgewählt';
@override
String get actionQueuedForSync => 'Wird synchronisiert wenn online';
@override
String pendingOfflineActions(int count) {
return '$count ausstehende Synchronisierung';
}
@override
String get hintsAndTips => 'Tipps & Hinweise';
@override
String get showHints => 'Hinweise anzeigen';
@override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@override
String get holdToSync => 'Gedrückt halten zum Sync';
@override
String get swipeToAdjustVolume => 'Wischen für Lautstärke';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class SEn extends S {
SEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Connecting...';
@override
String get connected => 'Connected';
@override
String get disconnected => 'Disconnected';
@override
String get serverAddress => 'Server Address';
@override
String get serverAddressHint => 'e.g., music.example.com or 192.168.1.100';
@override
String get yourName => 'Your Name';
@override
String get yourFirstName => 'Your first name';
@override
String get portOptional => 'Port (Optional)';
@override
String get portHint => 'e.g., 8095 or leave blank';
@override
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
@override
String get username => 'Username';
@override
String get password => 'Password';
@override
String get detectAndConnect => 'Detect & Connect';
@override
String get connect => 'Connect';
@override
String get disconnect => 'Disconnect';
@override
String get authServerUrlOptional => 'Auth Server URL (Optional)';
@override
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
@override
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
@override
String detectedAuthType(String authType) {
return 'Detected: $authType';
}
@override
String get serverRequiresAuth => 'This server requires authentication';
@override
String get tailscaleVpnConnection => 'Tailscale VPN Connection';
@override
String get unencryptedConnection => 'Unencrypted Connection';
@override
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@override
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
@override
String get pleaseEnterName => 'Please enter your name';
@override
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
@override
String get pleaseEnterCredentials => 'Please enter username and password';
@override
String get authFailed => 'Authentication failed. Please check your credentials.';
@override
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
@override
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
@override
String get detectingAuth => 'Detecting authentication...';
@override
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
@override
String get noAuthentication => 'No Authentication';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Music Assistant Login';
@override
String get pressBackToMinimize => 'Press back again to minimize';
@override
String get recentlyPlayed => 'Recently Played';
@override
String get discoverArtists => 'Discover Artists';
@override
String get discoverAlbums => 'Discover Albums';
@override
String get continueListening => 'Continue Listening';
@override
String get discoverAudiobooks => 'Discover Audiobooks';
@override
String get discoverSeries => 'Discover Series';
@override
String get favoriteAlbums => 'Favorite Albums';
@override
String get favoriteArtists => 'Favorite Artists';
@override
String get favoriteTracks => 'Favorite Tracks';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Search music...';
@override
String get searchForContent => 'Search for artists, albums, or tracks';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Retry';
@override
String get all => 'All';
@override
String get artists => 'Artists';
@override
String get albums => 'Albums';
@override
String get tracks => 'Tracks';
@override
String get playlists => 'Playlists';
@override
String get audiobooks => 'Audiobooks';
@override
String get music => 'Music';
@override
String get radio => 'Radio';
@override
String get stations => 'Stations';
@override
String get selectLibrary => 'Select Library';
@override
String get noRadioStations => 'No radio stations';
@override
String get addRadioStationsHint => 'Add radio stations in Music Assistant';
@override
String get searchFailed => 'Search failed. Please check your connection.';
@override
String get queue => 'Queue';
@override
String playerQueue(String playerName) {
return '$playerName Queue';
}
@override
String get noPlayerSelected => 'No player selected';
@override
String get undo => 'Undo';
@override
String removedItem(String itemName) {
return 'Removed $itemName';
}
@override
String get settings => 'Settings';
@override
String get debugLogs => 'Debug Logs';
@override
String get themeMode => 'Theme Mode';
@override
String get light => 'Light';
@override
String get dark => 'Dark';
@override
String get system => 'System';
@override
String get pullToRefresh => 'Pull to refresh the library to apply changes';
@override
String get viewDebugLogs => 'View Debug Logs';
@override
String get viewAllPlayers => 'View All Players';
@override
String get copyLogs => 'Copy Logs';
@override
String get clearLogs => 'Clear Logs';
@override
String get copyList => 'Copy List';
@override
String get close => 'Close';
@override
String allPlayersCount(int count) {
return 'All Players ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Error loading players: $error';
}
@override
String get logsCopied => 'Logs copied to clipboard';
@override
String get logsCleared => 'Logs cleared';
@override
String get playerListCopied => 'Player list copied to clipboard!';
@override
String get noLogsYet => 'No logs yet';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Warnings';
@override
String get errors => 'Errors';
@override
String showingEntries(int filtered, int total) {
return 'Showing $filtered of $total entries';
}
@override
String get thisDevice => 'This device';
@override
String get ghostPlayer => 'Ghost player (duplicate)';
@override
String get unavailableCorrupt => 'Unavailable/Corrupt';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Available: $available | Provider: $provider';
}
@override
String get shareBugReport => 'Share bug report';
@override
String get moreOptions => 'More options';
@override
String failedToUpdateFavorite(String error) {
return 'Failed to update favorite: $error';
}
@override
String get noPlayersAvailable => 'No players available';
@override
String get albumAddedToQueue => 'Album added to queue';
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get play => 'Play';
@override
String get noPlayersFound => 'No players found';
@override
String startingRadio(String name) {
return 'Starting $name radio';
}
@override
String failedToStartRadio(String error) {
return 'Failed to start radio: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Starting $name radio on $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Added $name radio to queue';
}
@override
String failedToAddToQueue(String error) {
return 'Failed to add to queue: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'In Library';
@override
String get noAlbumsFound => 'No albums found';
@override
String playing(String name) {
return 'Playing $name';
}
@override
String get playingTrack => 'Playing track';
@override
String get nowPlaying => 'Now playing';
@override
String markedAsFinished(String name) {
return '$name marked as finished';
}
@override
String failedToMarkFinished(String error) {
return 'Failed to mark as finished: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marked as unplayed';
}
@override
String failedToMarkUnplayed(String error) {
return 'Failed to mark as unplayed: $error';
}
@override
String failedToPlay(String error) {
return 'Failed to play: $error';
}
@override
String get markAsFinished => 'Mark as Finished';
@override
String get markAsUnplayed => 'Mark as Unplayed';
@override
String byAuthor(String author) {
return 'By $author';
}
@override
String audiobookCount(int count) {
return '$count audiobook(s)';
}
@override
String get loading => 'Loading...';
@override
String bookCount(int count) {
return '$count book(s)';
}
@override
String get books => 'Books';
@override
String get noFavoriteAudiobooks => 'No favorite audiobooks';
@override
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
@override
String get noAudiobooks => 'No audiobooks';
@override
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
@override
String get noFavoriteArtists => 'No favorite artists';
@override
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
@override
String get noFavoriteAlbums => 'No favorite albums';
@override
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
@override
String get noFavoritePlaylists => 'No favorite playlists';
@override
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
@override
String get noFavoriteTracks => 'No favorite tracks';
@override
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
@override
String get loadSeries => 'Load Series';
@override
String get notConnected => 'Not connected to Music Assistant';
@override
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connect to your Music Assistant server to start listening';
@override
String get configureServer => 'Configure Server';
@override
String get noArtistsFound => 'No artists found';
@override
String get noTracksFound => 'No tracks found';
@override
String get noPlaylistsFound => 'No playlists found';
@override
String get queueIsEmpty => 'Queue is empty';
@override
String get noResultsFound => 'No results found';
@override
String get refresh => 'Refresh';
@override
String get debugConsole => 'Debug Console';
@override
String get copy => 'Copy';
@override
String get clear => 'Clear';
@override
String get noLogsToCopy => 'No logs to copy';
@override
String get noDebugLogsYet => 'No debug logs yet. Try detecting auth.';
@override
String get showDebug => 'Show Debug';
@override
String get hideDebug => 'Hide Debug';
@override
String get chapters => 'Chapters';
@override
String get noChapters => 'No chapters';
@override
String get noChapterInfo => 'This audiobook has no chapter information';
@override
String errorSeeking(String error) {
return 'Error seeking: $error';
}
@override
String error(String error) {
return 'Error: $error';
}
@override
String get home => 'Home';
@override
String get library => 'Library';
@override
String get homeScreen => 'Home Screen';
@override
String get metadataApis => 'Metadata APIs';
@override
String get theme => 'Theme';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Adaptive Theme';
@override
String get language => 'Language';
@override
String get english => 'English';
@override
String get german => 'German';
@override
String get spanish => 'Spanish';
@override
String get french => 'French';
@override
String get noTracksInPlaylist => 'No tracks in playlist';
@override
String get sortAlphabetically => 'Sort alphabetically';
@override
String get sortByYear => 'Sort by year';
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get listView => 'List view';
@override
String get gridView => 'Grid view';
@override
String get noBooksInSeries => 'No books found in this series';
@override
String get artist => 'Artist';
@override
String get showRecentlyPlayedAlbums => 'Show recently played albums';
@override
String get showRandomArtists => 'Show random artists to discover';
@override
String get showRandomAlbums => 'Show random albums to discover';
@override
String get showAudiobooksInProgress => 'Show audiobooks in progress';
@override
String get showRandomAudiobooks => 'Show random audiobooks to discover';
@override
String get showRandomSeries => 'Show random audiobook series to discover';
@override
String get showFavoriteAlbums => 'Show a row of your favorite albums';
@override
String get showFavoriteArtists => 'Show a row of your favorite artists';
@override
String get showFavoriteTracks => 'Show a row of your favorite tracks';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
@override
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
@override
String get addedToFavorites => 'Added to favorites';
@override
String get removedFromFavorites => 'Removed from favorites';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unknown';
@override
String get noUpcomingTracks => 'No upcoming tracks';
@override
String get showAll => 'Show all';
@override
String get showFavoritesOnly => 'Show favorites only';
@override
String get changeView => 'Change view';
@override
String get authors => 'Authors';
@override
String get series => 'Series';
@override
String get shows => 'Shows';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Podcast support coming soon';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Playlist';
@override
String get connectionError => 'Connection Error';
@override
String get twoColumnGrid => '2-column grid';
@override
String get threeColumnGrid => '3-column grid';
@override
String get fromProviders => 'From Providers';
@override
String get resume => 'Resume';
@override
String get about => 'About';
@override
String get inProgress => 'In progress';
@override
String narratedBy(String narrators) {
return 'Narrated by $narrators';
}
@override
String get unknownNarrator => 'Unknown Narrator';
@override
String get unknownAuthor => 'Unknown Author';
@override
String get loadingChapters => 'Loading chapters...';
@override
String get noChapterInfoAvailable => 'No chapter information available';
@override
String percentComplete(int percent) {
return '$percent% complete';
}
@override
String get theAudioDbApiKey => 'TheAudioDB API Key';
@override
String get theAudioDbApiKeyHint => 'Use \"2\" for free tier or premium key';
@override
String get audiobookLibraries => 'Audiobook Libraries';
@override
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
@override
String get unknownLibrary => 'Unknown Library';
@override
String get noSeriesFound => 'No Series Found';
@override
String get ensembleBugReport => 'Ensemble Bug Report';
@override
String byOwner(String owner) {
return 'By $owner';
}
@override
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
@override
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
@override
String get search => 'Search';
@override
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override
String get lastFmApiKey => 'Last.fm API Key';
@override
String get lastFmApiKeyHint => 'Get free key at last.fm/api';
@override
String get swipeToSwitchDevice => 'Swipe to change player';
@override
String chapterNumber(int number) {
return 'Chapter $number';
}
@override
String get pcmAudio => 'PCM Audio';
@override
String get playOn => 'Play on...';
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
}
@override
String get book => 'book';
@override
String get audiobookSingular => 'audiobook';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Use system colors (Android 12+)';
@override
String get accentColor => 'Accent Color';
@override
String get players => 'Players';
@override
String get preferLocalPlayer => 'Prefer Local Player';
@override
String get preferLocalPlayerDescription => 'Always select this device first when available';
@override
String get smartSortPlayers => 'Smart Sort';
@override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
@override
String get playerStateUnavailable => 'Unavailable';
@override
String get playerStateOff => 'Off';
@override
String get playerStateIdle => 'Idle';
@override
String get playerStateExternalSource => 'External Source';
@override
String get playerSelected => 'Selected';
@override
String get actionQueuedForSync => 'Will sync when online';
@override
String pendingOfflineActions(int count) {
return '$count pending sync';
}
@override
String get hintsAndTips => 'Hints & Tips';
@override
String get showHints => 'Show Hints';
@override
String get showHintsDescription => 'Display helpful tips for discovering features';
@override
String get pullToSelectPlayers => 'Pull to select players';
@override
String get holdToSync => 'Long-press to sync';
@override
String get swipeToAdjustVolume => 'Swipe to adjust volume';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Spanish Castilian (`es`).
class SEs extends S {
SEs([String locale = 'es']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Conectando...';
@override
String get connected => 'Conectado';
@override
String get disconnected => 'Desconectado';
@override
String get serverAddress => 'Dirección del servidor';
@override
String get serverAddressHint => 'ej., music.example.com o 192.168.1.100';
@override
String get yourName => 'Tu nombre';
@override
String get yourFirstName => 'Tu nombre de pila';
@override
String get portOptional => 'Puerto (Opcional)';
@override
String get portHint => 'ej., 8095 o dejar en blanco';
@override
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override
String get username => 'Usuario';
@override
String get password => 'Contraseña';
@override
String get detectAndConnect => 'Detectar y conectar';
@override
String get connect => 'Conectar';
@override
String get disconnect => 'Desconectar';
@override
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
@override
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
@override
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
@override
String detectedAuthType(String authType) {
return 'Detectado: $authType';
}
@override
String get serverRequiresAuth => 'Este servidor requiere autenticación';
@override
String get tailscaleVpnConnection => 'Conexión VPN Tailscale';
@override
String get unencryptedConnection => 'Conexión sin cifrar';
@override
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
@override
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
@override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
@override
String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
@override
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
@override
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
@override
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override
String get detectingAuth => 'Detectando autenticación...';
@override
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override
String get noAuthentication => 'Sin autenticación';
@override
String get httpBasicAuth => 'Autenticación HTTP Basic';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Inicio de sesión de Music Assistant';
@override
String get pressBackToMinimize => 'Presiona atrás de nuevo para minimizar';
@override
String get recentlyPlayed => 'Reproducido recientemente';
@override
String get discoverArtists => 'Descubrir artistas';
@override
String get discoverAlbums => 'Descubrir álbumes';
@override
String get continueListening => 'Continuar escuchando';
@override
String get discoverAudiobooks => 'Descubrir audiolibros';
@override
String get discoverSeries => 'Descubrir series';
@override
String get favoriteAlbums => 'Álbumes favoritos';
@override
String get favoriteArtists => 'Artistas favoritos';
@override
String get favoriteTracks => 'Canciones favoritas';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Buscar música...';
@override
String get searchForContent => 'Buscar artistas, álbumes o canciones';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Reintentar';
@override
String get all => 'Todo';
@override
String get artists => 'Artistas';
@override
String get albums => 'Álbumes';
@override
String get tracks => 'Canciones';
@override
String get playlists => 'Listas de reproducción';
@override
String get audiobooks => 'Audiolibros';
@override
String get music => 'Música';
@override
String get radio => 'Radio';
@override
String get stations => 'Emisoras';
@override
String get selectLibrary => 'Seleccionar Biblioteca';
@override
String get noRadioStations => 'No hay emisoras de radio';
@override
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
@override
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
@override
String get queue => 'Cola';
@override
String playerQueue(String playerName) {
return 'Cola de $playerName';
}
@override
String get noPlayerSelected => 'Ningún reproductor seleccionado';
@override
String get undo => 'Deshacer';
@override
String removedItem(String itemName) {
return 'Eliminado $itemName';
}
@override
String get settings => 'Ajustes';
@override
String get debugLogs => 'Registros de depuración';
@override
String get themeMode => 'Modo de tema';
@override
String get light => 'Claro';
@override
String get dark => 'Oscuro';
@override
String get system => 'Sistema';
@override
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
@override
String get viewDebugLogs => 'Ver registros de depuración';
@override
String get viewAllPlayers => 'Ver todos los reproductores';
@override
String get copyLogs => 'Copiar registros';
@override
String get clearLogs => 'Borrar registros';
@override
String get copyList => 'Copiar lista';
@override
String get close => 'Cerrar';
@override
String allPlayersCount(int count) {
return 'Todos los reproductores ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Error al cargar reproductores: $error';
}
@override
String get logsCopied => 'Registros copiados al portapapeles';
@override
String get logsCleared => 'Registros borrados';
@override
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
@override
String get noLogsYet => 'Aún no hay registros';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Advertencias';
@override
String get errors => 'Errores';
@override
String showingEntries(int filtered, int total) {
return 'Mostrando $filtered de $total entradas';
}
@override
String get thisDevice => 'Este dispositivo';
@override
String get ghostPlayer => 'Reproductor fantasma (duplicado)';
@override
String get unavailableCorrupt => 'No disponible/Corrupto';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Disponible: $available | Proveedor: $provider';
}
@override
String get shareBugReport => 'Compartir informe de errores';
@override
String get moreOptions => 'Más opciones';
@override
String failedToUpdateFavorite(String error) {
return 'Error al actualizar favorito: $error';
}
@override
String get noPlayersAvailable => 'No hay reproductores disponibles';
@override
String get albumAddedToQueue => 'Álbum añadido a la cola';
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get play => 'Reproducir';
@override
String get noPlayersFound => 'No se encontraron reproductores';
@override
String startingRadio(String name) {
return 'Iniciando radio de $name';
}
@override
String failedToStartRadio(String error) {
return 'Error al iniciar radio: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Iniciando radio de $name en $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Radio de $name añadida a la cola';
}
@override
String failedToAddToQueue(String error) {
return 'Error al añadir a la cola: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'En la biblioteca';
@override
String get noAlbumsFound => 'No se encontraron álbumes';
@override
String playing(String name) {
return 'Reproduciendo $name';
}
@override
String get playingTrack => 'Reproduciendo canción';
@override
String get nowPlaying => 'Reproduciendo ahora';
@override
String markedAsFinished(String name) {
return '$name marcado como terminado';
}
@override
String failedToMarkFinished(String error) {
return 'Error al marcar como terminado: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marcado como no reproducido';
}
@override
String failedToMarkUnplayed(String error) {
return 'Error al marcar como no reproducido: $error';
}
@override
String failedToPlay(String error) {
return 'Error al reproducir: $error';
}
@override
String get markAsFinished => 'Marcar como terminado';
@override
String get markAsUnplayed => 'Marcar como no reproducido';
@override
String byAuthor(String author) {
return 'Por $author';
}
@override
String audiobookCount(int count) {
return '$count audiolibro(s)';
}
@override
String get loading => 'Cargando...';
@override
String bookCount(int count) {
return '$count libro(s)';
}
@override
String get books => 'Libros';
@override
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@override
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
@override
String get noAudiobooks => 'No hay audiolibros';
@override
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
@override
String get noFavoriteArtists => 'No hay artistas favoritos';
@override
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
@override
String get noFavoriteAlbums => 'No hay álbumes favoritos';
@override
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
@override
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
@override
String get noFavoriteTracks => 'No hay canciones favoritas';
@override
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override
String get loadSeries => 'Cargar series';
@override
String get notConnected => 'No conectado a Music Assistant';
@override
String get notConnectedTitle => 'No conectado';
@override
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
@override
String get configureServer => 'Configurar servidor';
@override
String get noArtistsFound => 'No se encontraron artistas';
@override
String get noTracksFound => 'No se encontraron canciones';
@override
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get queueIsEmpty => 'La cola está vacía';
@override
String get noResultsFound => 'No se encontraron resultados';
@override
String get refresh => 'Actualizar';
@override
String get debugConsole => 'Consola de depuración';
@override
String get copy => 'Copiar';
@override
String get clear => 'Borrar';
@override
String get noLogsToCopy => 'No hay registros para copiar';
@override
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
@override
String get showDebug => 'Mostrar depuración';
@override
String get hideDebug => 'Ocultar depuración';
@override
String get chapters => 'Capítulos';
@override
String get noChapters => 'Sin capítulos';
@override
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
@override
String errorSeeking(String error) {
return 'Error al buscar: $error';
}
@override
String error(String error) {
return 'Error: $error';
}
@override
String get home => 'Inicio';
@override
String get library => 'Biblioteca';
@override
String get homeScreen => 'Pantalla de inicio';
@override
String get metadataApis => 'APIs de metadatos';
@override
String get theme => 'Tema';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Tema adaptativo';
@override
String get language => 'Idioma';
@override
String get english => 'Inglés';
@override
String get german => 'Alemán';
@override
String get spanish => 'Español';
@override
String get french => 'Francés';
@override
String get noTracksInPlaylist => 'No hay canciones en la lista';
@override
String get sortAlphabetically => 'Ordenar alfabéticamente';
@override
String get sortByYear => 'Ordenar por año';
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get listView => 'Vista de lista';
@override
String get gridView => 'Vista de cuadrícula';
@override
String get noBooksInSeries => 'No se encontraron libros en esta serie';
@override
String get artist => 'Artista';
@override
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
@override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
@override
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
@override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
@override
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
@override
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
@override
String get addedToFavorites => 'Añadido a favoritos';
@override
String get removedFromFavorites => 'Eliminado de favoritos';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Desconocido';
@override
String get noUpcomingTracks => 'No hay canciones próximas';
@override
String get showAll => 'Mostrar todo';
@override
String get showFavoritesOnly => 'Mostrar solo favoritos';
@override
String get changeView => 'Cambiar vista';
@override
String get authors => 'Autores';
@override
String get series => 'Series';
@override
String get shows => 'Programas';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Soporte de podcasts próximamente';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Lista de reproducción';
@override
String get connectionError => 'Error de conexión';
@override
String get twoColumnGrid => 'Cuadrícula de 2 columnas';
@override
String get threeColumnGrid => 'Cuadrícula de 3 columnas';
@override
String get fromProviders => 'De proveedores';
@override
String get resume => 'Continuar';
@override
String get about => 'Acerca de';
@override
String get inProgress => 'En progreso';
@override
String narratedBy(String narrators) {
return 'Narrado por $narrators';
}
@override
String get unknownNarrator => 'Narrador desconocido';
@override
String get unknownAuthor => 'Autor desconocido';
@override
String get loadingChapters => 'Cargando capítulos...';
@override
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
@override
String percentComplete(int percent) {
return '$percent% completado';
}
@override
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
@override
String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
@override
String get unknownLibrary => 'Biblioteca desconocida';
@override
String get noSeriesFound => 'No se encontraron series';
@override
String get ensembleBugReport => 'Informe de errores de Ensemble';
@override
String byOwner(String owner) {
return 'Por $owner';
}
@override
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
@override
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
@override
String get search => 'Buscar';
@override
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override
String get lastFmApiKey => 'Clave API de Last.fm';
@override
String get lastFmApiKeyHint => 'Obtén una clave gratuita en last.fm/api';
@override
String get swipeToSwitchDevice => 'Desliza para cambiar dispositivo';
@override
String chapterNumber(int number) {
return 'Capítulo $number';
}
@override
String get pcmAudio => 'Audio PCM';
@override
String get playOn => 'Reproducir en...';
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
}
@override
String get book => 'libro';
@override
String get audiobookSingular => 'audiolibro';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'canciones',
one: 'canción',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Usar colores del sistema (Android 12+)';
@override
String get accentColor => 'Color de acento';
@override
String get players => 'Dispositivos';
@override
String get preferLocalPlayer => 'Preferir dispositivo local';
@override
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override
String get smartSortPlayers => 'Orden inteligente';
@override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get playerStateUnavailable => 'No disponible';
@override
String get playerStateOff => 'Apagado';
@override
String get playerStateIdle => 'Inactivo';
@override
String get playerStateExternalSource => 'Fuente Externa';
@override
String get playerSelected => 'Seleccionado';
@override
String get actionQueuedForSync => 'Se sincronizará cuando esté en línea';
@override
String pendingOfflineActions(int count) {
return '$count sincronización pendiente';
}
@override
String get hintsAndTips => 'Consejos y Sugerencias';
@override
String get showHints => 'Mostrar Sugerencias';
@override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@override
String get holdToSync => 'Mantén presionado para sync';
@override
String get swipeToAdjustVolume => 'Desliza para ajustar volumen';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for French (`fr`).
class SFr extends S {
SFr([String locale = 'fr']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Connexion...';
@override
String get connected => 'Connecté';
@override
String get disconnected => 'Déconnecté';
@override
String get serverAddress => 'Adresse du serveur';
@override
String get serverAddressHint => 'ex., music.example.com ou 192.168.1.100';
@override
String get yourName => 'Votre nom';
@override
String get yourFirstName => 'Votre prénom';
@override
String get portOptional => 'Port (Facultatif)';
@override
String get portHint => 'ex., 8095 ou laisser vide';
@override
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override
String get username => 'Nom d\'utilisateur';
@override
String get password => 'Mot de passe';
@override
String get detectAndConnect => 'Détecter et connecter';
@override
String get connect => 'Connectér';
@override
String get disconnect => 'Déconnectér';
@override
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
@override
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
@override
String detectedAuthType(String authType) {
return 'Detected: $authType';
}
@override
String get serverRequiresAuth => 'Ce serveur requiert une authentification';
@override
String get tailscaleVpnConnection => 'Connexion VPN Tailscale';
@override
String get unencryptedConnection => 'Connexion non chiffrée';
@override
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
@override
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
@override
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
@override
String get pleaseEnterName => 'Veuillez entrer votre nom';
@override
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
@override
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
@override
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override
String get detectingAuth => 'Detection de l\'authentification...';
@override
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override
String get noAuthentication => 'Pas d\'authentification';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Connexion Music Assistant';
@override
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
@override
String get recentlyPlayed => 'Ecoutes recemment';
@override
String get discoverArtists => 'Découvrir des artistes';
@override
String get discoverAlbums => 'Découvrir des albums';
@override
String get continueListening => 'Continuer l\'écoute';
@override
String get discoverAudiobooks => 'Découvrir des livres audio';
@override
String get discoverSeries => 'Découvrir des séries';
@override
String get favoriteAlbums => 'Albums favoris';
@override
String get favoriteArtists => 'Artistes favoris';
@override
String get favoriteTracks => 'Pistes favorites';
@override
String get favoritePlaylists => 'Playlists favorites';
@override
String get favoriteRadioStations => 'Stations de radio favorites';
@override
String get favoritePodcasts => 'Podcasts favoris';
@override
String get searchMusic => 'Rechercher de la musique...';
@override
String get searchForContent => 'Rechercher des artistes, albums ou pistes';
@override
String get recentSearches => 'Recherches recentes';
@override
String get clearSearchHistory => 'Effacer l\'historique de recherche';
@override
String get searchHistoryCleared => 'Historique de recherche efface';
@override
String get libraryOnly => 'Bibliothèque uniquement';
@override
String get retry => 'Reessayer';
@override
String get all => 'Tout';
@override
String get artists => 'Artistes';
@override
String get albums => 'Albums';
@override
String get tracks => 'Pistes';
@override
String get playlists => 'Playlists';
@override
String get audiobooks => 'Livres audio';
@override
String get music => 'Musique';
@override
String get radio => 'Radio';
@override
String get stations => 'Stations';
@override
String get selectLibrary => 'Sélectionner la bibliothèque';
@override
String get noRadioStations => 'Aucune station de radio';
@override
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
@override
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
@override
String get queue => 'File d\'attente';
@override
String playerQueue(String playerName) {
return 'File d\'attente de $playerName';
}
@override
String get noPlayerSelected => 'Aucun lecteur selectionne';
@override
String get undo => 'Annuler';
@override
String removedItem(String itemName) {
return '$itemName supprimé';
}
@override
String get settings => 'Paramètres';
@override
String get debugLogs => 'Journaux de debogage';
@override
String get themeMode => 'Mode de theme';
@override
String get light => 'Clair';
@override
String get dark => 'Sombre';
@override
String get system => 'Système';
@override
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
@override
String get viewDebugLogs => 'Voir les journaux de debogage';
@override
String get viewAllPlayers => 'Voir tous les lecteurs';
@override
String get copyLogs => 'Copier les journaux';
@override
String get clearLogs => 'Effacer les journaux';
@override
String get copyList => 'Copier la liste';
@override
String get close => 'Fermer';
@override
String allPlayersCount(int count) {
return 'Tous les lecteurs ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Erreur lors du chargement des lecteurs : $error';
}
@override
String get logsCopied => 'Journaux copies dans le presse-papiers';
@override
String get logsCleared => 'Journaux effaces';
@override
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
@override
String get noLogsYet => 'Pas encore de journaux';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Avertissements';
@override
String get errors => 'Erreurs';
@override
String showingEntries(int filtered, int total) {
return 'Affichage de $filtered sur $total entrées';
}
@override
String get thisDevice => 'Cet appareil';
@override
String get ghostPlayer => 'Lecteur fantome (doublon)';
@override
String get unavailableCorrupt => 'Indisponible/Corrompu';
@override
String playerId(String id) {
return 'ID : $id';
}
@override
String playerInfo(String available, String provider) {
return 'Disponible : $available | Fournisseur : $provider';
}
@override
String get shareBugReport => 'Partager le rapport de bogue';
@override
String get moreOptions => 'Plus d\'options';
@override
String failedToUpdateFavorite(String error) {
return 'Echec de la mise a jour du favori : $error';
}
@override
String get noPlayersAvailable => 'Aucun lecteur disponible';
@override
String get albumAddedToQueue => 'Album ajouté a la file d\'attente';
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get play => 'Lire';
@override
String get noPlayersFound => 'Aucun lecteur trouve';
@override
String startingRadio(String name) {
return 'Demarrage de la radio $name';
}
@override
String failedToStartRadio(String error) {
return 'Echec du demarrage de la radio : $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Demarrage de la radio $name sur $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Radio $name ajoutée a la file d\'attente';
}
@override
String failedToAddToQueue(String error) {
return 'Echec de l\'ajout a la file d\'attente : $error';
}
@override
String playingRadioStation(String name) {
return 'Lecture de $name';
}
@override
String get addedToQueue => 'Ajoute a la file d\'attente';
@override
String get inLibrary => 'Dans la bibliothèque';
@override
String get noAlbumsFound => 'Aucun album trouve';
@override
String playing(String name) {
return 'Lecture de $name';
}
@override
String get playingTrack => 'Lecture de la piste';
@override
String get nowPlaying => 'En cours de lecture';
@override
String markedAsFinished(String name) {
return '$name marque comme termine';
}
@override
String failedToMarkFinished(String error) {
return 'Echec du marquage comme termine : $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marque comme non lu';
}
@override
String failedToMarkUnplayed(String error) {
return 'Echec du marquage comme non lu : $error';
}
@override
String failedToPlay(String error) {
return 'Echec de la lecture : $error';
}
@override
String get markAsFinished => 'Marquer comme termine';
@override
String get markAsUnplayed => 'Marquer comme non lu';
@override
String byAuthor(String author) {
return 'Par $author';
}
@override
String audiobookCount(int count) {
return '$count livre(s) audio';
}
@override
String get loading => 'Chargement...';
@override
String bookCount(int count) {
return '$count livre(s)';
}
@override
String get books => 'Livres';
@override
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override
String get noAudiobooks => 'Aucun livre audio';
@override
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override
String get noFavoriteArtists => 'Aucun artiste favori';
@override
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override
String get noFavoriteAlbums => 'Aucun album favori';
@override
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override
String get noFavoritePlaylists => 'Aucune playlist favorite';
@override
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override
String get noFavoriteTracks => 'Aucune piste favorite';
@override
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override
String get loadSeries => 'Charger les séries';
@override
String get notConnected => 'Not connected to Music Assistant';
@override
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
@override
String get configureServer => 'Configurer le serveur';
@override
String get noArtistsFound => 'Aucun artiste trouve';
@override
String get noTracksFound => 'Aucune piste trouvee';
@override
String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get queueIsEmpty => 'La file d\'attente est vide';
@override
String get noResultsFound => 'Aucun resultat trouve';
@override
String get refresh => 'Rafraichir';
@override
String get debugConsole => 'Console de debogage';
@override
String get copy => 'Copier';
@override
String get clear => 'Effacer';
@override
String get noLogsToCopy => 'Aucun journal a copier';
@override
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
@override
String get showDebug => 'Afficher le debogage';
@override
String get hideDebug => 'Masquer le debogage';
@override
String get chapters => 'Chapitres';
@override
String get noChapters => 'Aucun chapitre';
@override
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
@override
String errorSeeking(String error) {
return 'Erreur de positionnement : $error';
}
@override
String error(String error) {
return 'Erreur : $error';
}
@override
String get home => 'Accueil';
@override
String get library => 'Bibliothèque';
@override
String get homeScreen => 'Ecran d\'accueil';
@override
String get metadataApis => 'API de metadonnees';
@override
String get theme => 'Theme';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Theme adaptatif';
@override
String get language => 'Langue';
@override
String get english => 'Anglais';
@override
String get german => 'Allemand';
@override
String get spanish => 'Espagnol';
@override
String get french => 'Français';
@override
String get noTracksInPlaylist => 'Aucune piste dans la playlist';
@override
String get sortAlphabetically => 'Trier alphabétiquement';
@override
String get sortByYear => 'Trier par année';
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get listView => 'Vue en liste';
@override
String get gridView => 'Vue en grille';
@override
String get noBooksInSeries => 'Aucun livre trouve dans cette série';
@override
String get artist => 'Artiste';
@override
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
@override
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@override
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
@override
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
@override
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
@override
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
@override
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
@override
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
@override
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
@override
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
@override
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@override
String get removedFromFavorites => 'Retire des favoris';
@override
String get addedToLibrary => 'Ajoute a la bibliothèque';
@override
String get removedFromLibrary => 'Retire de la bibliothèque';
@override
String get addToLibrary => 'Ajouter a la bibliothèque';
@override
String get unknown => 'Inconnu';
@override
String get noUpcomingTracks => 'Aucune piste a venir';
@override
String get showAll => 'Tout afficher';
@override
String get showFavoritesOnly => 'Afficher les favoris uniquement';
@override
String get changeView => 'Changer la vue';
@override
String get authors => 'Auteurs';
@override
String get series => 'Series';
@override
String get shows => 'Emissions';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
@override
String get noPodcasts => 'Aucun podcast';
@override
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Playlist';
@override
String get connectionError => 'Erreur de connexion';
@override
String get twoColumnGrid => 'Grille a 2 colonnes';
@override
String get threeColumnGrid => 'Grille a 3 colonnes';
@override
String get fromProviders => 'Des fournisseurs';
@override
String get resume => 'Reprendre';
@override
String get about => 'A propos';
@override
String get inProgress => 'En cours';
@override
String narratedBy(String narrators) {
return 'Lu par $narrators';
}
@override
String get unknownNarrator => 'Narrateur inconnu';
@override
String get unknownAuthor => 'Auteur inconnu';
@override
String get loadingChapters => 'Chargement des chapitres...';
@override
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
@override
String percentComplete(int percent) {
return '$percent% termine';
}
@override
String get theAudioDbApiKey => 'Cle API TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
@override
String get audiobookLibraries => 'Bibliothèques de livres audio';
@override
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
@override
String get unknownLibrary => 'Bibliothèque inconnue';
@override
String get noSeriesFound => 'Aucune série trouvee';
@override
String get ensembleBugReport => 'Rapport de bogue Ensemble';
@override
String byOwner(String owner) {
return 'Par $owner';
}
@override
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
@override
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
@override
String get search => 'Rechercher';
@override
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override
String get lastFmApiKey => 'Cle API Last.fm';
@override
String get lastFmApiKeyHint => 'Obtenez une cle gratuite sur last.fm/api';
@override
String get swipeToSwitchDevice => 'Balayez pour changer de lecteur';
@override
String chapterNumber(int number) {
return 'Chapitre $number';
}
@override
String get pcmAudio => 'Audio PCM';
@override
String get playOn => 'Lire sur...';
@override
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
}
@override
String get book => 'livre';
@override
String get audiobookSingular => 'livre audio';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Piste';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count pistes',
one: '$count piste',
);
return '$_temp0';
}
@override
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
@override
String get accentColor => 'Couleur d\'accentuation';
@override
String get players => 'Lecteurs';
@override
String get preferLocalPlayer => 'Préférer le lecteur local';
@override
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
@override
String get smartSortPlayers => 'Tri intelligent';
@override
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override
String get playerStateUnavailable => 'Indisponible';
@override
String get playerStateOff => 'Eteint';
@override
String get playerStateIdle => 'Inactif';
@override
String get playerStateExternalSource => 'Source externe';
@override
String get playerSelected => 'Selectionne';
@override
String get actionQueuedForSync => 'Sera synchronise une fois en ligne';
@override
String pendingOfflineActions(int count) {
return '$count en attente de synchronisation';
}
@override
String get hintsAndTips => 'Astuces et conseils';
@override
String get showHints => 'Afficher les astuces';
@override
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@override
String get holdToSync => 'Appui long pour synchroniser';
@override
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@override
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
@override
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override
String get skip => 'Passer';
@override
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override
String playingAlbum(String albumName) {
return 'Lecture de $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Lecture de $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+28 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
@@ -84,7 +89,29 @@ Future<void> main() async {
),
);
runApp(const MusicAssistantApp());
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
// Wrap app in zone to catch async errors
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
},
);
}
class MusicAssistantApp extends StatefulWidget {
-258
View File
@@ -1,258 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server
/// Handles WebSocket connection, authentication, and reconnection
class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
String? _error;
// Callbacks for post-connection initialization
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService;
ConnectionProvider() {
_initialize();
}
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
/// Restore auth credentials from persistent storage
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
} else {
_logger.log('🔐 No saved auth credentials found');
}
}
/// Handle Music Assistant native authentication after WebSocket connection
Future<bool> handleMaAuthentication() async {
if (_api == null) return false;
try {
// First, try stored MA token
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
// No valid token - try stored credentials
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
/// Fetch user profile from MA and set owner name + create/activate local profile
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) {
_logger.log('⚠️ Could not fetch user profile');
return;
}
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
final profileName = (displayName != null && displayName.isNotEmpty)
? displayName
: username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
// Create/activate profile in local database
if (DatabaseService.instance.isInitialized && username != null) {
await ProfileService.instance.onMaAuthenticated(
username: username,
displayName: displayName,
);
_logger.log('✅ Local profile activated: $username');
}
} else {
_logger.log('⚠️ No valid name in profile');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
}
}
Future<void> connectToServer(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection
await _api?.disconnect();
_api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes
_api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication();
if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required - notify connected
onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, connection verified');
}
}
@override
void dispose() {
_api?.dispose();
super.dispose();
}
}
-392
View File
@@ -1,392 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
class LibraryProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService;
MusicAssistantAPI? _api;
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
bool _isLoading = false;
String? _error;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Getters
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
bool get isLoading => _isLoading;
String? get error => _error;
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
LibraryProvider(this._cacheService);
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api) {
_api = api;
}
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
}
// ============================================================================
// HOME SCREEN DATA
// ============================================================================
/// Get recently played albums with caching
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!;
}
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh recent albums...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
return _cacheService.getCachedRecentAlbums() ?? [];
}
}
/// Get discover artists (random) with caching
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
}
}
/// Get discover albums (random) with caching
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
}
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_cacheService.invalidateHomeCache();
}
// ============================================================================
// LIBRARY DATA
// ============================================================================
Future<void> loadLibrary() async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
_artists = results[0] as List<Artist>;
_albums = results[1] as List<Album>;
_tracks = results[2] as List<Track>;
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
// ============================================================================
// DETAIL SCREEN DATA
// ============================================================================
/// Get album tracks with caching
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
/// Get playlist tracks with caching
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
/// Get artist albums with caching
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
final libraryAlbums = await _api!.getAlbums();
final artistAlbums = libraryAlbums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH
// ============================================================================
/// Search with caching
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
final cacheKey = query.toLowerCase().trim();
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query"');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
try {
_logger.log('🔄 Searching for "$query"...');
final results = await _api!.search(query);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
void clearState() {
_artists = [];
_albums = [];
_tracks = [];
_isLoading = false;
_error = null;
clearSearchState();
notifyListeners();
}
}
@@ -501,6 +501,12 @@ class MusicAssistantProvider with ChangeNotifier {
return getImageUrl(track, size: size);
}
/// Clear cached track for a player (e.g., after queue transfer)
void clearPlayerTrackCache(String playerId) {
_cacheService.clearCachedTrackForPlayer(playerId);
notifyListeners();
}
/// Number of pending offline actions
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
-964
View File
@@ -1,964 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/local_player_service.dart';
import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
/// Provider for managing player state, selection, and controls
class PlayerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
late final LocalPlayerService _localPlayer;
final CacheService _cacheService;
// Dependencies injected from parent provider
MusicAssistantAPI? _api;
AuthManager? _authManager;
String? _serverUrl;
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Track? _currentTrack;
Timer? _playerStateTimer;
Timer? _adjacentPlayerCacheTimer;
// Local Playback
bool _isLocalPlayerPowered = true;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
Timer? _localPlayerStateReportTimer;
// Pending metadata from player_updated events
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
// Registration guard
Completer<void>? _registrationInProgress;
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
PlayerProvider(this._cacheService, AuthManager authManager) {
_authManager = authManager;
_localPlayer = LocalPlayerService(authManager);
}
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api, String serverUrl) {
_api = api;
_serverUrl = serverUrl;
}
/// Set up event listeners after connection
Future<void> setupEventListeners() async {
if (_api == null) return;
// Listen to built-in player events
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) {
_logger.log('Builtin player event stream error: $error');
},
);
// Listen to player_updated events
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) {
_logger.log('Player updated event stream error: $error');
},
);
}
/// Initialize local playback
Future<void> initializeLocalPlayback() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up skip button callbacks
audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
nextTrackSelectedPlayer();
};
audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
previousTrackSelectedPlayer();
};
if (_api != null) {
await registerLocalPlayer();
}
}
/// Try to adopt an existing ghost player
Future<bool> tryAdoptGhostPlayer() async {
if (_api == null) return false;
try {
final isFresh = await DeviceIdService.isFreshInstallation();
if (!isFresh) {
_logger.log('👻 Not a fresh install, skipping ghost adoption');
return false;
}
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) {
_logger.log('👻 No owner name set, cannot adopt ghost player');
return false;
}
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
if (adoptableId == null) {
_logger.log('👻 No matching ghost player found - will generate new ID');
return false;
}
_logger.log('👻 Found adoptable ghost: $adoptableId');
await DeviceIdService.adoptPlayerId(adoptableId);
_logger.log('✅ Successfully adopted ghost player');
return true;
} catch (e) {
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
return false;
}
}
/// Register local player with MA server
Future<void> registerLocalPlayer() async {
if (_api == null) return;
if (_registrationInProgress != null) {
_logger.log('⏳ Registration already in progress, waiting...');
return _registrationInProgress!.future;
}
_registrationInProgress = Completer<void>();
try {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('🆔 Using player ID: $playerId');
await SettingsService.setBuiltinPlayerId(playerId);
final name = await SettingsService.getLocalPlayerName();
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
if (existingPlayer != null && existingPlayer.available) {
_logger.log('✅ Player already registered and available: $playerId');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
} else if (existingPlayer != null && !existingPlayer.available) {
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
} else {
_logger.log('🆔 Player not found in MA, registering as new');
}
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
await _api!.registerBuiltinPlayer(playerId, name);
_logger.log('✅ Player registration complete');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
} catch (e) {
_logger.log('❌ CRITICAL: Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
_registrationInProgress = null;
rethrow;
}
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
try {
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error reporting local player state (will retry): $e');
}
});
}
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
// Don't try to report state if not authenticated - avoids spamming errors
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round();
final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState(
playerId,
powered: _isLocalPlayerPowered,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayer.volume == 0.0,
);
}
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
try {
final eventPlayerId = event['player_id'] as String?;
final myPlayerId = await SettingsService.getBuiltinPlayerId();
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
return;
}
final command = (event['type'] as String?) ?? (event['command'] as String?);
switch (command) {
case 'play_media':
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
if (urlPath != null && _serverUrl != null) {
String fullUrl;
if (urlPath.startsWith('http')) {
fullUrl = urlPath;
} else {
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
fullUrl = '$baseUrl$path';
}
TrackMetadata metadata;
if (_pendingTrackMetadata != null) {
metadata = _pendingTrackMetadata!;
} else {
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
final albumName = event['album_name'] as String? ?? event['album'] as String?;
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
final durationSecs = event['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
metadata = TrackMetadata(
title: trackName,
artist: artistName,
album: albumName,
artworkUrl: artworkUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
}
_localPlayer.setCurrentTrackMetadata(metadata);
_currentNotificationMetadata = metadata;
await _localPlayer.playUrl(fullUrl);
}
break;
case 'stop':
await _localPlayer.stop();
break;
case 'pause':
await _localPlayer.pause();
break;
case 'play':
await _localPlayer.play();
break;
case 'seek':
final position = event['position'] as int?;
if (position != null) {
await _localPlayer.seek(Duration(seconds: position));
}
break;
case 'volume_set':
final volume = event['volume_level'] as int?;
if (volume != null) {
await _localPlayer.setVolume(volume / 100.0);
}
break;
case 'power_on':
case 'power_off':
case 'power':
bool? newPowerState;
if (command == 'power_on') {
newPowerState = true;
} else if (command == 'power_off') {
newPowerState = false;
} else {
newPowerState = event['powered'] as bool?;
}
if (newPowerState != null) {
_isLocalPlayerPowered = newPowerState;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
}
break;
}
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error handling local player event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
if (playerId == null) return;
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_updatePlayerState();
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?;
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
final imageUrl = currentMedia['image_url'] as String?;
Map<String, dynamic>? metadata;
if (imageUrl != null && _serverUrl != null) {
var finalImageUrl = imageUrl;
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
finalImageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
// Use original URL
}
metadata = {
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
};
}
final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track',
uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_currentTrack = trackFromEvent;
}
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
if (imageUrl != null) {
final images = metadata?['images'] as List?;
if (images != null && images.isNotEmpty) {
final imagePath = images[0]['path'] as String?;
if (imagePath != null) {
_precacheImage(imagePath);
}
}
}
notifyListeners();
}
}
// Handle notification metadata for local player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
if (currentMedia == null) return;
final title = currentMedia['title'] as String? ?? 'Unknown Track';
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
if (imageUrl != null && _serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
imageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
if (imageUrl != null && imageUrl.startsWith('http://')) {
imageUrl = imageUrl.replaceFirst('http://', 'https://');
}
}
}
final newMetadata = TrackMetadata(
title: title,
artist: artist,
album: album,
artworkUrl: imageUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
final mediaType = currentMedia['media_type'] as String?;
if (mediaType == 'flow_stream') return;
_pendingTrackMetadata = newMetadata;
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
(_currentNotificationMetadata!.title != title ||
_currentNotificationMetadata!.artist != artist);
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
_currentNotificationMetadata = newMetadata;
}
} catch (e) {
_logger.log('Error handling player_updated event: $e');
}
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
if (_api == null) return;
try {
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
return;
}
final allPlayers = await _api!.getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
filteredCount++;
return false;
}
if (player.playerId.startsWith('ensemble_')) {
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
filteredCount++;
return false;
}
}
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched();
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
if (_selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Priority 1: Last selected player
if (lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
}
// Priority 2: Built-in (local) player
if (playerToSelect == null && builtinPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId && p.available,
orElse: () => null,
);
}
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
orElse: () => null,
);
}
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
}
}
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
void selectPlayer(Player player, {bool skipNotify = false}) {
_selectedPlayer = player;
SettingsService.setLastSelectedPlayerId(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
if (!skipNotify) {
notifyListeners();
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
if (players.isEmpty) return;
if (preloadAll) {
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
_logger.log('🖼️ Preloading complete');
return;
}
if (_selectedPlayer == null || players.length <= 1) return;
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
if (currentIndex == -1) return;
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
final playersToPreload = <Player>{};
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
}
Future<void> _preloadPlayerTrack(Player player) async {
if (_api == null) return;
try {
if (!player.available || !player.powered) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
final queue = await _api!.getQueue(player.playerId);
if (queue != null && queue.currentItem != null) {
final track = queue.currentItem!.track;
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = track.metadata?['images'] != null;
if (!existingHasImage || newHasImage) {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
}
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
if (artworkUrl512 != null) {
await _precacheImage(artworkUrl512);
}
} else {
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
if (existingTrack?.metadata?['images'] == null) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
}
}
} catch (e) {
_logger.log('Error preloading player track for ${player.name}: $e');
}
}
Future<void> _precacheImage(String url) async {
try {
final imageProvider = NetworkImage(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
_logger.log('Image precache failed for $url: $e');
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
}
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await _updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
_updatePlayerState();
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
bool stateChanged = false;
final allPlayers = await _api!.getPlayers();
final updatedPlayer = allPlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
orElse: () => _selectedPlayer!,
);
_selectedPlayer = updatedPlayer;
stateChanged = true;
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
if (!shouldShowTrack) {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
if (stateChanged) notifyListeners();
return;
}
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
if (_currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name) {
_currentTrack = queue.currentItem!.track;
stateChanged = true;
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
_localPlayer.updateNotification(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artworkUrl: artworkUrl,
duration: track.duration,
);
}
}
} else {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
}
// ============================================================================
// PLAYER CONTROLS
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
if (_selectedPlayer!.isPlaying) {
await _api!.pausePlayer(_selectedPlayer!.playerId);
} else {
await _api!.resumePlayer(_selectedPlayer!.playerId);
}
await refreshPlayers();
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await loadAndSelectPlayers(forceRefresh: true);
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
} catch (e) {
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
}
Future<void> togglePower(String playerId) async {
if (_api == null) return;
try {
final localPlayerId = await SettingsService.getBuiltinPlayerId();
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
if (isLocalPlayer) {
_isLocalPlayerPowered = !_isLocalPlayerPowered;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
await _reportLocalPlayerState();
await refreshPlayers();
} else {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
await _api!.setPower(playerId, !player.powered);
await refreshPlayers();
}
} catch (e) {
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
if (_api == null) return;
try {
await _api!.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
if (_api == null) return;
try {
await _api!.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
if (_api == null) return;
try {
await _api!.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
if (_api == null) return;
try {
await _api!.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
void clearState() {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_availablePlayers = [];
_selectedPlayer = null;
_currentTrack = null;
}
@override
void dispose() {
clearState();
super.dispose();
}
}
+11 -10
View File
@@ -12,6 +12,7 @@ import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
@@ -529,12 +530,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -609,12 +610,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -902,7 +903,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Material(
@@ -922,7 +923,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -944,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
Row(
children: [
@@ -1015,7 +1016,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
),
),
child: Icon(
@@ -1193,7 +1194,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1216,7 +1217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
);
},
+15 -14
View File
@@ -13,6 +13,7 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -417,12 +418,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -497,12 +498,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -786,7 +787,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -807,7 +808,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
// Action Buttons Row
Row(
@@ -861,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -897,7 +898,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
],
),
const SizedBox(height: 16),
Spacing.vGap16,
],
),
),
@@ -947,7 +948,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
@@ -968,7 +969,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
@@ -1004,7 +1005,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
@@ -1024,7 +1025,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
@@ -1032,7 +1033,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
_buildAlbumSliver(_providerAlbums),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
],
],
@@ -1104,7 +1105,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Material(
+40 -28
View File
@@ -150,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -172,37 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: ClipOval(
child: _authorImageUrl != null
? CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
MdiIcons.accountOutline,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
// Image covers icon when loaded
if (_authorImageUrl != null)
SizedBox(
width: coverSize,
height: coverSize,
child: ClipOval(
child: CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
)
: Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
@@ -287,8 +297,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
_buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
+7 -6
View File
@@ -12,6 +12,7 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
@@ -566,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
// Author
Text(
@@ -588,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
],
// Duration & Progress
const SizedBox(height: 16),
Spacing.vGap16,
Row(
children: [
if (book.duration != null) ...[
@@ -712,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -736,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
InkWell(
onTap: () {
setState(() {
@@ -767,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
),
),
@@ -777,7 +778,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
+2 -1
View File
@@ -9,6 +9,7 @@ import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -168,7 +169,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -11,6 +11,7 @@ import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class PlaylistDetailsScreen extends StatefulWidget {
final Playlist playlist;
@@ -410,12 +411,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -488,12 +489,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -758,7 +759,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
// Owner info
if (widget.playlist.owner != null)
Text(
@@ -775,7 +776,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
color: colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
// Action buttons row
Row(
children: [
@@ -846,7 +847,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1031,7 +1032,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1054,7 +1055,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
);
},
+2 -2
View File
@@ -860,8 +860,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
+68
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
}
}
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Get available players, excluding the current source player
final allPlayers = maProvider.availablePlayers;
final targetPlayers = allPlayers
.where((p) => p.playerId != player.playerId && p.available)
.toList();
if (targetPlayers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No other players available to transfer to'),
duration: Duration(seconds: 2),
),
);
return;
}
// Show player picker sheet
await showPlayerPickerSheet(
context: context,
title: 'Transfer Queue To',
players: targetPlayers,
onPlayerSelected: (targetPlayer) async {
try {
await maProvider.api?.transferQueue(
sourceQueueId: player.playerId,
targetQueueId: targetPlayer.playerId,
autoPlay: true,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Queue transferred to ${targetPlayer.name}'),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error transferring queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to transfer queue: $e'),
backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 3),
),
);
}
}
},
);
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: 'Transfer queue',
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadQueue,
+94 -77
View File
@@ -22,6 +22,7 @@ import 'playlist_details_screen.dart';
import 'audiobook_detail_screen.dart';
import 'podcast_detail_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -486,81 +487,97 @@ class SearchScreenState extends State<SearchScreen> {
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Search field
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
child: Row(
children: [
// Search field container
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
prefixIcon: Icon(
Icons.search_rounded,
color: colorScheme.onSurface.withOpacity(0.5),
),
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
),
),
),
// Library-only toggle
Tooltip(
message: S.of(context)!.libraryOnly,
child: IconButton(
icon: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
color: _libraryOnly ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
),
onPressed: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
),
),
],
),
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Tooltip(
message: S.of(context)!.libraryOnly,
child: SizedBox(
width: 44,
height: 44,
child: Material(
color: _libraryOnly
? colorScheme.primary
: colorScheme.surfaceVariant.withOpacity(0.6),
shape: const CircleBorder(),
child: InkWell(
onTap: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
customBorder: const CircleBorder(),
child: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
size: 22,
color: _libraryOnly
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
),
),
],
),
),
),
@@ -602,7 +619,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 80,
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.searchForContent,
style: TextStyle(
@@ -655,7 +672,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 64,
color: colorScheme.error.withOpacity(0.7),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
_searchError!,
style: TextStyle(
@@ -664,7 +681,7 @@ class SearchScreenState extends State<SearchScreen> {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Spacing.vGap16,
FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text),
child: Text(S.of(context)!.retry),
@@ -785,7 +802,7 @@ class SearchScreenState extends State<SearchScreen> {
end: Alignment.bottomCenter,
colors: [
colorScheme.background,
colorScheme.surface.withOpacity(0),
colorScheme.background.withOpacity(0),
],
),
),
@@ -1222,7 +1239,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1438,7 +1455,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1632,7 +1649,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1997,7 +2014,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
+350 -77
View File
@@ -326,120 +326,301 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Returns the image URL if found, null otherwise
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
/// Generate name variations for fuzzy matching
/// Returns a list of name variations to try, in priority order
static List<String> _generateNameVariations(String authorName) {
final variations = <String>[];
final original = authorName.trim();
// Always try original first
variations.add(original);
// Handle "Lastname, Firstname" format
if (original.contains(',')) {
final parts = original.split(',').map((p) => p.trim()).toList();
if (parts.length == 2) {
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
}
}
// Try Audnexus (specifically for audiobook authors, uses Audible data)
// Normalize and expand initials for better matching
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
String normalized = original;
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
// Match single letter followed by period and optional space, then another letter
normalized = normalized.replaceAllMapped(
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
(m) => '${m.group(1)}. ${m.group(3)}',
);
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
// This catches the case where we have "R.R" as a unit
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])\.([A-Z])'),
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
);
// Add periods to standalone single uppercase letters (initials without periods)
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])(\s)'),
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
);
if (normalized != original && !variations.contains(normalized)) {
variations.add(normalized);
}
// Remove common titles
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
String withoutTitle = original;
for (final title in titles) {
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
withoutTitle = withoutTitle.substring(title.length).trim();
if (withoutTitle != original) {
variations.add(withoutTitle);
}
break;
}
}
// Remove suffixes
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
String withoutSuffix = withoutTitle;
for (final suffix in suffixes) {
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
variations.add(withoutSuffix);
}
break;
}
}
// Split into parts for further variations (use normalized name for better splitting)
final nameToSplit = normalized != original ? normalized : withoutSuffix;
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.length >= 2) {
// Try first + last only (skip middle names/initials)
final firstLast = '${parts.first} ${parts.last}';
if (!variations.contains(firstLast)) {
variations.add(firstLast);
}
// Check if name starts with initials (single letter or letter with period)
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
// If first part looks like an initial, try just the last name
if (isInitial(parts.first)) {
if (!variations.contains(parts.last)) {
variations.add(parts.last);
}
}
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
// Find where initials end and actual name begins
int initialsEnd = 0;
for (int i = 0; i < parts.length - 1; i++) {
if (isInitial(parts[i])) {
initialsEnd = i + 1;
} else {
break;
}
}
if (initialsEnd > 0 && initialsEnd < parts.length) {
// Try the non-initial part of the name
final restOfName = parts.sublist(initialsEnd).join(' ');
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
variations.add(restOfName);
}
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
final initialsNoPeriods = parts.sublist(0, initialsEnd)
.map((s) => s.replaceAll('.', ''))
.join('');
final compactInitials = '$initialsNoPeriods ${parts.last}';
if (!variations.contains(compactInitials)) {
variations.add(compactInitials);
}
}
}
// Remove duplicates while preserving order
return variations.toSet().toList();
}
/// Try to fetch author image from Audnexus
/// Audnexus requires a two-step process:
/// 1. Search by name to get the ASIN
/// 2. Fetch full author details by ASIN to get the image
static Future<String?> _tryAudnexus(String name) async {
try {
final audnexusUri = Uri.https(
'api.audnex.us',
'/authors',
{'name': authorName},
);
// Step 1: Search for author to get ASIN
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
if (searchData is List && searchData.isNotEmpty) {
// Find the best match - prefer exact name matches
String? bestAsin;
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
if (audnexusResponse.statusCode == 200) {
final data = json.decode(audnexusResponse.body);
// Audnexus returns an array of authors
if (data is List && data.isNotEmpty) {
final author = data[0];
final imageUrl = author['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
for (final result in searchData) {
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
final asin = result['asin'] as String?;
if (asin != null) {
if (resultName == nameLower) {
bestAsin = asin;
break; // Exact match found
}
bestAsin ??= asin; // Keep first result as fallback
}
}
if (bestAsin != null) {
// Step 2: Fetch full author details by ASIN
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
if (detailsResponse.statusCode == 200) {
final detailsData = json.decode(detailsResponse.body);
final imageUrl = detailsData['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Open Library API
/// Try to fetch author image from Open Library
static Future<String?> _tryOpenLibrary(String name) async {
try {
final uri = Uri.https(
'openlibrary.org',
'/search/authors.json',
{
'q': authorName,
'limit': '1',
},
);
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'q': name,
'limit': '1',
});
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final author = authors[0];
final authorKey = author['key'] as String?;
final authorKey = authors[0]['key'] as String?;
if (authorKey != null) {
// Open Library author images are available at:
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify the image actually exists (Open Library returns 404 for missing images)
// Verify image exists - Open Library returns 302 redirects for valid images
try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) {
_authorImageCache[cacheKey] = imageUrl;
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl;
}
} catch (_) {
// Image doesn't exist, continue to cache null
} catch (_) {}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikidata (structured Wikipedia data)
static Future<String?> _tryWikidata(String name) async {
try {
// Search for entity by name
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbsearchentities',
'search': name,
'language': 'en',
'type': 'item',
'limit': '3',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['search'] as List?;
// Try each result to find one with an image
for (final result in results ?? []) {
final entityId = result['id'] as String?;
if (entityId == null) continue;
// Get the image property (P18)
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbgetclaims',
'entity': entityId,
'property': 'P18', // P18 is the "image" property
'format': 'json',
});
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
if (claimsResponse.statusCode == 200) {
final claimsData = json.decode(claimsResponse.body);
final claims = claimsData['claims'] as Map<String, dynamic>?;
final imageProperty = claims?['P18'] as List?;
if (imageProperty != null && imageProperty.isNotEmpty) {
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
if (imageName != null && imageName.isNotEmpty) {
// Convert filename to Wikimedia Commons URL
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
}
}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error: $e', context: 'Metadata');
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Wikipedia API
/// Try to fetch author image from Wikipedia
static Future<String?> _tryWikipedia(String name) async {
try {
// Search for author on Wikipedia
final searchUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'list': 'search',
'srsearch': '$authorName writer',
'srlimit': '1',
'format': 'json',
},
);
// Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'list': 'search',
'srsearch': '$name writer author',
'srlimit': '1',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?;
final results = searchData['query']?['search'] as List?;
if (results != null && results.isNotEmpty) {
final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
},
);
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
});
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
@@ -450,7 +631,6 @@ class MetadataService {
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
@@ -459,7 +639,100 @@ class MetadataService {
}
}
} catch (e) {
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
+48 -7
View File
@@ -1082,7 +1082,7 @@ class MusicAssistantAPI {
for (final item in providerRoot) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
if (path != 'root' && name != '..') {
discoveredLibraries.add({'path': path, 'name': name});
}
@@ -1101,7 +1101,7 @@ class MusicAssistantAPI {
for (final libraryItem in providerRoot) {
final libraryMap = libraryItem as Map<String, dynamic>;
final libraryPath = libraryMap['path'] as String? ?? '';
final libraryName = libraryMap['name'] as String? ?? '';
final libraryName = (libraryMap['name'] as String?) ?? (libraryMap['label'] as String?) ?? '';
// Skip "root" (parent navigation) item
if (libraryPath == 'root' || libraryName == '..') continue;
@@ -1121,7 +1121,7 @@ class MusicAssistantAPI {
// Log library contents to find series
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
final path = itemMap['path'] as String? ?? '';
_logger.log('📚 Library item: name=$name, path=$path');
}
@@ -1129,10 +1129,11 @@ class MusicAssistantAPI {
// Look for "Series" folder in the library
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = (itemMap['name'] as String? ?? '').toLowerCase();
final name = ((itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '').toLowerCase();
final path = itemMap['path'] as String? ?? '';
if (name.contains('series') || path.contains('series')) {
// Check for "series" in name/path, or abbreviated path ending with "/s"
if (name.contains('series') || path.contains('series') || path.endsWith('/s')) {
_logger.log('📚 Found series folder: $path');
// Browse the series folder
@@ -1141,13 +1142,15 @@ class MusicAssistantAPI {
for (final seriesItem in seriesItems) {
final seriesMap = seriesItem as Map<String, dynamic>;
final seriesName = (seriesMap['name'] as String?) ?? (seriesMap['label'] as String?) ?? '';
final seriesPath = seriesMap['path'] as String? ?? '';
// Skip parent navigation
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
// Skip actual audiobook items (we only want series folders)
final mediaType = seriesMap['media_type'] as String?;
if (mediaType == 'audiobook' || mediaType == 'track') continue;
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
_logger.log('📚 Series: name=$seriesName, path=$seriesPath, media_type=$mediaType');
allSeries.add(AudiobookSeries.fromJson(seriesMap));
}
break; // Found series in this library, move to next library
@@ -2518,6 +2521,44 @@ class MusicAssistantAPI {
}
}
/// Transfer the current queue to another player
/// This moves the entire playback queue from source player to target player,
/// including shuffle and repeat settings.
///
/// [sourceQueueId] - The queue ID of the source player (optional, uses first playing player if null)
/// [targetQueueId] - The queue ID of the target player (required)
/// [autoPlay] - Whether to automatically start playback on the target player (default: true)
Future<void> transferQueue({
String? sourceQueueId,
required String targetQueueId,
bool autoPlay = true,
}) async {
try {
_logger.log('🔄 Transferring queue to player: $targetQueueId (source: ${sourceQueueId ?? "auto"}, autoPlay: $autoPlay)');
final args = <String, dynamic>{
'target_queue_id': targetQueueId,
'auto_play': autoPlay,
};
// Only include source_queue_id if explicitly provided
// When null, MA will use the first playing player
if (sourceQueueId != null) {
args['source_queue_id'] = sourceQueueId;
}
await _sendCommand(
'player_queues/transfer',
args: args,
);
_logger.log('✅ Queue transferred successfully');
} catch (e) {
_logger.log('❌ Error transferring queue: $e');
rethrow;
}
}
// ============================================================================
// BUILT-IN PLAYER MANAGEMENT
// ============================================================================
+68 -5
View File
@@ -20,7 +20,17 @@ class SecureStorageService {
// Auth Token (for stream requests)
static Future<String?> getAuthToken() async {
return await _storage.read(key: _keyAuthToken);
try {
return await _storage.read(key: _keyAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setAuthToken(String? token) async {
@@ -33,7 +43,17 @@ class SecureStorageService {
// Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async {
return await _storage.read(key: _keyMaAuthToken);
try {
return await _storage.read(key: _keyMaAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyMaAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setMaAuthToken(String? token) async {
@@ -50,7 +70,18 @@ class SecureStorageService {
// Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
final json = await _storage.read(key: _keyAuthCredentials);
String? json;
try {
json = await _storage.read(key: _keyAuthCredentials);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthCredentials);
return null;
}
rethrow;
}
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
@@ -69,7 +100,17 @@ class SecureStorageService {
// Password
static Future<String?> getPassword() async {
return await _storage.read(key: _keyPassword);
try {
return await _storage.read(key: _keyPassword);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyPassword);
return null;
}
rethrow;
}
}
static Future<void> setPassword(String? password) async {
@@ -82,7 +123,17 @@ class SecureStorageService {
// Audiobookshelf API Token
static Future<String?> getAbsApiToken() async {
return await _storage.read(key: _keyAbsApiToken);
try {
return await _storage.read(key: _keyAbsApiToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAbsApiToken);
return null;
}
rethrow;
}
}
static Future<void> setAbsApiToken(String? token) async {
@@ -98,12 +149,21 @@ class SecureStorageService {
await _storage.deleteAll();
}
// Migration completed flag key
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
/// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users.
/// Uses a flag to skip migration on subsequent runs for performance.
static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Skip if migration already completed
if (prefs.getBool(_keyMigrationCompleted) == true) {
return;
}
// Migrate auth token
final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) {
@@ -144,5 +204,8 @@ class SecureStorageService {
await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token');
}
// Mark migration as completed to skip on future runs
await prefs.setBool(_keyMigrationCompleted, true);
}
}
+281 -327
View File
@@ -4,6 +4,72 @@ import 'device_id_service.dart';
import 'secure_storage_service.dart';
class SettingsService {
// Cached SharedPreferences instance for performance
static SharedPreferences? _prefsCache;
/// Initialize the SharedPreferences cache. Call this early in app startup.
static Future<void> initialize() async {
_prefsCache ??= await SharedPreferences.getInstance();
}
/// Get SharedPreferences instance, using cache if available.
static Future<SharedPreferences> _getPrefs() async {
return _prefsCache ??= await SharedPreferences.getInstance();
}
// ============ GENERIC HELPERS ============
// These reduce boilerplate for repetitive getter/setter patterns
/// Get a string value, with optional default
static Future<String?> _getString(String key, {String? defaultValue}) async {
final prefs = await _getPrefs();
return prefs.getString(key) ?? defaultValue;
}
/// Set a string value, removing if null/empty when removeIfEmpty is true
static Future<void> _setString(String key, String? value, {bool removeIfEmpty = false}) async {
final prefs = await _getPrefs();
if (removeIfEmpty && (value == null || value.isEmpty)) {
await prefs.remove(key);
} else if (value != null) {
await prefs.setString(key, value);
}
}
/// Get a bool value with default
static Future<bool> _getBool(String key, {bool defaultValue = false}) async {
final prefs = await _getPrefs();
return prefs.getBool(key) ?? defaultValue;
}
/// Set a bool value
static Future<void> _setBool(String key, bool value) async {
final prefs = await _getPrefs();
await prefs.setBool(key, value);
}
/// Get an int value, with optional default
static Future<int?> _getInt(String key, {int? defaultValue}) async {
final prefs = await _getPrefs();
return prefs.getInt(key) ?? defaultValue;
}
/// Set an int value, removing if null
static Future<void> _setInt(String key, int? value) async {
final prefs = await _getPrefs();
if (value == null) {
await prefs.remove(key);
} else {
await prefs.setInt(key, value);
}
}
/// Remove a key
static Future<void> _remove(String key) async {
final prefs = await _getPrefs();
await prefs.remove(key);
}
static const String _keyServerUrl = 'server_url';
static const String _keyAuthServerUrl = 'auth_server_url';
static const String _keyWebSocketPort = 'websocket_port';
@@ -76,6 +142,16 @@ class SettingsService {
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
// Library Sort Order Settings
static const String _keyLibraryArtistsSortOrder = 'library_artists_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyLibraryAlbumsSortOrder = 'library_albums_sort_order'; // 'alpha', 'alpha_desc', 'year', 'year_desc', 'artist'
static const String _keyLibraryTracksSortOrder = 'library_tracks_sort_order'; // 'alpha', 'artist', 'album', 'duration'
static const String _keyLibraryPlaylistsSortOrder = 'library_playlists_sort_order'; // 'alpha', 'alpha_desc', 'tracks'
static const String _keyLibraryAuthorsSortOrder = 'library_authors_sort_order'; // 'alpha', 'alpha_desc', 'books'
static const String _keyLibrarySeriesSortOrder = 'library_series_sort_order'; // 'alpha', 'alpha_desc', 'books'
static const String _keyLibraryRadioSortOrder = 'library_radio_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyLibraryPodcastsSortOrder = 'library_podcasts_sort_order'; // 'alpha', 'alpha_desc'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
static const String _keyAbsApiToken = 'abs_api_token';
@@ -97,29 +173,29 @@ class SettingsService {
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl);
}
static Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyServerUrl, url);
}
static Future<void> clearServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.remove(_keyServerUrl);
}
// Get authentication server URL (returns null if not set, meaning use server URL)
static Future<String?> getAuthServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyAuthServerUrl);
}
// Set authentication server URL (null means use same as server URL)
static Future<void> setAuthServerUrl(String? url) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
if (url == null || url.isEmpty) {
await prefs.remove(_keyAuthServerUrl);
} else {
@@ -129,13 +205,13 @@ class SettingsService {
// Get custom WebSocket port (null means use default logic)
static Future<int?> getWebSocketPort() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getInt(_keyWebSocketPort);
}
// Set custom WebSocket port (null to use default logic)
static Future<void> setWebSocketPort(int? port) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
if (port == null) {
await prefs.remove(_keyWebSocketPort);
} else {
@@ -185,13 +261,13 @@ class SettingsService {
// Get username for authentication
static Future<String?> getUsername() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyUsername);
}
// Set username for authentication
static Future<void> setUsername(String? username) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
if (username == null || username.isEmpty) {
await prefs.remove(_keyUsername);
} else {
@@ -211,111 +287,43 @@ class SettingsService {
// Get built-in player ID (persistent UUID for this device)
static Future<String?> getBuiltinPlayerId() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyBuiltinPlayerId);
}
// Set built-in player ID
static Future<void> setBuiltinPlayerId(String id) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyBuiltinPlayerId, id);
}
// Theme settings
static Future<String?> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyThemeMode) ?? 'system';
}
static Future<String?> getThemeMode() => _getString(_keyThemeMode, defaultValue: 'system');
static Future<void> saveThemeMode(String mode) => _setString(_keyThemeMode, mode);
static Future<void> saveThemeMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyThemeMode, mode);
}
static Future<bool> getUseMaterialTheme() => _getBool(_keyUseMaterialTheme, defaultValue: false);
static Future<void> saveUseMaterialTheme(bool enabled) => _setBool(_keyUseMaterialTheme, enabled);
static Future<bool> getUseMaterialTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyUseMaterialTheme) ?? false;
}
static Future<bool> getAdaptiveTheme() => _getBool(_keyAdaptiveTheme, defaultValue: true);
static Future<void> saveAdaptiveTheme(bool enabled) => _setBool(_keyAdaptiveTheme, enabled);
static Future<void> saveUseMaterialTheme(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyUseMaterialTheme, enabled);
}
static Future<bool> getAdaptiveTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyAdaptiveTheme) ?? true; // Default to true
}
static Future<void> saveAdaptiveTheme(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAdaptiveTheme, enabled);
}
static Future<String?> getCustomColor() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyCustomColor);
}
static Future<void> saveCustomColor(String color) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyCustomColor, color);
}
static Future<String?> getCustomColor() => _getString(_keyCustomColor);
static Future<void> saveCustomColor(String color) => _setString(_keyCustomColor, color);
// Locale (null = system default)
static Future<String?> getLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocale);
}
static Future<void> saveLocale(String? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
await prefs.remove(_keyLocale);
} else {
await prefs.setString(_keyLocale, locale);
}
}
static Future<String?> getLocale() => _getString(_keyLocale);
static Future<void> saveLocale(String? locale) => _setString(_keyLocale, locale, removeIfEmpty: true);
// Metadata API Keys
static Future<String?> getLastFmApiKey() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLastFmApiKey);
}
static Future<String?> getLastFmApiKey() => _getString(_keyLastFmApiKey);
static Future<void> setLastFmApiKey(String? key) => _setString(_keyLastFmApiKey, key, removeIfEmpty: true);
static Future<void> setLastFmApiKey(String? key) async {
final prefs = await SharedPreferences.getInstance();
if (key == null || key.isEmpty) {
await prefs.remove(_keyLastFmApiKey);
} else {
await prefs.setString(_keyLastFmApiKey, key);
}
}
static Future<String?> getTheAudioDbApiKey() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyTheAudioDbApiKey);
}
static Future<void> setTheAudioDbApiKey(String? key) async {
final prefs = await SharedPreferences.getInstance();
if (key == null || key.isEmpty) {
await prefs.remove(_keyTheAudioDbApiKey);
} else {
await prefs.setString(_keyTheAudioDbApiKey, key);
}
}
static Future<String?> getTheAudioDbApiKey() => _getString(_keyTheAudioDbApiKey);
static Future<void> setTheAudioDbApiKey(String? key) => _setString(_keyTheAudioDbApiKey, key, removeIfEmpty: true);
// Local Playback Settings
static Future<bool> getEnableLocalPlayback() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyEnableLocalPlayback) ?? true;
}
static Future<void> setEnableLocalPlayback(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyEnableLocalPlayback, enabled);
}
static Future<bool> getEnableLocalPlayback() => _getBool(_keyEnableLocalPlayback, defaultValue: true);
static Future<void> setEnableLocalPlayback(bool enabled) => _setBool(_keyEnableLocalPlayback, enabled);
static Future<String> getLocalPlayerName() async {
// Derive player name from owner name
@@ -325,62 +333,28 @@ class SettingsService {
}
// Fallback to stored local player name or default
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLocalPlayerName) ?? 'Ensemble';
}
static Future<void> setLocalPlayerName(String name) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLocalPlayerName, name);
}
static Future<void> setLocalPlayerName(String name) => _setString(_keyLocalPlayerName, name);
// Owner Name - used to derive player name
static Future<String?> getOwnerName() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyOwnerName);
}
static Future<void> setOwnerName(String name) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyOwnerName, name.trim());
}
static Future<String?> getOwnerName() => _getString(_keyOwnerName);
static Future<void> setOwnerName(String name) => _setString(_keyOwnerName, name.trim());
// Last selected player ID - persists user's player selection across sessions
static Future<String?> getLastSelectedPlayerId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLastSelectedPlayerId);
}
static Future<void> setLastSelectedPlayerId(String? playerId) async {
final prefs = await SharedPreferences.getInstance();
if (playerId == null || playerId.isEmpty) {
await prefs.remove(_keyLastSelectedPlayerId);
} else {
await prefs.setString(_keyLastSelectedPlayerId, playerId);
}
}
static Future<String?> getLastSelectedPlayerId() => _getString(_keyLastSelectedPlayerId);
static Future<void> setLastSelectedPlayerId(String? playerId) =>
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
}
static Future<void> setPreferLocalPlayer(bool prefer) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyPreferLocalPlayer, prefer);
}
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keySmartSortPlayers) ?? false;
}
static Future<void> setSmartSortPlayers(bool smartSort) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keySmartSortPlayers, smartSort);
}
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
@@ -395,7 +369,7 @@ class SettingsService {
}
static Future<void> clearSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.clear();
await SecureStorageService.clearAll(); // Also clear secure storage
}
@@ -407,142 +381,51 @@ class SettingsService {
}
// Home Screen Row Settings (Main rows - default on)
static Future<bool> getShowRecentAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowRecentAlbums) ?? true;
}
static Future<bool> getShowRecentAlbums() => _getBool(_keyShowRecentAlbums, defaultValue: true);
static Future<void> setShowRecentAlbums(bool show) => _setBool(_keyShowRecentAlbums, show);
static Future<void> setShowRecentAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowRecentAlbums, show);
}
static Future<bool> getShowDiscoverArtists() => _getBool(_keyShowDiscoverArtists, defaultValue: true);
static Future<void> setShowDiscoverArtists(bool show) => _setBool(_keyShowDiscoverArtists, show);
static Future<bool> getShowDiscoverArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
}
static Future<void> setShowDiscoverArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverArtists, show);
}
static Future<bool> getShowDiscoverAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
}
static Future<void> setShowDiscoverAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverAlbums, show);
}
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
// Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowContinueListeningAudiobooks) ?? false;
}
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
static Future<void> setShowContinueListeningAudiobooks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowContinueListeningAudiobooks, show);
}
static Future<bool> getShowDiscoverAudiobooks() => _getBool(_keyShowDiscoverAudiobooks, defaultValue: false);
static Future<void> setShowDiscoverAudiobooks(bool show) => _setBool(_keyShowDiscoverAudiobooks, show);
static Future<bool> getShowDiscoverAudiobooks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverAudiobooks) ?? false;
}
static Future<void> setShowDiscoverAudiobooks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverAudiobooks, show);
}
static Future<bool> getShowDiscoverSeries() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowDiscoverSeries) ?? false;
}
static Future<void> setShowDiscoverSeries(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowDiscoverSeries, show);
}
static Future<bool> getShowDiscoverSeries() => _getBool(_keyShowDiscoverSeries, defaultValue: false);
static Future<void> setShowDiscoverSeries(bool show) => _setBool(_keyShowDiscoverSeries, show);
// Home Screen Favorites Settings (default off)
static Future<bool> getShowFavoriteAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
}
static Future<bool> getShowFavoriteAlbums() => _getBool(_keyShowFavoriteAlbums, defaultValue: false);
static Future<void> setShowFavoriteAlbums(bool show) => _setBool(_keyShowFavoriteAlbums, show);
static Future<void> setShowFavoriteAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteAlbums, show);
}
static Future<bool> getShowFavoriteArtists() => _getBool(_keyShowFavoriteArtists, defaultValue: false);
static Future<void> setShowFavoriteArtists(bool show) => _setBool(_keyShowFavoriteArtists, show);
static Future<bool> getShowFavoriteArtists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
}
static Future<bool> getShowFavoriteTracks() => _getBool(_keyShowFavoriteTracks, defaultValue: false);
static Future<void> setShowFavoriteTracks(bool show) => _setBool(_keyShowFavoriteTracks, show);
static Future<void> setShowFavoriteArtists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteArtists, show);
}
static Future<bool> getShowFavoritePlaylists() => _getBool(_keyShowFavoritePlaylists, defaultValue: false);
static Future<void> setShowFavoritePlaylists(bool show) => _setBool(_keyShowFavoritePlaylists, show);
static Future<bool> getShowFavoriteTracks() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
}
static Future<bool> getShowFavoriteRadioStations() => _getBool(_keyShowFavoriteRadioStations, defaultValue: false);
static Future<void> setShowFavoriteRadioStations(bool show) => _setBool(_keyShowFavoriteRadioStations, show);
static Future<void> setShowFavoriteTracks(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteTracks, show);
}
static Future<bool> getShowFavoritePlaylists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
}
static Future<void> setShowFavoritePlaylists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePlaylists, show);
}
static Future<bool> getShowFavoriteRadioStations() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
}
static Future<void> setShowFavoriteRadioStations(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteRadioStations, show);
}
static Future<bool> getShowFavoritePodcasts() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePodcasts) ?? false;
}
static Future<void> setShowFavoritePodcasts(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePodcasts, show);
}
static Future<bool> getShowFavoritePodcasts() => _getBool(_keyShowFavoritePodcasts, defaultValue: false);
static Future<void> setShowFavoritePodcasts(bool show) => _setBool(_keyShowFavoritePodcasts, show);
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
}
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
}
static Future<bool> getShowOnlyArtistsWithAlbums() => _getBool(_keyShowOnlyArtistsWithAlbums, defaultValue: false);
static Future<void> setShowOnlyArtistsWithAlbums(bool show) => _setBool(_keyShowOnlyArtistsWithAlbums, show);
// Home Row Order
static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
final json = prefs.getString(_keyHomeRowOrder);
if (json != null) {
try {
@@ -566,176 +449,247 @@ class SettingsService {
}
static Future<void> setHomeRowOrder(List<String> order) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyHomeRowOrder, jsonEncode(order));
}
// View Mode Settings - Artist Albums
static Future<String> getArtistAlbumsSortOrder() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyArtistAlbumsSortOrder) ?? 'alpha';
}
static Future<String> getArtistAlbumsSortOrder() async => (await _getString(_keyArtistAlbumsSortOrder)) ?? 'alpha';
static Future<void> setArtistAlbumsSortOrder(String order) => _setString(_keyArtistAlbumsSortOrder, order);
static Future<void> setArtistAlbumsSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyArtistAlbumsSortOrder, order);
}
static Future<String> getArtistAlbumsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyArtistAlbumsViewMode) ?? 'grid2';
}
static Future<void> setArtistAlbumsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyArtistAlbumsViewMode, mode);
}
static Future<String> getArtistAlbumsViewMode() async => (await _getString(_keyArtistAlbumsViewMode)) ?? 'grid2';
static Future<void> setArtistAlbumsViewMode(String mode) => _setString(_keyArtistAlbumsViewMode, mode);
// View Mode Settings - Library Artists
static Future<String> getLibraryArtistsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryArtistsViewMode) ?? 'list';
}
static Future<String> getLibraryArtistsViewMode() async => (await _getString(_keyLibraryArtistsViewMode)) ?? 'list';
static Future<void> setLibraryArtistsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryArtistsViewMode, mode);
}
// View Mode Settings - Library Albums
static Future<String> getLibraryAlbumsViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAlbumsViewMode) ?? 'grid2';
}
static Future<void> setLibraryAlbumsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAlbumsViewMode, mode);
}
// View Mode Settings - Library Playlists
static Future<String> getLibraryPlaylistsViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryPlaylistsViewMode) ?? 'list';
}
static Future<void> setLibraryPlaylistsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryPlaylistsViewMode, mode);
}
// View Mode Settings - Author Audiobooks (author detail screen)
static Future<String> getAuthorAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyAuthorAudiobooksSortOrder) ?? 'alpha';
}
static Future<void> setAuthorAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyAuthorAudiobooksSortOrder, order);
}
static Future<String> getAuthorAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyAuthorAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setAuthorAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyAuthorAudiobooksViewMode, mode);
}
// View Mode Settings - Library Authors
static Future<String> getLibraryAuthorsViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAuthorsViewMode) ?? 'list';
}
static Future<void> setLibraryAuthorsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAuthorsViewMode, mode);
}
// View Mode Settings - Library Audiobooks
static Future<String> getLibraryAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setLibraryAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAudiobooksViewMode, mode);
}
static Future<String> getLibraryAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAudiobooksSortOrder) ?? 'alpha';
}
static Future<void> setLibraryAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAudiobooksSortOrder, order);
}
static Future<String> getLibrarySeriesViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibrarySeriesViewMode) ?? 'grid2';
}
static Future<void> setLibrarySeriesViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibrarySeriesViewMode, mode);
}
static Future<String> getLibraryRadioViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
}
static Future<void> setLibraryRadioViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryRadioViewMode, mode);
}
static Future<String> getLibraryPodcastsViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
}
static Future<void> setLibraryPodcastsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
}
// View Mode Settings - Series Audiobooks (series detail screen)
static Future<String> getSeriesAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keySeriesAudiobooksSortOrder) ?? 'series';
}
static Future<void> setSeriesAudiobooksSortOrder(String order) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keySeriesAudiobooksSortOrder, order);
}
static Future<String> getSeriesAudiobooksViewMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keySeriesAudiobooksViewMode) ?? 'grid2';
}
static Future<void> setSeriesAudiobooksViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keySeriesAudiobooksViewMode, mode);
}
// Library Sort Order Settings - Artists
static Future<String> getLibraryArtistsSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryArtistsSortOrder) ?? 'alpha';
}
static Future<void> setLibraryArtistsSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryArtistsSortOrder, order);
}
// Library Sort Order Settings - Albums
static Future<String> getLibraryAlbumsSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAlbumsSortOrder) ?? 'alpha';
}
static Future<void> setLibraryAlbumsSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAlbumsSortOrder, order);
}
// Library Sort Order Settings - Tracks
static Future<String> getLibraryTracksSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryTracksSortOrder) ?? 'artist';
}
static Future<void> setLibraryTracksSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryTracksSortOrder, order);
}
// Library Sort Order Settings - Playlists
static Future<String> getLibraryPlaylistsSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryPlaylistsSortOrder) ?? 'alpha';
}
static Future<void> setLibraryPlaylistsSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryPlaylistsSortOrder, order);
}
// Library Sort Order Settings - Authors
static Future<String> getLibraryAuthorsSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryAuthorsSortOrder) ?? 'alpha';
}
static Future<void> setLibraryAuthorsSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryAuthorsSortOrder, order);
}
// Library Sort Order Settings - Series
static Future<String> getLibrarySeriesSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibrarySeriesSortOrder) ?? 'alpha';
}
static Future<void> setLibrarySeriesSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibrarySeriesSortOrder, order);
}
// Library Sort Order Settings - Radio
static Future<String> getLibraryRadioSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryRadioSortOrder) ?? 'alpha';
}
static Future<void> setLibraryRadioSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryRadioSortOrder, order);
}
// Library Sort Order Settings - Podcasts
static Future<String> getLibraryPodcastsSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyLibraryPodcastsSortOrder) ?? 'alpha';
}
static Future<void> setLibraryPodcastsSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyLibraryPodcastsSortOrder, order);
}
// Audiobookshelf Settings
static Future<String?> getAbsServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getString(_keyAbsServerUrl);
}
static Future<void> setAbsServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyAbsServerUrl, url);
}
@@ -748,17 +702,17 @@ class SettingsService {
}
static Future<bool> getAbsEnabled() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getBool(_keyAbsEnabled) ?? false;
}
static Future<void> setAbsEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setBool(_keyAbsEnabled, enabled);
}
static Future<void> clearAbsSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.remove(_keyAbsServerUrl);
await prefs.remove(_keyAbsEnabled);
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
@@ -768,7 +722,7 @@ class SettingsService {
/// Get list of enabled library paths (all enabled by default if not set)
static Future<List<String>?> getEnabledAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
final json = prefs.getString(_keyEnabledAbsLibraries);
if (json == null) return null; // null means all libraries are enabled
try {
@@ -781,19 +735,19 @@ class SettingsService {
/// Set list of enabled library paths
static Future<void> setEnabledAbsLibraries(List<String> paths) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyEnabledAbsLibraries, jsonEncode(paths));
}
/// Clear enabled libraries (reverts to all enabled)
static Future<void> clearEnabledAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.remove(_keyEnabledAbsLibraries);
}
/// Get discovered libraries [{path: String, name: String}]
static Future<List<Map<String, String>>?> getDiscoveredAbsLibraries() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
final json = prefs.getString(_keyDiscoveredAbsLibraries);
if (json == null) return null;
try {
@@ -806,7 +760,7 @@ class SettingsService {
/// Save discovered libraries
static Future<void> setDiscoveredAbsLibraries(List<Map<String, String>> libraries) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyDiscoveredAbsLibraries, jsonEncode(libraries));
}
@@ -839,37 +793,37 @@ class SettingsService {
/// Get whether hints are enabled (default: true)
static Future<bool> getShowHints() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getBool(_keyShowHints) ?? true;
}
/// Set whether hints are enabled
static Future<void> setShowHints(bool show) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setBool(_keyShowHints, show);
}
/// Check if user has ever used the player reveal gesture
static Future<bool> getHasUsedPlayerReveal() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
}
/// Mark that user has used the player reveal gesture
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
}
/// Check if user has completed onboarding (seen welcome screen)
static Future<bool> getHasCompletedOnboarding() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
}
/// Mark that user has completed onboarding
static Future<void> setHasCompletedOnboarding(bool completed) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
@@ -879,13 +833,13 @@ class SettingsService {
/// When enabled, holding still while adjusting volume enters precision mode
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
static Future<void> setVolumePrecisionMode(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setBool(_keyVolumePrecisionMode, enabled);
}
@@ -894,7 +848,7 @@ class SettingsService {
/// Get cached podcast cover URLs (iTunes high-res)
static Future<Map<String, String>> getPodcastCoverCache() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
final json = prefs.getString(_keyPodcastCoverCache);
if (json == null) return {};
try {
@@ -907,7 +861,7 @@ class SettingsService {
/// Save podcast cover cache
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _getPrefs();
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
}
+48
View File
@@ -124,3 +124,51 @@ class Durations {
static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500);
}
/// Button size tokens
class ButtonSizes {
ButtonSizes._();
// Touch target sizes (Material Design minimum: 48x48)
static const double sm = 32.0;
static const double md = 36.0;
static const double lg = 44.0;
static const double xl = 48.0;
static const double xxl = 56.0;
// Icon sizes within buttons
static const double iconSm = 16.0;
static const double iconMd = 18.0;
static const double iconLg = 20.0;
static const double iconXl = 24.0;
}
/// Semantic status colors for consistent feedback
/// Use with Theme brightness for proper contrast
class StatusColors {
StatusColors._();
// Success states
static const Color success = Color(0xFF4CAF50);
static const Color successLight = Color(0xFF81C784);
static const Color successDark = Color(0xFF388E3C);
// Error states
static const Color error = Color(0xFFF44336);
static const Color errorLight = Color(0xFFE57373);
static const Color errorDark = Color(0xFFD32F2F);
// Warning states
static const Color warning = Color(0xFFFF9800);
static const Color warningLight = Color(0xFFFFB74D);
static const Color warningDark = Color(0xFFF57C00);
// Info states
static const Color info = Color(0xFF2196F3);
static const Color infoLight = Color(0xFF64B5F6);
static const Color infoDark = Color(0xFF1976D2);
// Favorites (heart icon)
static const Color favorite = Color(0xFFF44336);
static const Color favoriteActive = Color(0xFFE53935);
}
+12 -1
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
@@ -36,6 +37,7 @@ class _AlbumCardState extends State<AlbumCard> {
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@@ -117,6 +119,10 @@ class _AlbumCardState extends State<AlbumCard> {
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
@@ -128,7 +134,12 @@ class _AlbumCardState extends State<AlbumCard> {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+12 -1
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
@@ -38,6 +39,7 @@ class _ArtistCardState extends State<ArtistCard> {
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@@ -110,6 +112,10 @@ class _ArtistCardState extends State<ArtistCard> {
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
@@ -121,7 +127,12 @@ class _ArtistCardState extends State<ArtistCard> {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
+27 -2
View File
@@ -73,6 +73,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Queue state
PlayerQueue? _queue;
String? _queuePlayerId; // Track which player the queue belongs to
bool _isLoadingQueue = false;
bool _isQueueDragging = false; // True while queue item is being dragged
bool _queuePanelTargetOpen = false; // Target state for queue panel (separate from animation value)
@@ -380,6 +381,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
});
}
/// Force collapse player to mini state, instantly closing queue panel if open.
/// Used for queue transfer where we want to go directly to mini player.
void forceCollapse() {
if (_isVerticalDragging) return;
// Instantly close queue panel without animation
_queuePanelController.value = 0;
_queuePanelTargetOpen = false;
// Collapse player
_controller.duration = _collapseDuration;
_controller.reverse();
}
/// Handle vertical drag start - begin gesture-driven expansion
void _handleVerticalDragStart(DragStartDetails details) {
_isVerticalDragging = true;
@@ -512,9 +525,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return;
}
// Clear queue if player changed (e.g., after queue transfer)
if (_queuePlayerId != null && _queuePlayerId != player.playerId) {
debugPrint('🔀 Player changed from $_queuePlayerId to ${player.playerId}, clearing queue');
_queue = null;
_queuePlayerId = player.playerId;
}
// 1. Show cached queue immediately (if available)
// Only load from cache if we don't already have a queue (preserves optimistic updates)
if (_queue == null) {
_queuePlayerId = player.playerId;
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
@@ -555,6 +576,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
setState(() {
_queue = freshQueue;
_queuePlayerId = player.playerId;
_isLoadingQueue = false;
});
}
@@ -1229,7 +1251,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
// PERF: Use select() to only rebuild when adaptive theme setting changes
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
// Get full themeProvider for methods that need adaptive color schemes
final themeProvider = context.read<ThemeProvider>();
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
@@ -1247,7 +1272,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
: null;
// Extract colors for adaptive theme
if (themeProvider.adaptiveTheme && imageUrl != null) {
if (adaptiveTheme && imageUrl != null) {
_extractColors(imageUrl);
}
+13 -5
View File
@@ -97,6 +97,12 @@ class GlobalPlayerOverlay extends StatefulWidget {
globalPlayerKey.currentState?.collapse();
}
/// Force collapse player to mini state, instantly closing queue panel if open.
/// Used for queue transfer where we want to go directly to mini player.
static void forceCollapsePlayer() {
globalPlayerKey.currentState?.forceCollapse();
}
/// Check if the player is currently expanded
static bool get isPlayerExpanded =>
globalPlayerKey.currentState?.isExpanded ?? false;
@@ -426,15 +432,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Watch provider to trigger rebuilds when connection state changes
final provider = context.watch<MusicAssistantProvider>();
// PERF: Use select() to only rebuild when connection state or player changes
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
(p) => (p.isConnected, p.selectedPlayer != null),
);
// Compute whether this is a first-time user ready for welcome screen.
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
final isFirstTimeUserReady = _settingsLoaded &&
!_hasCompletedOnboarding &&
provider.isConnected &&
provider.selectedPlayer != null;
isConnected &&
hasSelectedPlayer;
// Show welcome backdrop if:
// 1. Already in hint mode (_isHintModeActive), OR
@@ -480,7 +488,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
+82 -68
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
///
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
/// section you're currently in via a popup indicator.
class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.)
final Widget child;
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
/// 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
/// List of strings to extract letters from (should match visual sort order).
/// Each item's first character is used to determine the letter shown in the popup.
final List<String> items;
/// Callback when user taps/drags to a specific index
/// 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;
/// Fixed item height for lists. Enables precise scroll calculation.
final double? itemExtent;
/// Number of columns for grid layouts.
final int? crossAxisCount;
/// Aspect ratio of grid children (width / height).
final double? childAspectRatio;
/// Spacing between grid rows (mainAxisSpacing)
final double? mainAxisSpacing;
/// Horizontal padding of the scrollable area
final double? horizontalPadding;
/// Bottom padding to prevent scrollbar from going behind bottom nav/mini player
final double bottomPadding;
const LetterScrollbar({
super.key,
required this.child,
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
required this.items,
this.onScrollToIndex,
this.onDragStateChanged,
this.itemExtent,
this.crossAxisCount,
this.childAspectRatio,
this.mainAxisSpacing,
this.horizontalPadding,
this.bottomPadding = 0,
});
@override
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
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;
_currentLetter = _getLetterAtFraction(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;
}
}
/// Normalizes a character to a letter for the scrollbar.
String _normalizeToLetter(String char) {
if (char.isEmpty) return '';
final upper = char[0].toUpperCase();
if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
return upper;
}
return map;
return '#';
}
String _getLetterAtPosition(double position, double maxHeight) {
if (widget.items.isEmpty || maxHeight <= 0) return '';
/// Get the letter at a given scroll fraction (0.0 to 1.0)
String _getLetterAtFraction(double fraction) {
if (widget.items.isEmpty) 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 index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
if (index < widget.items.length) {
final item = widget.items[index];
if (item.isNotEmpty) {
return item[0].toUpperCase();
return _normalizeToLetter(item[0]);
}
}
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);
_scrollToPosition(details.localPosition.dy);
}
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
_dragPosition = details.localPosition.dy;
});
_updateLetterAndScroll(details.localPosition.dy);
_scrollToPosition(details.localPosition.dy);
}
void _handleDragEnd(DragEndDetails details) {
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
widget.onDragStateChanged?.call(false);
}
void _updateLetterAndScroll(double position) {
/// Scroll proportionally based on drag position
void _scrollToPosition(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);
if (renderBox == null || !widget.controller.hasClients) return;
// Account for bottom padding in the effective track height
final totalHeight = renderBox.size.height;
final effectiveHeight = totalHeight - widget.bottomPadding;
if (effectiveHeight <= 0) return;
// Calculate scroll fraction from position (clamped to effective area)
final clampedPosition = position.clamp(0.0, effectiveHeight);
final fraction = clampedPosition / effectiveHeight;
// Get letter for display
final letter = _getLetterAtFraction(fraction);
// Scroll proportionally
final maxScroll = widget.controller.position.maxScrollExtent;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
setState(() {
_scrollFraction = fraction;
if (letter.isNotEmpty) {
_currentLetter = letter;
}
}
});
widget.controller.jumpTo(targetScroll);
}
@override
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
// The scrollable content
widget.child,
// The draggable scrollbar area (right edge) - wider grab area
// The draggable scrollbar area (right edge)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 32, // Wider grab area
bottom: widget.bottomPadding,
width: 32,
child: GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction;
final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
return Stack(
children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
// Thumb that tracks scroll position
AnimatedPositioned(
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
if (_isDragging && _currentLetter.isNotEmpty)
Positioned(
right: 48,
top: _dragPosition - 28,
top: (_dragPosition - 28).clamp(
0.0,
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
),
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
+34 -23
View File
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart';
import '../../services/settings_service.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
@@ -41,9 +40,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Threshold for drag-to-dismiss (in animation value, 0.0-1.0)
static const double _dismissThreshold = 0.7;
// Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
@@ -154,13 +155,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
void fireBounce() {
if (!bounceFired) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
void checkBounce() {
if (_revealController.value < 0.3) {
fireBounce();
}
}
// If user already dragged past bounce threshold, fire immediately
if (_revealController.value < 0.3) {
fireBounce();
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
@@ -201,11 +213,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return;
// Swiping down = dismiss gesture
setState(() {
_isDragging = true;
_dragOffset += delta;
});
// Swiping down = dismiss gesture - directly control animation controller
_isDragging = true;
// Convert drag delta to animation value change
// Use average card travel distance (~150px) as reference
const dragSensitivity = 150.0;
final valueDelta = delta / dragSensitivity;
// Decrease animation value (cards move down)
final newValue = (_revealController.value - valueDelta).clamp(0.0, 1.0);
_revealController.value = newValue;
}
void _handleVerticalDragEnd(DragEndDetails details) {
@@ -213,28 +231,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// If not dragging (was scrolling), don't dismiss
if (!_isDragging) return;
_isDragging = false;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
// Dismiss if dragged past threshold or with enough velocity
if (_revealController.value < _dismissThreshold || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
// Spring back to fully revealed
_revealController.duration = const Duration(milliseconds: 150);
_revealController.forward();
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -322,7 +333,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
+270 -33
View File
@@ -7,6 +7,7 @@ import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart';
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
import '../global_player_overlay.dart';
/// Panel that displays the current playback queue with drag-to-reorder
/// and swipe-left-to-delete functionality.
@@ -49,10 +50,14 @@ class QueuePanel extends StatefulWidget {
State<QueuePanel> createState() => _QueuePanelState();
}
class _QueuePanelState extends State<QueuePanel> {
class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateMixin {
List<QueueItem> _items = [];
final GlobalKey _stackKey = GlobalKey();
// Transfer dropdown animation
late AnimationController _dropdownController;
bool _showingTransferDropdown = false;
// Drag state
int? _dragIndex;
int? _dragStartIndex;
@@ -90,6 +95,10 @@ class _QueuePanelState extends State<QueuePanel> {
void initState() {
super.initState();
_items = List.from(widget.queue?.items ?? []);
_dropdownController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
@@ -125,6 +134,7 @@ class _QueuePanelState extends State<QueuePanel> {
@override
void dispose() {
_dropdownController.dispose();
_pendingReorderTimer?.cancel();
super.dispose();
}
@@ -181,6 +191,176 @@ class _QueuePanelState extends State<QueuePanel> {
}
}
void _openTransferDropdown() {
if (_showingTransferDropdown) return;
setState(() => _showingTransferDropdown = true);
_dropdownController.forward();
}
void _closeTransferDropdown() {
if (!_showingTransferDropdown) return;
_dropdownController.reverse().then((_) {
if (mounted) setState(() => _showingTransferDropdown = false);
});
}
void _handleTransferQueue(BuildContext context) {
if (_showingTransferDropdown) {
_closeTransferDropdown();
} else {
_openTransferDropdown();
}
}
List<Player> _getTargetPlayers() {
// Use selected player (current player) as source, not the queue's player ID
// This ensures correct filtering after queue transfer when player switches
final sourcePlayerId = widget.maProvider.selectedPlayer?.playerId;
if (sourcePlayerId == null) return [];
return widget.maProvider.availablePlayers
.where((p) => p.playerId != sourcePlayerId && p.available)
.toList();
}
Widget _buildTransferDropdown() {
final targetPlayers = _getTargetPlayers();
final colorScheme = Theme.of(context).colorScheme;
// Use a lighter shade of the queue panel background color
final hsl = HSLColor.fromColor(widget.backgroundColor);
final menuBackground = hsl.withLightness((hsl.lightness + 0.04).clamp(0.0, 1.0)).toColor();
// Use theme's onSurface for text (matches library dropdown styling)
final menuTextColor = colorScheme.onSurface;
return Container(
constraints: const BoxConstraints(maxHeight: 280, maxWidth: 220),
decoration: BoxDecoration(
color: menuBackground,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title header (matches library dropdown section headers)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Transfer to...',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: menuTextColor.withOpacity(0.5),
),
),
),
// Player list
if (targetPlayers.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text(
'No other players available',
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 8),
itemCount: targetPlayers.length,
itemBuilder: (context, index) {
final player = targetPlayers[index];
// Status color
Color statusColor;
if (!player.powered) {
statusColor = Colors.grey;
} else if (player.state == 'playing') {
statusColor = Colors.green;
} else {
statusColor = Colors.orange;
}
// Match library dropdown item styling
return InkWell(
onTap: () => _transferToPlayer(player),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.speaker_rounded, color: menuTextColor, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
player.name,
style: TextStyle(color: menuTextColor),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
],
),
),
);
},
),
),
],
),
),
);
}
Future<void> _transferToPlayer(Player targetPlayer) async {
final sourcePlayerId = widget.queue?.playerId;
if (sourcePlayerId == null) return;
// Close dropdown first
_closeTransferDropdown();
try {
await widget.maProvider.api?.transferQueue(
sourceQueueId: sourcePlayerId,
targetQueueId: targetPlayer.playerId,
autoPlay: true,
);
debugPrint('QueuePanel: Queue transferred to ${targetPlayer.name}');
// Clear source player's track cache since its queue was transferred away
widget.maProvider.clearPlayerTrackCache(sourcePlayerId);
// Force collapse to mini player (closes queue instantly + collapses)
GlobalPlayerOverlay.forceCollapsePlayer();
// Switch to the target player - mini player will update to show new player
widget.maProvider.selectPlayer(targetPlayer);
} catch (e) {
debugPrint('QueuePanel: Error transferring queue: $e');
}
}
void _resetSwipeState() {
_swipeStart = null;
_swipeLast = null;
@@ -506,41 +686,55 @@ class _QueuePanelState extends State<QueuePanel> {
},
child: Container(
color: widget.backgroundColor,
child: Column(
child: Stack(
children: [
// Header
Container(
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
onPressed: widget.onClose,
padding: Spacing.paddingAll12,
// Main content column
Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Transfer queue button
IconButton(
icon: Icon(
_showingTransferDropdown ? Icons.close : Icons.swap_horiz_rounded,
color: _showingTransferDropdown ? widget.primaryColor : widget.textColor.withOpacity(0.7),
size: IconSizes.sm,
),
onPressed: () => _handleTransferQueue(context),
padding: Spacing.paddingAll12,
tooltip: 'Transfer queue to another player',
),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: 'Clear queue',
),
],
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: 'Clear queue',
),
],
),
),
),
// Queue content
Expanded(
// Queue content
Expanded(
child: widget.isLoading
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
: widget.queue == null || _items.isEmpty
@@ -585,7 +779,49 @@ class _QueuePanelState extends State<QueuePanel> {
],
),
),
),
],
),
// Floating dropdown overlay for transfer
if (_showingTransferDropdown) ...[
// Tap-to-dismiss backdrop
Positioned.fill(
child: GestureDetector(
onTap: _closeTransferDropdown,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Dropdown menu with open/close animation
Positioned(
top: widget.topPadding + 52, // Below header
right: 56, // Aligned with transfer button
child: AnimatedBuilder(
animation: CurvedAnimation(
parent: _dropdownController,
curve: Curves.easeInOut,
),
builder: (context, child) {
final value = _dropdownController.value;
final curvedValue = Curves.easeInOut.transform(value);
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
widthFactor: 1.0,
heightFactor: curvedValue,
child: Opacity(
opacity: curvedValue,
child: child,
),
),
);
},
child: _buildTransferDropdown(),
),
),
],
],
),
),
@@ -775,3 +1011,4 @@ class _VelocitySample {
_VelocitySample(this.position, this.timeMs);
}
+1
View File
@@ -212,6 +212,7 @@ Future<void> showPlayerPickerSheet({
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
useRootNavigator: true, // Required for overlays like QueuePanel
builder: (context) => PlayerPickerSheet(
title: title,
players: players,
+30 -12
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';
@@ -5,10 +6,11 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
class PlaylistCard extends StatelessWidget {
class PlaylistCard extends StatefulWidget {
final Playlist playlist;
final VoidCallback? onTap;
final String? heroTagSuffix;
@@ -22,31 +24,47 @@ class PlaylistCard extends StatelessWidget {
this.imageCacheSize,
});
@override
State<PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<PlaylistCard> {
bool _isNavigating = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final cacheSize = imageCacheSize ?? 256;
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
heroTagSuffix: heroTagSuffix,
playlist: widget.playlist,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -55,7 +73,7 @@ class PlaylistCard extends StatelessWidget {
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
@@ -91,11 +109,11 @@ class PlaylistCard extends StatelessWidget {
const SizedBox(height: 8),
// Playlist title
Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
@@ -107,7 +125,7 @@ class PlaylistCard extends StatelessWidget {
),
// Owner/track count
Text(
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
+30 -12
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';
@@ -6,10 +7,11 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
class PodcastCard extends StatelessWidget {
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
final VoidCallback? onTap;
final String? heroTagSuffix;
@@ -25,34 +27,50 @@ class PodcastCard extends StatelessWidget {
this.imageCacheSize,
});
@override
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> {
bool _isNavigating = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Use provider's getPodcastImageUrl which includes iTunes cache
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
final imageUrl = maProvider.getPodcastImageUrl(widget.podcast, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// PERF: Use appropriate cache size based on display size
final cacheSize = imageCacheSize ?? 256;
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PodcastDetailScreen(
podcast: podcast,
heroTagSuffix: heroTagSuffix,
podcast: widget.podcast,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -61,7 +79,7 @@ class PodcastCard extends StatelessWidget {
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix,
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
@@ -97,11 +115,11 @@ class PodcastCard extends StatelessWidget {
const SizedBox(height: 8),
// Podcast title
Hero(
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix,
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
podcast.name,
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
@@ -113,7 +131,7 @@ class PodcastCard extends StatelessWidget {
),
// Podcast author (if available)
Text(
_getAuthor(podcast),
_getAuthor(widget.podcast),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
+27 -9
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -5,8 +6,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
class RadioStationCard extends StatelessWidget {
class RadioStationCard extends StatefulWidget {
final MediaItem radioStation;
final VoidCallback? onTap;
final String? heroTagSuffix;
@@ -20,25 +22,41 @@ class RadioStationCard extends StatelessWidget {
this.imageCacheSize,
});
@override
State<RadioStationCard> createState() => _RadioStationCardState();
}
class _RadioStationCardState extends State<RadioStationCard> {
bool _isTapping = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
final imageUrl = maProvider.api?.getImageUrl(widget.radioStation, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final cacheSize = imageCacheSize ?? 256;
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
onTap: widget.onTap ?? () {
// Prevent double-tap actions
if (_isTapping) return;
_isTapping = true;
HapticFeedback.mediumImpact();
// Play the radio station on selected player
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
maProvider.api?.playRadioStation(selectedPlayer.playerId, widget.radioStation);
}
// Reset after debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isTapping = false;
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -47,7 +65,7 @@ class RadioStationCard extends StatelessWidget {
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
tag: HeroTags.radioCover + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
child: ClipOval(
child: Container(
color: colorScheme.surfaceContainerHighest,
@@ -84,11 +102,11 @@ class RadioStationCard extends StatelessWidget {
SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
tag: HeroTags.radioTitle + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
radioStation.name,
widget.radioStation.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
+1212
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.7-beta+45
version: 2.8.8-beta+46
environment:
sdk: '>=3.0.0 <4.0.0'