Compare commits

..
88 Commits
Author SHA1 Message Date
Chris f7fe6dfbbe Add MA_BETA14_IMPACT.md 2026-08-25 21:41:35 +01:00
Chris cb07e55d69 Merge: per-ABI versionCode override for F-Droid 2026-05-05 06:49:22 +01:00
Chris cf0336e99d Bump version to 3.0.5+57 2026-05-05 06:45:09 +01:00
ChrisandClaude Opus 4.7 73fd1dbdcd Add per-ABI versionCode override for F-Droid split builds
Required by F-Droid for Flutter apps using --split-per-abi: each
per-ABI APK gets a unique versionCode = base * 10 + abiCode
(armeabi-v7a=1, arm64-v8a=2, x86_64=3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 06:08:46 +01:00
Chris 2eb22cf524 Add new sponsor to README
Added a sponsor section with a new sponsor link.
2026-02-24 15:28:20 +00:00
ChrisandClaude Opus 4.6 3fe7a5800a Reduce log noise and add AA diagnostic logging
Downgrade high-frequency polling logs to debug level (PositionTracker,
Sendspin state, queue metadata, received messages) to prevent the
1000-entry log cap from rotating out useful entries. Add logging when
AA provider is not set or when it becomes available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:06:28 +00:00
ChrisandClaude Opus 4.6 972690b894 Add QueueHandler mixin for Android Auto queue management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:28:03 +00:00
ChrisandClaude Opus 4.6 0e5679262c Add Android Auto unknown sources setup steps to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:19:16 +00:00
ChrisandClaude Opus 4.6 67bae29f9d Add Android Auto to features and setup in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:17:36 +00:00
ChrisandClaude Opus 4.6 4f7fbe92d9 Remove duplicate contributors section from README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:14:14 +00:00
ChrisandClaude Opus 4.6 0c2853b3df Add antoinevandenhurk as contributor for Android Auto work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 06:55:33 +00:00
ChrisandClaude Opus 4.6 0d0ca75a6a Bump version to 3.0.4+56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 06:55:33 +00:00
Chrisandantoinevandenhurk 661888ec37 Restructure Android Auto browse tree with Home, Music, Audiobooks, Podcasts, Radio
Co-authored-by: antoinevandenhurk <35188384+antoinevandenhurk@users.noreply.github.com>
2026-02-20 06:55:33 +00:00
Chrisandantoinevandenhurk c9b03bf045 Add Android Auto media browsing and playback support
Co-authored-by: antoinevandenhurk <35188384+antoinevandenhurk@users.noreply.github.com>
2026-02-20 06:55:27 +00:00
ChrisandClaude Sonnet 4.6 f287fa2819 Fix Sendspin volume sync: use FlutterVolumeController, remove track cache, fix volume UI
- Replace pcmAudioPlayer.setVolumeGain() with FlutterVolumeController.setVolume() for system volume control
- Add notifyListeners() after local volume updates so volume slider stays in sync
- Sync localPlayerVolume from player_updated events to keep UI accurate
- Fix volume_control.dart to read maLocalVolume from provider instead of _systemVolume
- Change supported_roles to 'player@v1' in Sendspin handshake
- Remove track background sync from SyncService (tracks load on-demand)
- Simplify getAlbumTracks/getPlaylistTracks error handling (rethrow instead of swallow)
- Pass album to getAlbumTracks in AlbumDetailsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:50:05 +00:00
ChrisandClaude Sonnet 4.6 e50ca040d8 Fix MA→Ensemble volume and hardware button sync
MA→Ensemble: supported_roles must be ['player'] not ['player@v1'], and the
key must be 'player@v1_support' not 'player_support'. The server checks for
the literal string "player" in supported_roles to initialise PlayerClient -
without it, self.api.player is always None and volume commands are silently
dropped.

Hardware buttons: FlutterVolumeController listener was updating the UI but
never calling updateLocalPlayerVolume, so hardware volume presses never
reached MA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 08:53:11 +00:00
ChrisandClaude Sonnet 4.6 c7773ead0c Fix Sendspin volume sync: handle server/command message type and report state immediately
MA sends volume via {"type":"server/command","payload":{"player":{"command":"volume","volume":N}}}
but we were handling a dead "volume" case with a different format that MA never sends.

Also: updateLocalPlayerVolume now calls reportState immediately instead of waiting for
the 5-second periodic timer, so volume changes in Ensemble reach MA without delay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 08:21:16 +00:00
ChrisandClaude Sonnet 4.6 ba2928a847 Fix Sendspin volume sync: apply PCM gain and report back to MA
Two bugs fixed:

1. MA→Ensemble: Volume commands from MA (HA automations, MA web UI) were
   applied as system media volume via FlutterVolumeController. This is
   unreliable on some devices and incorrect — all other Sendspin clients
   (Snapcast, MassDroid, HA Voice PE) apply volume as software gain on the
   PCM audio stream. Add setVolumeGain() to PcmAudioPlayer and scale Int16
   samples before feeding to flutter_pcm_sound instead.

2. Ensemble→MA: When the user moved the volume slider for the local player,
   _localPlayerVolume was never updated. Every 5 seconds the periodic state
   report would send the stale value to MA, keeping MA's Sendspin player
   volume permanently out of sync. Now VolumeControl calls
   updateLocalPlayerVolume() on all local-player volume changes so the next
   report reflects the actual level.

Fixes: CollotsSpot/Ensemble#70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 07:20:06 +00:00
ChrisandClaude Opus 4.6 51c94e40a3 Improve author image loading and fix series cover rebuilds
Author images:
- Fetch from all 4 sources in parallel (was sequential) — ~5s vs 30s worst case
- Validate all image URLs with HEAD request before accepting
- Deduplicate concurrent requests for same author via Completer map
- Persist URLs to SharedPreferences for instant cold start
- Increase LRU cache from 100 to 500 entries

Series cover mosaic:
- Cache built cover widget to prevent rebuilds from unrelated setState
- Match hero widget tree structure (RepaintBoundary > AspectRatio > ClipRRect)
- Keep initialCovers stable — don't overwrite from loaded books
- Add ValueKey and memCacheWidth/Height to cover CachedNetworkImages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:32:00 +00:00
ChrisandClaude Opus 4.6 e6b2eb48b8 Add audiobook library cache and fix detail screen loading consistency
- Add _audiobooks in-memory list to provider (bulk loaded like albums)
- Add series audiobook caching for instant revisit
- Fix audiobook cover not loading in detail screen (use _audiobook getter)
- Defer data loading 350ms on audiobook/podcast/playlist detail screens
- Fix artist detail not showing provider albums after background fetch
- Add audiobook case to _removeFromLocalLibrary and _scheduleLibraryRefresh
- Delay color extraction and add memCacheHeight on series hero images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:59:57 +00:00
ChrisandClaude Opus 4.6 fb867f7410 Fix polling spam and track loss after Chromecast idle-out (#61)
Guard _updatePlayerState() polling against disconnected state to prevent
"Not authenticated" log spam. Cache last-known track per player so it
survives idle-out and can be re-queued when user taps play.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:09 +00:00
ChrisandClaude Opus 4.6 e91bdf2353 Add detail track cache, image prefetch, and offline caching improvements
Cache album/playlist tracks in a new DetailTrackCache DB table (schema v7)
so detail screens load instantly on revisit. Prefetch album artwork after
sync via ImagePrefetchService with batched downloads and LRU eviction.
Expand sync_service and cache_service to support the new caching layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:42:43 +00:00
ChrisandClaude Opus 4.6 547319231b Persist nav bar and mini player during brief disconnects, tune gradient
Keep bottom nav and mini player visible when cached players exist so
the UI doesn't flash during reconnection. Also refine the home screen
bottom fade gradient with intermediate stops to avoid a hard dark line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:27:06 +00:00
ChrisandClaude Opus 4.6 42dc7727a0 Add bottom fade gradient and increase row height on home screen
Adds a gradient overlay above the mini player to smoothly mask content
scrolling behind it, and increases each row height by 2px to push the
4th row title fully behind the mini player.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:38:15 +00:00
ChrisandClaude Opus 4.6 803cd5694c Make APK downloads badge green
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:01:30 +00:00
ChrisandClaude Opus 4.6 64dc18c0fe Change APK downloads badge to latest release only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:00:46 +00:00
ChrisandClaude Opus 4.6 47b4e41252 Add APK downloads badge to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:58:55 +00:00
Chris 6920ea0bbd Update GitHub release badge in README.md 2026-02-11 12:57:22 +00:00
Chris d1d29486d6 Update GitHub release badge color in README 2026-02-11 07:52:04 +00:00
ChrisandClaude Opus 4.6 e84515664a Dismiss keyboard on search interaction and fix search tab re-tap
Re-tapping the search nav tab now scrolls to top and focuses the search
field (#67). Keyboard dismisses on scroll (onDrag), tap, or long-press.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:49:29 +00:00
ChrisandClaude Opus 4.6 57cb13b26d Fix menu positioning behind mini player and simplify row height calc
Context menu and options menu now account for mini player/nav bar insets
so they don't get clipped behind the player overlay. Detail screens use
different bottom spacing since the nav bar is hidden. Home screen row
height calculation simplified by removing the caching/ratchet mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:34:39 +00:00
ChrisandClaude Opus 4.6 844bce2eea Bump version to 3.0.3+55 for v3.0.3-beta release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:08:19 +00:00
ChrisandClaude Opus 4.6 49ccbc618a Reduce player reveal jitter and remove unused Maven mirrors
Simplify blur backdrop to static filter, defer color extraction until
after reveal animation, and use Selector to prevent unnecessary rebuilds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:52:57 +00:00
ChrisandClaude Opus 4.6 90cb699566 Fix jarring home screen refresh and row size jump on app resume
Replace UniqueKey-based row refresh with ValueNotifier signal so rows
reload data in-place without being destroyed/recreated. Only invalidate
home cache when connecting to a different server (not on routine
reconnects after backgrounding). Cache row height to prevent size jumps
caused by the bottom nav bar toggling on connect/disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 07:29:55 +00:00
ChrisandClaude Opus 4.6 92777197ac Normalize discovery rows and eliminate unnecessary home screen refreshes
Discovery rows now behave like regular rows - always kept in _homeRowOrder
with visibility controlled by _isRowEnabled(), eliminating flash for non-Tidal
users. Tab-switch from settings only reloads local preferences (no API calls).
Settings scroll position preserved via ScrollController.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 06:29:28 +00:00
ChrisandClaude Opus 4.6 688b6354a8 Fix hardware volume in background, improve player reveal UX
- Stop volume observer and key interception on app pause so hardware
  buttons only control the foreground app (fixes volume affecting MA
  player while in YouTube etc.)
- Restore volume interception on app resume
- Animate blur backdrop fade in (250ms) and out (120ms) for player
  reveal instead of instant toggle
- Start blur fade-out immediately on dismiss, in parallel with card
  slide animation
- Extend mini player gesture hit area 20px above for easier swipe-down,
  especially on detail screens where player sits closer to screen edge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:15:44 +00:00
ChrisandClaude Opus 4.6 d29ce362e7 Add bottom fade gradient to detail screens and reduce unnecessary refreshes
- Add gradient fade overlay to all 8 detail screens so content fades
  smoothly behind the mini player instead of cutting off abruptly
- Fix player reveal gap on detail screens by accounting for hidden nav bar
  in miniPlayerBottom calculation
- Stabilize home row height by using constant margin calculation (prevents
  subtle row size jitter when app resumes and row count fluctuates)
- Remove settings reload on app resume; only reload when switching from
  Settings tab back to Home tab
- Skip discovery folders API call if already loaded (just re-sync row order)
- Only invalidate home cache after real disconnects, not routine reconnects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:19:16 +00:00
Chrisandrelic664 0a385af168 Credit relic664 for discovery/Tidal mixes concept
The discovery rows feature (commits efb84a4..0263c75) was inspired by
and based on work from PR #64 by relic664.

Co-Authored-By: relic664 <relic664@pm.me>
2026-02-09 11:19:40 +00:00
ChrisandClaude Opus 4.6 c448c4ab19 Fix row height, library assertions, album refresh, and settings sync
- Fix home screen row height formula for extendBody:true (use 106px
  mini player space instead of 162px which double-counts nav bar)
- Fix 4+ row scroll padding to use mini player space
- Wrap Artists/Albums tabs with Consumer to fix context.select assertion
- Force home cache refresh after auth to fix race condition where
  AlbumRow fires before credentials are ready
- Add .catchError() to individual futures in getRecentAlbums batch
- Fix settings toggle to add enabled rows to homeRowOrder
- Animate mini player position on detail screen nav bar transitions
- Prevent discovery loading placeholder for users without Tidal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:15:51 +00:00
Chris ea7144d81d Merge PR #69: Sync group volume management, hardware volume buttons, and UI bug fixes
Sync groups, volume management and UI bug fixes.
2026-02-09 11:13:39 +00:00
ChrisandClaude Opus 4.6 48afca040e Add sponsors section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:08:40 +00:00
ChrisandClaude Opus 4.6 c85f19d1d3 Adjust spacing in album and playlist detail screens
Reduce gaps in album details and fix playlist track list padding for better alignment of track numbers, artwork, and durations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:32:40 +00:00
pcsokonay 1f3a3b3c1d Sync group volume management and UI bug fixes.
This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:

1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible

All changes have been tested and verified with `dart analyze` showing no new issues.

---

**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:

1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally

**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.

**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:

- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).

**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.

**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.

**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.

**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)

---

**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.

**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.

**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.

---

During sync group testing, fixed these UI bugs:

**File:** `lib/main.dart`

**Issue:** Console warnings during init.

**Root Cause:** Init code outside `runZonedGuarded()`.

**Fix:** Move init inside `runZonedGuarded()`.

---

**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`

**Issue:** Crash on nav interaction.

**Root Cause:** Nav in Stack without Overlay.

**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.

---

**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`

**Issue:** Warnings causing corruption.

**Root Cause:** Service calls in initState trigger setState during build.

**Fix:** Defer with addPostFrameCallback, add mounted guard.

---

**File:** `lib/screens/new_library_screen.dart`

**Issue:** Crash on library tabs.

**Root Cause:** context.select in sliver context.

**Fix:** Use Consumer wrappers for tabs.

---

**File:** `lib/widgets/player/queue_panel.dart`

**Issue:** Crash on long-press buttons.

**Root Cause:** Tooltips require Overlay, but in Stack.

**Fix:** Remove tooltip properties.

---

**File:** `lib/screens/settings_screen.dart`

**Issue:** Back button crashes to black screen.

**Root Cause:** Navigator.pop pops home screen.

**Fix:** Use showPlayer + setSelectedIndex(0).

---

**Issue:** Intermittent empty homepage on cold start.

**Root Cause:** isConnected true before auth complete, leading to failed fetches.

**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).

---

The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:

1. **Detects group players:**
   ```dart
   bool isGroupPlayer(Player player) {
     // Pre-configured MA speaker groups
     if (player.provider == 'player_group') return true;
     // Dynamic sync groups: have multiple members but MA returns null volume
     if (player.groupMembers != null &&
         player.groupMembers!.length > 1 &&
         player.volumeLevel == null) {
       return true;
     }
     return false;
   }
   ```

2. **Computes effective volume:**
   - Recursively resolves nested groups (max depth 3)
   - Averages volumes across all leaf members
   - Skips self-references in `groupMembers`
   - Returns `null` if no members have valid volumes

3. **Caches pending volumes:**
   - When user sets volume: stored with timestamp
   - Returns cached value if within 30s timeout
   - Clears cache when member average converges (±3 tolerance)
   - Falls back to computed average after timeout

4. **Integrates with provider:**
   - Called from `_applyVolumeProtection()` in provider
   - Receives fresh `allPlayers` list for accurate member volumes
   - Injects effective volume into `_selectedPlayer` via `copyWith()`

For **in-app created groups with non-null leader volume**, proportional propagation works as follows:

1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
   - Leader has `groupMembers` with 2+ entries
   - Leader has its own non-null `volumeLevel`
   - NOT detected as a group player (GroupVolumeManager handles those)

2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
   ```dart
   if (leaderCurrentVol > 0) {
     memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
   } else {
     memberNewVol = newVol  // Can't ratio from zero
   }
   ```

3. **Execution:**
   - Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
   - Update `_availablePlayers[idx]` optimistically
   - Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
   - Continue to send leader's own `volume_set` command

4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.

```
WebSocket connects → MAConnectionState.connected
                    ↓
         Auth required?
        ↙              ↘
      YES              NO
       ↓                ↓
  Don't notify    Set state + notify
  Try auth              ↓
       ↓          Initialize
   Success?
  ↙        ↘
YES        NO
 ↓          ↓
auth     error state
event     + notify
 ↓
Set state
+ notify
 ↓
Initialize
```

**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).

android/.../MainActivity.kt                      |  120 + (NEW - ContentObserver)
lib/main.dart                                    |  207 ±
lib/providers/music_assistant_provider.dart      |  165 ±
lib/screens/home_screen.dart                     |  172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart              |   70 ± (Consumer wrappers)
lib/screens/settings_screen.dart                 |    9 ± (back button fix)
lib/services/group_volume_manager.dart           |  162 + (NEW)
lib/services/hardware_volume_service.dart        |   65 + (NEW)
lib/widgets/album_card.dart                      |   23 ± (postFrameCallback)
lib/widgets/artist_card.dart                     |   23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart           |  171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart              |    2 - (tooltip removed)

**Total:** ~650 added, ~300 removed/refactored.
2026-02-08 21:16:30 +11:00
ChrisandClaude Opus 4.6 501fe0967a Adjust detail screen spacing: add 5px back below cover art
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:09:07 +00:00
ChrisandClaude Opus 4.6 f17bfdfa55 Reduce spacing below cover art on artist and author detail screens
Tightens the gap between the cover image and the content below so album/audiobook
titles aren't obscured by the mini player. Also centers the context menu button
vertically with the author name and book count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 08:59:34 +00:00
ChrisandClaude Opus 4.6 be1d7c3b50 fix: force sync when in-memory cache is empty regardless of timestamp
SyncService was skipping sync when the database had a recent timestamp
but in-memory lists were empty (e.g., after app restart). This caused
library screens to show no data until a manual refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:52:48 +00:00
ChrisandClaude Opus 4.6 1d7cbdeeed Fix: Add Overlay wrapper and checks to prevent red screen error
- Wrap GlobalPlayerOverlay with Overlay in main.dart to provide Overlay ancestor
- Add Overlay.maybeOf check in HiResBadge before using Tooltip
- Add Overlay.maybeOf check in search_screen library-only toggle before using Tooltip

This fixes "No Overlay widget found" error that caused red screen in debug builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:45:03 +00:00
ChrisandClaude Opus 4.6 d62e1169ea Fix setState and context.select errors causing red screen
- Replace context.select with context.watch in PageView.builder tabs
  (context.select cannot be used inside PageView.builder/SliverList)
- Fix LibraryStatusMixin setState during build error using Future.microtask
- Both fixes resolve the red screen issue after login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:34:05 +00:00
ChrisandClaude Opus 4.6 3970839133 Fix: Show all available home screen rows in settings
Commit 0263c75 reduced defaultHomeRowOrder to 3 rows but also
introduced a bug where _filteredRowOrder only showed rows currently
in _homeRowOrder. This made most row options invisible in settings.

Added _allAvailableRows constant (13 rows) and modified _filteredRowOrder
to show all available static rows regardless of their current enabled state.

Users can now see and toggle all 13 home screen rows:
- 3 enabled by default (recent albums, discover artists, discover albums)
- 10 disabled by default (all other rows)

Fixes regression from commit 0263c75 (Feb 5, 2025)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:37 +00:00
ChrisandClaude Opus 4.6 8181e75a08 Fix detail screen layout: reduce cover size and spacing
- Reduce artist and audiobook author cover size from 70%/280px to 50%/200px
- Reduce expanded height padding for better space utilization
- Reduce top padding across all detail screens from 24px to 8px
- Move audiobook author three-dot menu from SliverAppBar to inline with author info

This fixes issue #60 where album art/titles were cut off by bottom menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:59:15 +00:00
ChrisandClaude Opus 4.6 9600f4bcfe Fix search race conditions with request tracking (#62)
Implement "latest request wins" pattern to prevent stale search results from overwriting current results when multiple searches are in flight. This was causing results to suddenly switch to only show "All", "Radio", "Podcasts" categories when typing quickly.

- Add _currentSearchId to track the active search request
- Add mounted check to Timer callback in _onSearchChanged
- Add search ID validation after each async operation (radio/podcast search)
- Only update state if the search is still current when results arrive
- Reset _hasPendingSearch flag when search completes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:02:54 +00:00
ChrisandClaude Opus 4.6 a2b8b1eeef Fix home screen refresh logic and prevent empty cache persistence
- Add home refresh counter to signal UI refresh after authentication
- Smart cache invalidation: only refresh home cache if it was empty
- Prevent caching empty results to avoid persisting auth errors
- Add ensureHomeRowsLoaded() to populate cache on first access
- Prevent duplicate home row loads with shared future
- Fix SendspinService emitting after disposal
- Reset auth state on WebSocket disconnect
- Change volume precision mode default to false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:51:14 +00:00
ChrisandClaude Opus 4.6 0263c75804 Refactor discovery rows: remove master toggle, simplify defaults, and fix bugs
- Remove Discovery Mixes master toggle entirely
- Discovery rows now auto-appear based on provider availability (e.g., Tidal)
- Discovery rows are off by default on first launch
- All rows (discovery and static) now behave consistently:
  * Enabled rows are added to end of list, not inserted at fixed positions
  * Users can drag rows to any position and they stay there
- Simplified default row order to only include: recently played,
  discover artists, and discover albums
- Fixed critical bug: discovery rows now correctly default to false
- Fixed SharedPreferences variable shadowing bug in setDiscoveryRowPreferences
- Fixed grey section in settings draggable list by filtering discovery-mixes
- Added localization strings for discovery rows UI
- Removed debug print statements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 22:57:02 +00:00
ChrisandClaude Opus 4.6 469e77c572 Add loading placeholder for discovery folders
Show a loading indicator when discovery folders are being fetched,
ensuring pull-to-refresh works even on empty screens while discovery
is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:00:58 +00:00
ChrisandClaude Opus 4.5 577b03bbea Move discovery mixes to draggable list and fix empty screen pull-to-refresh
- Move discovery mixes toggle from standalone to draggable row list in settings
- Add empty state widget when no home screen rows are enabled
- Fix pull-to-refresh not working when screen is empty by using SizedBox.expand
- Add localization strings for empty state hint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:23:57 +00:00
ChrisandClaude Opus 4.5 7bbba90fff Fix discovery row height - match AlbumRow textAreaHeight of 44px
DiscoveryRow was using 52px for textAreaHeight vs AlbumRow's 44px,
causing smaller artwork and different row appearance.
Changed to 44px to match other rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:48:48 +00:00
ChrisandClaude Opus 4.5 6f66a1ebff Fix discovery rows crash - add missing import and handle firstWhere error
- Add import for RecommendationFolder model
- Wrap firstWhere in try-catch to handle missing folders gracefully
- Fixes the grey screen issue when discovery toggle is enabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:39:07 +00:00
Chris 0ceab80a14 Fix compilation errors in discovery row implementation
- Remove references to non-existent RadioStation, Podcast, PodcastEpisode classes
- Fix MediaType.podcastEpisode enum value (camelCase, not snake_case)
- Use MediaItem base class for radio, podcast, podcast_episode types
2026-02-05 15:31:45 +00:00
ChrisandClaude Opus 4.5 efb84a4e50 Implement dynamic discovery rows architecture
Instead of rendering multiple discovery shelves in a single MixesRow,
each discovery folder now becomes its own row in the home screen,
following the standard 237px height pattern.

Changes:
- Add RecommendationFolder model with polymorphic JSON parsing
- Add DiscoveryRow widget for displaying a single discovery folder
- Add discovery folders to MusicAssistantProvider (getDiscoveryFoldersWithCache)
- Add discovery folders to CacheService and MusicAssistantAPI
- Add "Discovery Mixes" toggle to settings (default off)
- Update home screen to dynamically register discovery rows

This fixes the architectural issue where MixesRow broke the home
screen's assumption that each row entry renders exactly one row
with a fixed height.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:04:32 +00:00
Chris 731f45bea9 Style badges with for-the-badge 2026-02-05 09:54:38 +00:00
Chris 32e61f109e Add release version and license badges to README 2026-02-05 09:52:46 +00:00
Chris cd9e82b1fd Add FUNDING.yml for GitHub Sponsors button 2026-02-05 09:20:30 +00:00
ChrisandClaude Opus 4.5 04322cf768 Add donation support feature
Add GitHub Sponsors and Ko-fi donation buttons in settings screen,
along with an About section showing app version and links to
GitHub repository and issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:19:12 +00:00
Chris e6349c309b Add link to Ensemble TV repo 2026-01-31 09:03:22 +00:00
Chris 00f065fbe9 v3.0.2: Disable dependency metadata for F-Droid 2026-01-28 05:00:57 +00:00
ChrisandClaude Opus 4.5 e440f7b1d7 Bump version to 3.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:21:16 +00:00
ChrisandClaude Opus 4.5 5a73d2d65b Add fastlane metadata for F-Droid
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:18:43 +00:00
ChrisandClaude Opus 4.5 3b5a8631a8 Change package ID to com.collotsspot.ensemble
BREAKING CHANGE: Users must uninstall and reinstall the app.
The previous package ID (com.musicassistant.music_assistant) could be
confused with an official Music Assistant app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:55:23 +00:00
ChrisandClaude Opus 4.5 1f3a01c22f Bump version to 2.9.3-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:20:19 +00:00
ChrisandClaude Opus 4.5 27e1b8b746 Fix local player track data disappearing when selected
When selecting the builtin/local player from the player selector overlay,
the track info would disappear because the queue API returns null for
local players. The code was clearing _currentTrack unconditionally.

Now for the builtin player:
- Preserve cached track data instead of clearing it
- Restore from cache if _currentTrack is null
- Update notification with actual track info when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 9be3737a9d Add centralized LibraryStatusService for consistent library/favorite state
- Create LibraryStatusService singleton for single source of truth
- Add LibraryStatusMixin for reactive status in StatefulWidgets
- Convert all card widgets to use centralized service:
  - album_card, artist_card, playlist_card, podcast_card
  - radio_station_card, track_row, audiobook_row
- Convert detail screens: album_details_screen, artist_details_screen
- Fix album stuck in library after removal:
  - Add removeFromCacheByLibraryId() to SyncService
  - Add markCachedItemDeletedByLibraryId() to DatabaseService
  - Update removeFromLibrary() to sync all caches

This ensures library/favorite status changes are immediately reflected
across all instances of the same item throughout the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 882e8b2ea4 Simplify player selection logic
Remove confusing auto-switch behavior that could change the selected
player unexpectedly right before the user taps play.

Removed settings:
- "Prefer Local Player"
- "Disable Auto-Switch"

New simplified logic:
1. User's manual selection is always respected
2. On app reopen: restore last selected player
3. If selected player goes offline: switch to local player
4. First launch (no history): select local player
5. Local player unavailable: select first available

Kept "Smart Sort" setting for display order only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 aef9423b42 Improve adaptive color contrast using HCT color space
- Replace HSL-based color adjustments with HCT (Material Design 3 standard)
- Use proper WCAG contrast ratio calculations instead of luminance threshold
- Implement binary search to find minimum tone adjustment for contrast
- Ensure minimum chroma of 36 for primary colors to prevent washed-out highlights
- Add color scheme caching for instant loading on revisits
- Pre-extract colors on album tap for faster initial load

Fixes issue where highlighted library button was darker than unhighlighted
state on albums with dark/muted covers (e.g., Portishead, NIHT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
Chris c0138828a7 Revise reverse proxy details and remove auth methods
Updated reverse proxy options and removed authentication section.
2026-01-27 08:01:56 +00:00
Chris 53225138a2 chore: Bump version to 2.9.2-beta 2026-01-26 22:29:11 +00:00
ChrisandClaude Opus 4.5 ebc3288bfc Replace library icon with book_2 from Material Symbols
- Add material_symbols_icons package for access to book_2 icon
- Update library icon across all detail screens, context menus, bottom
  nav, and search screen to use Symbols.book_2 with fill parameter
- Add podcast episode detail screen with hero animation and full
  scrollable description
- Add episode context menu support with Play, Play On, Add to Queue,
  and View Details options
- Add sort controls for podcast episodes (newest, oldest, alpha, duration)
- Improve context menu: slightly larger buttons (46px), wider menu (185px),
  neutral text color for labels
- Search library filter now uses filled icon without background highlight

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:13:42 +00:00
ChrisandClaude Opus 4.5 5bd0cef76e Fix context menu width consistency and improve UI polish
- Fix menu width rendering inconsistently on dark album covers by:
  - Adding explicit secondaryContainer/onSecondaryContainer to ColorScheme
  - Wrapping menu content in Theme to ensure FilledButton.tonal uses
    correct colorScheme
  - Constraining text labels to 44px width to prevent row overflow
  - Shorten "Add to library" label to "Add" for better fit

- Standardize button styling across all detail screens:
  - Use FilledButton.tonal for three-dot menu buttons
  - Use filled heart icons with Colors.white70 inactive state
  - Standardize icon size to 25

- Add sort/view controls to artist and audiobook author context menus

- Remove duplicate haptic feedback (keep single haptic in
  MediaContextMenu.show)

- Make context menu dividers more subtle (15% opacity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:23:54 +00:00
ChrisandClaude Opus 4.5 9f399b438f Add library toggle to all context menus and improve menu contrast
- Add inLibrary getter to base MediaItem class for podcasts/radio
- Add library toggle support to all card widgets (podcast, track, radio, audiobook)
- Add library toggle methods to detail screens (album, playlist, podcast, audiobook)
- Add library toggle for tracks within album and playlist detail screens
- Add library toggle methods to new_library_screen for audiobooks, podcasts, radio
- Update all context menu calls to use proper library toggle callbacks
- Make context menu background 25% lighter when adaptive colors are used
  for better contrast against detail screen backgrounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 09:40:19 +00:00
ChrisandClaude Opus 4.5 63473226f5 Fix navigation transitions and localize hardcoded strings
Phase 1 audit fixes:
- Use FadeSlidePageRoute for DebugLogScreen navigation (consistent transitions)
- Add volumePrecisionMode and volumePrecisionModeDescription to localizations
- Replace hardcoded empty state strings in all row widgets with localized versions:
  - album_row, artist_row, track_row, playlist_row
  - podcast_row, radio_station_row, audiobook_row, series_row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:55:20 +00:00
ChrisandClaude Opus 4.5 e3d257a6dc Add hi-res audio badge to track listings
Display a hi-res badge on tracks with lossless codec and either
sample rate > 48kHz or bit depth > 16. Badge uses adaptive primary
color muted with white to match album theme.

- Add HiResBadge widget with vector-drawn badge (no external assets)
- Show badge in album, playlist, and library track listings
- Fixed-width duration column to keep badges aligned
- Tooltip shows exact sample rate and bit depth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:31:03 +00:00
ChrisandClaude Opus 4.5 671b31de61 Standardize library list item height and add addTracksToQueue API
- Set itemExtent to 67px for consistent 10-item display across all library lists
- Remove vertical padding from ListTile contentPadding for better fit
- Add dedicated addTracksToQueue method to properly add tracks without interrupting playback
- Update album and playlist detail screens to use new queue method
- Add memCacheWidth/Height to series row for consistent image caching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:08:20 +00:00
ChrisandClaude Opus 4.5 67a4ebd771 Refactor player selector and optimize library list performance
Use centralized GlobalPlayerOverlay.showPlayerSelectorForAction() for queue
actions, reducing code duplication. Add itemExtent to library lists and reduce
image sizes for smoother scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:25:10 +00:00
ChrisandClaude Opus 4.5 23af5379a6 Fix external source detection for Spotify as music provider (issue #27)
The external source detection was incorrectly flagging Spotify tracks
played through Music Assistant as "external" because it checked for
spotify:// URI patterns without considering whether MA had an active queue.

Changes:
- Add activeQueue field to Player model to track MA queue state
- Fix detection priority: active_queue presence now indicates MA control
- Remove spotify:// from fallback URI checks (needs app_id context)
- Only flag physical inputs (optical, hdmi, etc.) in URI fallback
- Update provider event handling to use same detection logic
- Improve debug logging to include active_queue state

Detection logic priority:
1. app_id == 'music_assistant' → NOT external
2. active_queue has value → NOT external (key fix for Spotify provider)
3. app_id set and != 'music_assistant' → external
4. Fallback: Check physical input URIs only

Fixes #27

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 06:07:15 +00:00
ChrisandClaude Opus 4.5 86bdad5534 Simplify auth to use MA built-in authentication
- Remove auth strategy pattern (NoAuth, BasicAuth, Authelia, MAAuth)
- Simplify AuthManager to use MA's native token-based auth
- Streamline login screen with cleaner UI
- Remove unused auth strategy files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:27:37 +00:00
ChrisandClaude Opus 4.5 7ee27f6e75 Performance optimization: isolate palette extraction and provider selects
- Move palette extraction to isolate using compute() to prevent main thread blocking
- Add image package for pure-Dart isolate-compatible image decoding
- Add extractColorSchemesFromUrl() with LRU cache for downloaded images
- Replace context.watch() with context.select() to reduce unnecessary rebuilds
- Update all detail screens to use isolate-based color extraction
- Optimize library screens and widgets to only rebuild on specific field changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:24:56 +00:00
ChrisandClaude Opus 4.5 633f489bfa Add remote connection documentation
Documents WebRTC remote connection investigation and findings.
Feature on hold due to one-way channel bug. See docs for details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:19:20 +00:00
ChrisandClaude Opus 4.5 61503cbe49 Fix Flutter 3.38 slider grey rectangles and library tab scroll
- Replace Material Slider with custom drawn sliders in:
  - expandable_player.dart (progress bar)
  - volume_control.dart (volume slider)
- Add seek debounce to prevent rapid-fire seeks
- Use standard PageScrollPhysics in library to fix multi-tab scroll

Flutter 3.38's Material 3 Slider uses OverlayPortal which causes
grey rectangles in our overlay context. Custom sliders avoid this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:48:43 +00:00
ChrisandClaude Opus 4.5 0b49c5158c Update to Flutter 3.38 compatibility
- Upgrade Gradle 8.1.0 → 8.7.0
- Upgrade Kotlin 1.9.10 → 2.1.0
- Update intl ^0.19.0 → ^0.20.0
- Fix CardTheme → CardThemeData (Flutter 3.38 API change)
- Update CI workflow to use Flutter 3.38

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:37:23 +00:00
146 changed files with 18314 additions and 10061 deletions
+3
View File
@@ -0,0 +1,3 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
+388
View File
@@ -0,0 +1,388 @@
# Android Auto Implementation Plan for Ensemble
## 1. Overview
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
### What we're building
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
- Voice search support
### Why builtin player only
Android Auto requires local audio output. The builtin player plays audio through `just_audio` on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
---
## 2. Architecture
### Provider reference
`MassivAudioHandler` has no reference to `MusicAssistantProvider` today. The handler is created before the provider (in `main()` before `runApp`). Solution: add a `setProvider()` method and call it from `_MusicAssistantAppState.initState()`.
```
main.dart
audioHandler = await AudioService.init(...) // global, created first
runApp(MusicAssistantApp())
_MusicAssistantAppState.initState()
_musicProvider = MusicAssistantProvider()
audioHandler.setProvider(_musicProvider) // NEW
```
### Browse → play call flow
```
Android Auto MassivAudioHandler MusicAssistantProvider
| | |
getChildren('root') -----> | returns category items |
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
| populates _trackQueueCache |
playFromMediaId('track|lib|7|plist|lib|5') -> |
| lookup _trackQueueCache |
| provider.playTracks(builtinId, tracks, startIndex: i)
```
---
## 3. Media ID Scheme
Separator: `|` (pipe). Provider names use underscores/colons, never pipes — safe.
| ID | Meaning |
|----|---------|
| `root` | Root (browsable) |
| `recent` | Currently playing track (playable) |
| `cat\|favorites` | Favorites category (browsable) |
| `cat\|playlists` | Playlists category (browsable) |
| `cat\|albums` | Albums category (browsable) |
| `cat\|radio` | Radio stations category (browsable) |
| `playlist\|{provider}\|{itemId}` | A playlist (browsable) |
| `album\|{provider}\|{itemId}` | An album (browsable) |
| `track\|{tProvider}\|{tItemId}\|{ctxType}\|{ctxProvider}\|{ctxId}` | A track in context (playable) |
| `radio\|{provider}\|{itemId}` | A radio station (playable) |
The `ctxType|ctxProvider|ctxId` suffix in track IDs encodes the parent container. Context types: `plist`, `album`, `favs`, `search`.
**Track queue cache key** = `{ctxType}|{ctxProvider}|{ctxId}` (e.g. `plist|spotify|abc`). Stored when building children, looked up in `playFromMediaId`.
---
## 4. Browse Hierarchy
```
root
├── recent (playable, shown if something is/was playing)
├── cat|favorites (browsable → favorite tracks, all playable)
├── cat|playlists (browsable → list of playlists, each browsable)
│ └── playlist|{provider}|{id} → tracks (playable)
├── cat|albums (browsable → list of albums, each browsable)
│ └── album|{provider}|{id} → tracks (playable)
└── cat|radio (browsable → radio stations, each playable)
```
Artists are **not included**. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
---
## 5. Track Queue Cache
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
```dart
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
// Value: ordered list of tracks in that container
final Map<String, List<ma.Track>> _trackQueueCache = {};
```
When `getChildren('playlist|spotify|abc')` is called:
1. Fetch tracks via `provider.getPlaylistTracksWithCache('spotify', 'abc')`
2. Store `_trackQueueCache['plist|spotify|abc'] = tracks`
3. Return each track as a playable `MediaItem` with id `track|{t.provider}|{t.itemId}|plist|spotify|abc`
When `playFromMediaId('track|lib|7|plist|spotify|abc')` is called:
1. Parse ctxKey = `plist|spotify|abc`
2. Look up `_trackQueueCache['plist|spotify|abc']`
3. Find track index where `itemId == '7'`
4. Call `provider.playTracks(builtinId, tracks, startIndex: index)`
---
## 6. Key Code for `massiv_audio_handler.dart`
### New imports
```dart
import 'package:rxdart/rxdart.dart';
import '../../providers/music_assistant_provider.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../models/media_item.dart' as ma;
```
Note: `audio_service` exports its own `MediaItem`. Use `ma.MediaItem`, `ma.Track`, `ma.Album`, `ma.Playlist` for app models. The bare `MediaItem` refers to the audio_service type throughout new methods.
### New fields
```dart
MusicAssistantProvider? _provider;
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
void setProvider(MusicAssistantProvider provider) {
_provider = provider;
}
```
### `getChildren`
```dart
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _provider;
if (provider == null) return [];
switch (parentMediaId) {
case AudioService.browsableRootId:
return _buildRoot(provider);
case 'cat|favorites':
return _buildFavorites(provider);
case 'cat|playlists':
return _buildPlaylistList(provider);
case 'cat|albums':
return _buildAlbumList(provider);
case 'cat|radio':
return _buildRadioList(provider);
default:
if (parentMediaId.startsWith('playlist|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
}
if (parentMediaId.startsWith('album|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
}
return [];
}
}
```
### `playFromMediaId`
```dart
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId == 'recent') {
await provider.playPauseSelectedPlayer();
return;
}
if (mediaId.startsWith('radio|')) {
final parts = mediaId.split('|'); // ['radio', provider, itemId]
if (parts.length < 3) return;
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Station not found: $mediaId'),
);
await provider.playRadio(playerId, station);
return;
}
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _trackQueueCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
// Fallback: play single track (minimal Track object)
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
return;
}
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
```
### `search`
```dart
@override
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_trackQueueCache[ctxKey] = tracks;
return tracks.map((t) => MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _artUri(provider, t),
playable: true,
)).toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
```
### `subscribeToChildren`
```dart
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _childrenSubjects[parentMediaId]!.stream;
}
```
### `_artUri` helper
```dart
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
```
---
## 7. Files to Change
### `pubspec.yaml`
Add `rxdart: ^0.28.0` as direct dependency. The `^0.28.0` constraint matches the already-resolved transitive version so no upgrade occurs.
### `android/app/build.gradle`
Change:
```groovy
minSdkVersion flutter.minSdkVersion
```
To:
```groovy
minSdkVersion 23
```
Android Auto requires API 23+. `flutter.minSdkVersion` resolves to 21 — too low.
### `android/app/src/main/AndroidManifest.xml`
Add inside `<application>` tag:
```xml
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
```
The `MediaBrowserService` intent filter on `AudioService` is already present — no changes needed there.
### `android/app/src/main/res/xml/automotive_app_desc.xml` (NEW)
```xml
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
```
### `lib/main.dart`
In `_MusicAssistantAppState.initState()`, after creating `_musicProvider`:
```dart
audioHandler.setProvider(_musicProvider);
```
### `lib/services/audio/massiv_audio_handler.dart`
Add ~200 lines. New imports, fields, `setProvider()`, `getChildren()`, `playFromMediaId()`, `search()`, `subscribeToChildren()`, and private builder helpers. All existing code is untouched.
---
## 8. Testing with DHU (No Car Required)
### Install DHU
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
Or via command line:
```bash
sdkmanager "extras;google;auto"
```
### Enable Android Auto developer mode on phone
1. Settings → Apps → Android Auto → tap version number 10 times
2. In Android Auto developer settings: enable "Unknown sources"
### Run the test session
```bash
# Terminal 1: forward the port
adb forward tcp:5277 tcp:5277
# Terminal 2: start the DHU
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
```
In Android Auto developer settings on phone: tap "Start head unit server".
### Build and install
```bash
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
```
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
### Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| App not visible in DHU | Missing `automotive_app_desc.xml` or wrong meta-data name | Check manifest and XML |
| App visible, browse is empty | `setProvider` not yet called | Check `initState` wiring |
| Build fails | `minSdkVersion` too low | Check `build.gradle` |
| Track plays but wrong song | `_trackQueueCache` miss | Ensure `getChildren` ran before `playFromMediaId` |
---
## 9. Implementation Order
Do these steps in order to test incrementally:
1. Android config files (`build.gradle`, `AndroidManifest.xml`, `automotive_app_desc.xml`) — verify app appears in DHU before writing Dart
2. `pubspec.yaml` + `flutter pub get`
3. `massiv_audio_handler.dart`: add `setProvider`, fields, `subscribeToChildren` — minimum to not crash
4. `main.dart`: wire `setProvider`
5. `massiv_audio_handler.dart`: `getChildren` with hardcoded root categories — verify tree appears in DHU
6. `massiv_audio_handler.dart`: add all `_build*` helpers one by one, test each
7. `massiv_audio_handler.dart`: add `playFromMediaId` — test track playback
8. `massiv_audio_handler.dart`: add `search` — test voice search
---
## 10. Not In Scope
- **Artists browse** — too many taps for driving; albums alone suffice
- **Audiobooks/podcasts** — different UX needs; add later
- **Queue management from Android Auto** — MA queue model is complex
- **Push updates** — `notifyChildrenChanged` stubbed but not wired to sync events
- **Android Automotive OS** — different platform, requires a separate flavor
-391
View File
@@ -1,391 +0,0 @@
# Ensemble Codebase Intensive Audit Prompt
## Overview
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
**Codebase Statistics:**
- 90 Dart files, ~40,583 lines of code
- 18 screens, 27 services, 6 providers
- Architecture: Provider pattern with ChangeNotifier
- Audio: just_audio + flutter_pcm_sound + audio_service
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
---
## Audit Scope
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
1. **File path and line number(s)**
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
3. **Description** of the issue
4. **Impact** if left unfixed
5. **Recommended fix** with code example where applicable
---
## SECTION 1: SECURITY AUDIT
### 1.1 Credential Storage (CRITICAL PRIORITY)
Audit `/lib/services/settings_service.dart` for:
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
- [ ] Auth tokens stored without encryption
- [ ] Serialized credentials in JSON format (lines 169-190)
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
### 1.2 Certificate Validation
Audit `/lib/services/auth/auth_manager.dart` for:
- [ ] HTTPS redirect handling for local IPs
- [ ] Certificate validation disabled scenarios
- [ ] MITM vulnerability exposure
### 1.3 Authentication Strategies
Audit all files in `/lib/services/auth/`:
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
- [ ] `no_auth_strategy.dart` - Public server exposure
### 1.4 Input Validation
Audit `/lib/services/music_assistant_api.dart`:
- [ ] URI construction without validation (lines 1346-1360)
- [ ] Provider/itemId injection risks
- [ ] WebSocket message validation
### 1.5 Information Disclosure
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
- [ ] Server version exposure via `server_info`
- [ ] Error messages leaking technical details
---
## SECTION 2: MEMORY MANAGEMENT AUDIT
### 2.1 Static Memory Leaks
Audit `/lib/models/player.dart`:
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
- [ ] Cleanup only removes 50 of 100+ entries
### 2.2 Unbounded Caches
Audit `/lib/services/cache_service.dart`:
- [ ] `_albumTracksCache` - no size limit
- [ ] `_artistAlbumsCache` - no TTL expiration
- [ ] `_searchCache` - never evicted
- [ ] `_playerTrackCache` - only cleared on disconnect
### 2.3 Stream/Timer Cleanup
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_playerStateTimer` disposal (line 4435-4445)
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
- [ ] `_localPlayerEventSubscription` cleanup
- [ ] `_playerUpdatedEventSubscription` cleanup
- [ ] `_playerAddedEventSubscription` cleanup
Audit `/lib/services/hardware_volume_service.dart`:
- [ ] `_volumeUpController` never closed (lines 74-85)
- [ ] `_volumeDownController` never closed
### 2.4 Stream Subscription Leaks
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_api!.connectionState.listen()` - subscription not stored
- [ ] Old subscriptions not cancelled before new API instance
---
## SECTION 3: RACE CONDITION AUDIT
### 3.1 Player State Dual Updates
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] Timer polling (every 5s) vs WebSocket events racing
- [ ] `_updatePlayerState()` called from both paths
- [ ] No mutex/lock protection
### 3.2 Async Method Reentrancy
Audit `selectPlayer()` method:
- [ ] `async` method without guard against concurrent calls
- [ ] `_selectedPlayer` modified before and after `await`
- [ ] State could change during await
### 3.3 Fire-and-Forget Operations
Search for `unawaited(` and `() async {` patterns:
- [ ] `_persistPlaybackState()` - database save not awaited
- [ ] Pause operations fire-and-forget (lines 3876-3894)
- [ ] Preload operations (line 2707)
### 3.4 PCM Player State Machine
Audit `/lib/services/pcm_audio_player.dart`:
- [ ] 9-state machine with complex transitions (lines 32-37)
- [ ] Auto-recovery race condition (lines 215-229)
- [ ] Feed loop during pause transition (lines 174-179)
---
## SECTION 4: ERROR HANDLING AUDIT
### 4.1 Silent Exception Handlers (CRITICAL)
Find all instances of:
```dart
} catch (e) {}
} catch (_) {}
```
Known locations:
- [ ] `music_assistant_provider.dart` (multiple)
- [ ] `player_provider.dart` (3 instances)
- [ ] `recently_played_service.dart`
### 4.2 Missing Error Recovery
- [ ] No exponential backoff on connection retry
- [ ] No retry logic for failed API calls
- [ ] WebSocket timeout handling (30 seconds - too long?)
### 4.3 Null Safety Violations
Find all `!` (null assertion) operators:
- [ ] `search_screen.dart` (20+ instances)
- [ ] `models/media_item.dart`
- [ ] `models/player.dart`
- [ ] `item.mediaItem! as Artist` patterns
---
## SECTION 5: ARCHITECTURAL ISSUES
### 5.1 God Class
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] 4,469 lines - violates Single Responsibility
- [ ] 386 conditional branches
- [ ] Mixing: connection, players, library, sync, audio, position tracking
**Recommend splitting into:**
- ConnectionProvider (~500 LOC)
- PlayerManager (~800 LOC)
- LibraryProvider (~1000 LOC)
- LocalPlaybackManager (~600 LOC)
### 5.2 Oversized Widget Files
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
- [ ] `global_player_overlay.dart` - 737 lines
- [ ] `new_library_screen.dart` - 2,521 lines
### 5.3 Global Key Anti-Pattern
Audit `/lib/widgets/global_player_overlay.dart`:
- [ ] `globalPlayerKey` for state access
- [ ] `_overlayStateKey` for overlay state
- [ ] Should use Provider/context instead
### 5.4 Primitive Obsession
- [ ] View modes as strings ('grid2', 'grid3', 'list')
- [ ] Media types as strings ('track', 'album', 'artist')
- [ ] Should be strongly-typed enums
---
## SECTION 6: CODE QUALITY ISSUES
### 6.1 Testing (CRITICAL)
- [ ] **ZERO test files exist** - confirm this
- [ ] No unit tests, widget tests, or integration tests
- [ ] flutter_test dependency exists but unused
### 6.2 Hardcoded Values
Find and document all:
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
- [ ] Duration values not using `Timings` constants
- [ ] Magic numbers (16, 4, 5, 100, 50)
- [ ] String literals for media types
### 6.3 Mixed Async Patterns
- [ ] `.then()` chains alongside async/await
- [ ] `.whenComplete()` usage
- [ ] Consolidate to async/await
### 6.4 Dead Code
- [ ] `@Deprecated` methods still implemented
- [ ] `animation_debugger.dart` - dev-only?
- [ ] Unused imports
### 6.5 TODO Comments
Document all actionable TODOs:
- [ ] `queue_screen.dart:214` - queue item removal
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
---
## SECTION 7: UI/UX AUDIT
### 7.1 Accessibility (CRITICAL)
Audit for missing:
- [ ] `Semantics` widgets on interactive elements
- [ ] `Tooltip` on icon buttons
- [ ] Screen reader support
- [ ] Touch target sizes (minimum 48dp)
- [ ] Color contrast validation (WCAG AA)
### 7.2 Performance Issues
Audit `/lib/widgets/expandable_player.dart`:
- [ ] No `RepaintBoundary` wrappers
- [ ] 55+ `setState()` calls
- [ ] 3 AnimationControllers with multiple listeners
- [ ] Title height cache invalidation (lines 118+)
### 7.3 Double Opacity Bug
Audit `/lib/widgets/player/player_controls.dart`:
```dart
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
- [ ] Double `.withOpacity()` application
### 7.4 Missing Features
- [ ] No landscape orientation support
- [ ] No tablet layout optimization
- [ ] No loading skeleton animations
- [ ] No haptic feedback (except one location)
---
## SECTION 8: AUDIO PLAYBACK AUDIT
### 8.1 Position Interpolation
Audit `/lib/services/position_tracker.dart`:
- [ ] No timeout on anchor updates - unbounded drift possible
- [ ] Duration interpolation can exceed track duration
- [ ] Stale timestamp handling (>30 seconds)
### 8.2 Sendspin Protocol
Audit `/lib/services/sendspin_service.dart`:
- [ ] Proxy auth retry without deduplication (lines 188-209)
- [ ] WebSocket binary frame parsing
- [ ] Clock synchronization accuracy
### 8.3 Audio Resource Cleanup
Audit `/lib/services/audio/massiv_audio_handler.dart`:
- [ ] Audio session interrupt listeners not disposed
- [ ] Audio focus not explicitly released
### 8.4 Mixed Playback Modes
- [ ] just_audio initialized but unused during Sendspin
- [ ] Resource allocation for unused player
- [ ] Potential audio focus conflicts
---
## SECTION 9: NETWORKING AUDIT
### 9.1 WebSocket Lifecycle
Audit `/lib/services/music_assistant_api.dart`:
- [ ] Heartbeat interval (30s) - appropriate?
- [ ] Reconnection logic and timing
- [ ] Pending request cleanup on disconnect
### 9.2 Caching Strategy
- [ ] No incremental/delta sync (full library every 5 minutes)
- [ ] Cache staleness not communicated to UI
- [ ] No query result pagination (fetches up to 5000 items)
### 9.3 Rate Limiting
- [ ] No client-side rate limiting
- [ ] Search requests not throttled
- [ ] Could overload server
### 9.4 Offline Support
Audit `/lib/services/offline_action_queue.dart`:
- [ ] Queue execution not atomic (lines 118-130)
- [ ] Action could execute twice on crash
- [ ] Queue stored unencrypted
---
## SECTION 10: DATABASE AUDIT
### 10.1 Schema Review
Audit `/lib/database/database.dart`:
- [ ] Table indexes for query performance
- [ ] Foreign key constraints
- [ ] Migration strategy
### 10.2 Query Patterns
- [ ] N+1 query prevention
- [ ] Transaction usage for atomic operations
- [ ] Connection pooling
### 10.3 Data Consistency
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
- [ ] User modifications during background sync
- [ ] Conflict resolution strategy
---
## Deliverables
After completing the audit, provide:
### 1. Executive Summary
- Overall code quality score (1-10)
- Top 5 critical issues requiring immediate attention
- Estimated technical debt in developer-hours
### 2. Issue Tracker
Create a prioritized list of all issues found:
| ID | Severity | Category | File:Line | Description | Effort |
|----|----------|----------|-----------|-------------|--------|
### 3. Recommended Roadmap
Sprint-based plan to address findings:
- Sprint 1 (Immediate): Security fixes, silent exception handlers
- Sprint 2-3: God class decomposition, test infrastructure
- Sprint 4-6: Memory leak fixes, accessibility
- Long-term: Performance optimization, monitoring
### 4. Code Examples
For each CRITICAL and HIGH severity issue, provide:
- Current problematic code
- Recommended refactored code
- Migration steps
---
## Files to Prioritize
Start your audit with these critical files:
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
2. `/lib/services/settings_service.dart` - Credential storage
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
5. `/lib/services/auth/` directory - Security
6. `/lib/models/player.dart` - Memory leaks
7. `/lib/services/cache_service.dart` - Unbounded caches
8. `/lib/services/position_tracker.dart` - Audio sync issues
---
## Known Critical Issues (Pre-Identified)
These issues have been identified and MUST be verified and documented:
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
6. **HIGH: Race conditions** - Player state polling vs events
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
---
## Audit Execution Notes
- Use `Grep` tool to search for patterns across the codebase
- Use `Read` tool to examine specific file sections
- Document line numbers for all findings
- Provide code snippets for context
- Cross-reference related issues across files
- Verify pre-identified issues exist and document their exact locations
**Total estimated audit time: 4-6 hours for thorough coverage**
-419
View File
@@ -1,419 +0,0 @@
# Ensemble Codebase - Comprehensive Audit Report
**Audit Date:** 2026-01-01
**Codebase:** Ensemble Flutter App (Music Assistant Client)
**Version:** 2.7.3-beta+35
**Total Lines of Code:** ~40,583 Dart
**Files Analyzed:** 90 Dart files
---
## Executive Summary
### Overall Code Quality Score: 5.5/10
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
### Critical Statistics
| Metric | Value | Status |
|--------|-------|--------|
| Test Coverage | 0% | CRITICAL |
| God Class Size | 4,469 LOC | CRITICAL |
| Silent Exception Handlers | 7 | CRITICAL |
| Plaintext Credentials | 4 locations | CRITICAL |
| Memory Leak Locations | 6 | HIGH |
| Race Conditions | 4 | HIGH |
| Accessibility (Semantics) | 0 widgets | CRITICAL |
---
## Issue Summary by Severity
| Severity | Count | Categories |
|----------|-------|------------|
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
---
## CRITICAL ISSUES (Immediate Action Required)
### 1. Plaintext Credential Storage
**File:** `/lib/services/settings_service.dart`
**Lines:** 131-143, 147-159, 169-190, 209-222
Passwords and auth tokens stored in SharedPreferences without encryption.
```dart
// VULNERABLE CODE
static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPassword, password); // PLAINTEXT!
}
```
**Fix:** Use `flutter_secure_storage` for all sensitive data.
---
### 2. Zero Test Coverage
**Location:** Entire codebase
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
**Fix:** Establish test infrastructure immediately. Start with:
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
- Widget tests for player controls
- Integration tests for playback flows
---
### 3. God Class - MusicAssistantProvider
**File:** `/lib/providers/music_assistant_provider.dart`
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
Single class handles: connection, players, library, cache, audio, sync, offline queue.
**Fix:** Split into:
- `ConnectionManager` (~500 LOC)
- `PlayerManager` (~800 LOC)
- `LibraryCacheService` (~600 LOC)
- `SendspinManager` (~400 LOC)
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
---
### 4. Zero Accessibility Support
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
- **0** `Semantics` widgets
- **0** `semanticLabel` properties
- **1** `Tooltip` (entire app)
- Screen readers cannot navigate the app
**Fix:** Add Semantics to all interactive elements:
```dart
Semantics(
button: true,
label: isPlaying ? 'Pause playback' : 'Play',
child: IconButton(...)
)
```
---
### 5. Silent Exception Handlers
**Locations:** 7 instances across codebase
| File | Line | Code |
|------|------|------|
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
| music_assistant_api.dart | 2542 | `catch (e) {}` |
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
---
### 6. Audio Session Listeners Not Disposed
**File:** `/lib/services/audio/massiv_audio_handler.dart`
**Lines:** 45-73
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
**Fix:** Store subscriptions and cancel in dispose():
```dart
StreamSubscription? _interruptionSubscription;
_interruptionSubscription = session.interruptionEventStream.listen(...);
void dispose() {
_interruptionSubscription?.cancel();
}
```
---
## HIGH SEVERITY ISSUES
### 7. Connection State Subscription Not Stored
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
---
### 8. Player State Race Condition
**File:** `/lib/providers/music_assistant_provider.dart`
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
---
### 9. Async Method Reentrancy
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
---
### 10. Unbounded Search Cache
**File:** `/lib/services/cache_service.dart:31-32`
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
---
### 11. Static Memory Leak
**File:** `/lib/models/player.dart:96, 139-144`
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
---
### 12. No Database Indexes
**File:** `/lib/database/database.dart`
No secondary indexes defined. Query performance degrades as database grows.
---
### 13. No Incremental Sync
**File:** `/lib/services/sync_service.dart:150-165`
Full library fetch (up to 4000 items) on every sync instead of delta updates.
---
### 14. Offline Queue Not Atomic
**File:** `/lib/services/offline_action_queue.dart:80-108`
Queue persistence happens after all processing - crash during processing loses successful actions.
---
### 15. Auth Retry Without Deduplication
**File:** `/lib/services/sendspin_service.dart:189-209`
Multiple auth messages can be sent during rapid reconnection attempts.
---
### 16. Pending Requests Not Completed on Disconnect
**File:** `/lib/services/music_assistant_api.dart:2911`
`_pendingRequests.clear()` abandons Completers without completing them with errors.
---
### 17. HTTP Fallback for Local IPs
**File:** `/lib/services/auth/auth_manager.dart:51-58`
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
---
### 18. Certificate Validation Bypassed
**File:** `/lib/services/auth/auth_manager.dart:210-236`
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
---
## MEDIUM SEVERITY ISSUES
### 19. Double Opacity Bug
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
```dart
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
---
### 20. Mixed Async Patterns
**Locations:** 13 instances
`.then()` chains mixed with `async/await` throughout the codebase.
---
### 21. Hardcoded Colors
**Locations:** 55 instances
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
---
### 22. View Modes as Strings
**Locations:** 80+ instances
`'grid2'`, `'grid3'`, `'list'` should be enums.
---
### 23. Fire-and-Forget Pause Commands
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
Server pause command uses `unawaited()` - errors are silently lost.
---
### 24. Position Tracker Anchor Timeout Missing
**File:** `/lib/services/position_tracker.dart:24-26`
No timeout on stale anchor - interpolation can drift indefinitely.
---
### 25. PCM Buffer No Overflow Protection
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
Audio buffer grows unbounded if data arrives faster than consumption.
---
### 26. No Client-Side Rate Limiting
**Location:** App-wide
No throttling on API requests during rapid user interactions.
---
### 27. Portrait-Only Lock
**File:** `/lib/main.dart:64-68`
No landscape orientation support.
---
### 28. Touch Targets Below 48dp
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
---
### 29. WCAG Contrast Not Validated
**File:** `/lib/theme/palette_helper.dart`
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
---
### 30. StreamControllers Not Closed
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
`_volumeUpController` and `_volumeDownController` never closed in dispose().
---
## LOW SEVERITY ISSUES
### 31-34. Naming Inconsistencies
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
### 35-38. Missing TODO Implementations
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
### 39-42. Dead/Unused Code
- `@Deprecated` methods still implemented
- `library_stats.dart` appears unused
- `animation_debugger.dart` disabled by default
### 43-44. Minor Documentation Gaps
Missing method documentation, no architecture diagram.
---
## Recommended Remediation Roadmap
### Sprint 1 (Week 1-2): Security & Critical Fixes
1. Migrate credentials to `flutter_secure_storage`
2. Add logging to all silent catch blocks
3. Store connection state subscription
4. Fix audio session listener disposal
5. Add basic test infrastructure (10% coverage target)
### Sprint 2 (Week 3-4): Memory & Race Conditions
6. Add mutex to player state updates
7. Add reentrancy guard to `selectPlayer()`
8. Implement bounded caches with LRU eviction
9. Fix static memory leak in Player model
10. Store and cancel all stream subscriptions
### Sprint 3 (Week 5-6): Architecture Refactoring
11. Split `MusicAssistantProvider` into 5 smaller classes
12. Split `expandable_player.dart` into component widgets
13. Replace GlobalKey anti-patterns with Provider
14. Convert string constants to enums
15. Extract hardcoded colors to constants file
### Sprint 4 (Week 7-8): Accessibility & UI
16. Add Semantics to all interactive elements
17. Increase touch targets to 48dp minimum
18. Implement WCAG contrast validation
19. Fix double opacity bug
20. Add landscape orientation support
### Sprint 5 (Week 9-10): Performance & Polish
21. Add database indexes
22. Implement incremental sync
23. Add client-side rate limiting
24. Fix atomic offline queue processing
25. Achieve 30% test coverage target
---
## Files Requiring Most Attention
| File | LOC | Issues | Priority |
|------|-----|--------|----------|
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
| `settings_service.dart` | 811 | 4 | CRITICAL |
| `expandable_player.dart` | 2,485 | 6 | HIGH |
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
| `cache_service.dart` | 370 | 4 | HIGH |
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
| `player.dart` | 422 | 2 | HIGH |
| `auth_manager.dart` | 300 | 3 | HIGH |
---
## Estimated Technical Debt
| Category | Effort (Dev-Days) |
|----------|-------------------|
| Security Fixes | 3-5 |
| Test Infrastructure | 10-15 |
| Architecture Refactoring | 15-20 |
| Memory Leak Fixes | 3-5 |
| Accessibility | 5-8 |
| UI/UX Polish | 3-5 |
| Performance | 5-8 |
| **Total** | **44-66 dev-days** |
---
## Conclusion
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
The most urgent priorities are:
1. **Secure credential storage** - immediate security risk
2. **Add test infrastructure** - enables safe refactoring
3. **Split the god class** - enables maintainability
4. **Add accessibility** - legal/compliance requirement
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
---
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
-119
View File
@@ -1,119 +0,0 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
-257
View File
@@ -1,257 +0,0 @@
# Ensemble Codebase Audit Report
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
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:
| 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
---
## Critical Issues
### 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
### 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
---
## High Severity Issues
### 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 |
### 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 |
### 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 |
---
## Medium Severity Issues
### 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
### 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
### 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
---
## Quick Wins (High Impact, Low Effort)
| # | 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 |
---
## Refactoring Roadmap
### 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.
---
## Positive Findings
The codebase already demonstrates many good practices:
- **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 Priority
| 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.
-57
View File
@@ -1,57 +0,0 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
+167
View File
@@ -0,0 +1,167 @@
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
**Date:** 2026-03-03
**Current server:** 2.8.0 beta 12
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
---
## Summary
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
---
## Breaking: Player Merging (PR #3150)
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
### What Ensemble does today
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
- When ungrouped: hides Sendspin, shows original Cast
- Mappings persisted to database for cold start restoration
### Impact on Ensemble
| Component | Location | Impact | Severity |
|---|---|---|---|
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
### Assessment
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
### Action required
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
3. Clear stale database mappings on first connection to a beta 14+ server
---
## Sendspin v4.0 (PR #3158)
Major rewrite of the Sendspin server.
### Changes
- New `SendspinPlaybackSession` coordinator architecture
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
- Max buffer capacity 5 → 30 seconds
- Multiple Sendspin servers with automatic failover
- Per-client codec/sample-rate/bit-depth options
- Album artwork persists during skip/pause
- Reduced latency for live streams
### Impact on Ensemble
| Change | Impact |
|---|---|
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
| Buffer increase | No client impact |
### Action required
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
2. Confirm unknown fields in protocol messages are ignored gracefully
---
## Sendspin-Chromecast Bridge (PR #3255)
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
### Impact on Ensemble
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
---
## Group Volume / Grouping Fixes
| PR | Change | Ensemble Impact |
|---|---|---|
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
### Action required
1. Test group volume behavior — may "just work better" with server-side fixes
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
---
## Sendspin Bug Fixes
| PR | Fix | Ensemble Relevance |
|---|---|---|
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
---
## New APIs — Opportunities
| Feature | PR | Details | Ensemble Opportunity |
|---|---|---|---|
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
---
## Also Changed in Beta 13 (Skipped)
Relevant changes from beta 13 (between current beta 12 and target beta 14):
- Group volume mute support (#3034)
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
- Snapcast player availability alignment (#3104)
- DLNA reconnection fix (#3132)
---
## Recommended Action Plan
### Before upgrading
- [ ] Verify player selection fallback handles missing player IDs gracefully
- [ ] Back up current configuration
### Immediate (after upgrading)
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
- [ ] Test group volume behavior with new server-side fixes
- [ ] Test player selection persistence across app restarts
### Short-term
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
- [ ] Clear stale database mappings on beta 14+ detection
- [ ] Test mixed-media playlist handling if playlists are used
### Opportunistic
- [ ] Add playback speed control (audiobook/podcast feature)
- [ ] Add save-queue-to-playlist
- [ ] Explore Genres v2 browsing
+38 -15
View File
@@ -1,6 +1,12 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/badge/Release-v3.0.4-blue?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![GitHub Downloads](https://img.shields.io/github/downloads/CollotsSpot/Ensemble/latest/total?style=for-the-badge&logo=android&label=APK%20Downloads&color=green)](https://github.com/CollotsSpot/Ensemble/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
---
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
@@ -17,6 +23,12 @@ This application was built with AI-assisted development using **Claude Code** an
---
## Also See
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
---
## Features
### Local Playback
@@ -74,6 +86,13 @@ This application was built with AI-assisted development using **Claude Code** an
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Android Auto
- **Media Browsing** - Browse your library from your car's display with categories for Home, Music, Audiobooks, Podcasts, and Radio
- **Voice Search** - Find and play tracks using voice commands
- **Full Playback Controls** - Play, pause, and skip from your car's head unit
- **Album Art & Metadata** - Track info and artwork displayed on your car's screen
- **Smart Queueing** - Tap any track in an album or playlist to play from that point with the full queue
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
@@ -127,25 +146,22 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Android Auto
Since Ensemble is not installed from the Play Store, you need to enable unknown sources in Android Auto:
1. Open **Android Auto** settings on your phone
2. Tap the **Version** number at the bottom 10 times to enable developer mode
3. Tap the three-dot menu and open **Developer settings**
4. Enable **Unknown sources**
5. Connect your phone to your car via USB or wirelessly
6. Ensemble will appear in Android Auto's app list
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
## Requirements
@@ -154,6 +170,13 @@ Ensemble supports multiple authentication methods:
- Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
## Sponsors
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License
MIT License
+41
View File
@@ -2,6 +2,33 @@
## v2.9.2-beta (TBD)
### New Features
#### Context Menus
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
#### Podcasts
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
#### Audio Quality
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
### Improvements
#### UI/UX
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
- **Search library filter**: Filled icon indicates active filter state without background highlight.
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
- **Navigation transitions**: Smoother fade/slide animations between screens.
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
#### Performance
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
### Bug Fixes
#### Audiobooks
@@ -29,9 +56,23 @@
#### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
#### Playback
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
#### UI Fixes
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
- **Fixed library tab scroll position**: Maintains position when switching tabs.
### Technical Changes
- Flutter 3.38 compatibility
- Simplified authentication using MA built-in auth
- Added `material_symbols_icons` package for book_2 icon
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
+18 -3
View File
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace "com.musicassistant.music_assistant"
compileSdk 35
namespace "com.collotsspot.ensemble"
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
@@ -58,7 +58,7 @@ android {
}
defaultConfig {
applicationId "com.musicassistant.music_assistant"
applicationId "com.collotsspot.ensemble"
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
@@ -70,6 +70,21 @@ android {
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter {
+10
View File
@@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -48,6 +54,10 @@
</intent-filter>
</receiver>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,229 @@
package com.collotsspot.ensemble
import android.content.Context
import android.database.ContentObserver
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
// Volume observer for lockscreen volume changes
// Watches system STREAM_MUSIC volume and mirrors changes to the MA player
// when a remote/group player is active
private var audioManager: AudioManager? = null
private var volumeObserver: VolumeContentObserver? = null
private var isObservingVolume = false
private var lastKnownVolume: Int = -1
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
"startVolumeObserver" -> {
// Start observing system volume for lockscreen volume changes.
// Optional: initialVolume (0-100) sets the system volume to match
// the MA player's current volume for a consistent HUD display.
val initialVolume = call.argument<Int>("initialVolume")
startVolumeObserver(initialVolume)
result.success(null)
}
"stopVolumeObserver" -> {
stopVolumeObserver()
result.success(null)
}
"syncSystemVolume" -> {
// Sync system volume to match MA player volume (0-100)
val volume = call.argument<Int>("volume") ?: 0
syncSystemVolume(volume)
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
/// Start observing system STREAM_MUSIC volume changes.
/// When a change is detected, sends an "absoluteVolumeChange" event to Flutter
/// with the new volume mapped to 0-100 scale.
private fun startVolumeObserver(initialVolume: Int?) {
if (isObservingVolume) return
val am = audioManager ?: return
// Optionally set system volume to match the MA player's current volume
if (initialVolume != null) {
syncSystemVolume(initialVolume)
}
lastKnownVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
volumeObserver = VolumeContentObserver(Handler(Looper.getMainLooper()))
contentResolver.registerContentObserver(
Settings.System.CONTENT_URI,
true,
volumeObserver!!
)
isObservingVolume = true
Log.d(TAG, "Volume observer STARTED, initial system volume: $lastKnownVolume")
}
/// Stop observing system volume changes.
private fun stopVolumeObserver() {
if (!isObservingVolume) return
volumeObserver?.let {
contentResolver.unregisterContentObserver(it)
}
volumeObserver = null
isObservingVolume = false
Log.d(TAG, "Volume observer STOPPED")
}
/// Set the system STREAM_MUSIC volume to match an MA player volume (0-100).
/// Maps MA's 0-100 range to the device's 0-maxVolume range.
private fun syncSystemVolume(maVolume: Int) {
val am = audioManager ?: return
val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume)
ignoringVolumeChange = true
am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0)
lastKnownVolume = systemVolume
ignoringVolumeChange = false
Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume")
}
/// ContentObserver that watches for system volume changes.
/// When volume changes (e.g., from lockscreen hardware buttons),
/// maps the change to a 0-100 value and sends to Flutter.
inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
if (ignoringVolumeChange) return
val am = audioManager ?: return
val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
if (currentVolume != lastKnownVolume) {
val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0
Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)")
lastKnownVolume = currentVolume
// Send the absolute volume (0-100) to Flutter
methodChannel?.invokeMethod("absoluteVolumeChange", maVolume)
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
// Pause volume interception when app goes to background so hardware buttons
// only control the system volume (e.g., YouTube, phone ringer) as expected.
private var wasListeningBeforePause = false
private var wasObservingBeforePause = false
override fun onPause() {
wasListeningBeforePause = isListening
wasObservingBeforePause = isObservingVolume
isListening = false
stopVolumeObserver()
Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)")
super.onPause()
}
override fun onResume() {
super.onResume()
if (wasListeningBeforePause) {
isListening = true
}
if (wasObservingBeforePause) {
startVolumeObserver(null)
}
Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)")
}
override fun onDestroy() {
stopVolumeObserver()
super.onDestroy()
}
}
@@ -1,87 +0,0 @@
package com.musicassistant.music_assistant
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+1 -7
View File
@@ -2,14 +2,11 @@ buildscript {
ext.kotlin_version = '2.1.0'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.0'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@@ -17,9 +14,6 @@ buildscript {
allprojects {
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
+242
View File
@@ -0,0 +1,242 @@
# Discovery Rows Submenu - Implementation Plan
## Option: Hybrid with Discovery Badge
### Overview
Transform the "Discovery Mixes" toggle into a section that:
1. Shows individual discovery row toggles
2. Adds those rows to the main home row list with visual distinction
3. Allows full reordering alongside other rows
---
## UI Design
### Settings Screen - Discovery Section
```
┌─────────────────────────────────────────────┐
│ ⚬ Discovery Mixes ● │
│ Show discovery-based rows on home screen │
│ ──────────────────────────────────────── │
│ Discovery rows from Music Assistant: │
│ │
│ ☑ New Releases ● │
│ ☑ Recently Played ● │
│ ☐ Recommended Albums ○ │
│ ☐ For You ○ │
│ │
│ [Refresh available rows] │
└─────────────────────────────────────────────┘
```
### Main Home Row List - With Badge
Discovery rows appear interleaved with a badge:
```
┌─────────────────────────────────────────────┐
│ ⋮ ☑ Recent Albums ● │
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
│ ⋮ ☑ Discover Artists ● │
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
│ ⋮ ☑ Favorite Albums ● │
└─────────────────────────────────────────────┘
```
Badge style: Small chip/icon indicating this is a dynamic discovery row.
---
## Technical Changes
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
**Add state for discovery folders:**
```dart
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
bool _isDiscoveryFoldersExpanded = false;
```
**New methods:**
```dart
Future<void> _loadDiscoveryFolders() async { }
Future<void> _refreshDiscoveryFolders() async { }
void _toggleDiscoveryRow(String itemId, bool enabled) { }
void _toggleDiscoveryMixes(bool enabled) { }
Widget _buildDiscoverySection() { }
```
**Modify existing:**
- `_loadSettings()` - load discovery row preferences
- Home row list builder - filter out individual discovery rows
- Add discovery section before home row list
### 2. Settings Service (`lib/services/settings_service.dart`)
**New methods:**
```dart
static Future<bool> getShowDiscoveryFolders() async { }
static Future<void> setShowDiscoveryFolders(bool value) async { }
// Per-row discovery preferences
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
```
**Storage key pattern:**
- `discovery_folders_enabled` (bool)
- `discovery_row_{itemId}` (bool per row)
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
**Already has:** `getDiscoveryFoldersWithCache()`
**Add method:**
```dart
Future<void> refreshDiscoveryFolders() async {
// Clear cache and refetch from API
_cacheService.clearDiscoveryFoldersCache();
await getDiscoveryFoldersWithCache();
}
```
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
**Changes to row rendering:**
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
```dart
// Before: static list of row IDs
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
// After: can include discovery row IDs
List<String> _homeRowOrder = [
'recent-albums',
'discovery:abc123', // Discovery row ID
'discover-artists',
'discovery:def456',
...
];
```
**New method:**
```dart
bool _isDiscoveryRowId(String rowId) {
return rowId.startsWith('discovery:');
}
String _getDiscoveryRowItemId(String rowId) {
return rowId.substring('discovery:'.length);
}
```
**Modify row rendering:**
- Check if rowId is a discovery row
- If yes, render DiscoveryRow widget with that folder
- Cache discovery folders for quick lookup
### 5. Localization (`lib/l10n/app_en.arb`)
**Add strings:**
```json
{
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
"discoveryRows": "Discovery rows from Music Assistant",
"refreshDiscoveryRows": "Refresh available rows",
"noDiscoveryRows": "No discovery rows available",
"discoveryRowBadge": "Music Assistant"
}
```
### 6. Models (`lib/models/recommendation_folder.dart`)
**Add property:**
```dart
class RecommendationFolder {
...
/// Unique ID for use in row ordering and settings
String get rowId => 'discovery:$itemId';
}
```
---
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Settings Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load discovery folders from Provider │
│ 2. Load per-row enabled state from Settings │
│ 3. Render section with toggles │
│ 4. User toggles row → save to Settings │
│ 5. User toggles main → save to Settings + update home row list │
│ 6. User refreshes → Provider.fetch → update UI │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Home Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load home row order from Settings │
│ 2. Identify discovery row IDs (prefix "discovery:") │
│ 3. For discovery rows: look up folder from Provider cache │
│ 4. Render appropriate widget (DiscoveryRow or other) │
│ 5. Handle reordering including discovery rows │
└─────────────────────────────────────────────────────────────────┘
```
---
## Migration Path
### Phase 1: Settings Screen (No breaking changes)
- Add discovery section to settings
- Load and display discovery folders
- Individual row toggles (but don't affect home screen yet)
- Refresh button
### Phase 2: Home Screen Integration
- Modify home row order to support discovery row IDs
- Render discovery rows in main list
- Add badge visual distinction
### Phase 3: Polish
- Translations
- Edge cases (folder disappears from MA)
- Performance (lazy loading)
---
## Edge Cases & Considerations
| Issue | Solution |
|-------|----------|
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
| User reorders then MA returns new folders | New folders appended to end of discovery section |
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
---
## Implementation Checklist
- [ ] Settings: Add `_discoveryFolders` state
- [ ] Settings: Add `_discoveryRowEnabled` map
- [ ] Settings: Implement `_loadDiscoveryFolders()`
- [ ] Settings: Implement `_buildDiscoverySection()`
- [ ] Settings: Add refresh button handler
- [ ] SettingsService: Add discovery row preference methods
- [ ] Provider: Add `refreshDiscoveryFolders()`
- [ ] HomeScreen: Support discovery row IDs in order list
- [ ] HomeScreen: Render discovery rows with badge
- [ ] Localization: Add new strings
- [ ] Test: Toggle discovery rows on/off
- [ ] Test: Reorder discovery rows
- [ ] Test: Refresh discovery folders
- [ ] Test: Migrate existing preference to new system
@@ -0,0 +1,328 @@
# Remote Connection - Future Implementation Guide
## Overview
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
## Current Status: On Hold
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
---
## Architecture
### How It Works
```
┌─────────────────┐ WebSocket ┌──────────────────────────┐
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
└─────────────────┘ └──────────────────────────┘
│ │
│ (when local unavailable) │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
└─────────────────┘ │ .io/ws │
│ └──────────────────────────┘
│ ICE candidates, SDP │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
│ Connection │ (ma-api) │ Endpoint │
└─────────────────┘ └──────────────────────────┘
```
### Components
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
### Data Channels
| Channel | Purpose | Traffic Pattern |
|---------|---------|-----------------|
| `ma-api` | JSON API requests/responses | High volume, large payloads |
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
---
## Key Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
### Files to Modify
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
---
## Implementation Steps
### Step 1: Add Dependencies
```yaml
# pubspec.yaml
dependencies:
flutter_webrtc: ^0.12.0 # or latest
```
### Step 2: Implement SignalingClient
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
```dart
class SignalingClient {
WebSocket? _socket;
Future<void> connect(String serverId) async {
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
// Send connect request
_send({
'type': 'connect-request',
'server_id': serverId,
});
// Listen for messages
_socket!.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final message = jsonDecode(data);
switch (message['type']) {
case 'answer':
onAnswer?.call(message['sdp']);
break;
case 'ice-candidate':
onIceCandidate?.call(message['candidate']);
break;
}
}
}
```
### Step 3: Implement WebRTCConnection
```dart
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _apiChannel;
RTCDataChannel? _sendspinChannel;
Future<void> connect(SignalingClient signaling) async {
// Create peer connection with STUN/TURN servers
_peerConnection = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
// TURN servers provided by signaling
],
});
// Create data channels BEFORE creating offer
_apiChannel = await _peerConnection!.createDataChannel(
'ma-api',
RTCDataChannelInit()..ordered = true,
);
_sendspinChannel = await _peerConnection!.createDataChannel(
'sendspin',
RTCDataChannelInit()..ordered = true,
);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
signaling.sendOffer(offer.sdp!);
// Handle answer from server
signaling.onAnswer = (sdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
};
}
}
```
### Step 4: Implement RemoteBridge
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
```dart
class RemoteBridge {
final WebRTCConnection _webrtc;
final StreamController<String> _messageController;
void sendCommand(String json) {
_webrtc.sendToApiChannel(json);
}
Stream<String> get messages => _messageController.stream;
}
```
### Step 5: Integrate with MusicAssistantAPI
```dart
class MusicAssistantAPI {
WebSocket? _localSocket;
RemoteBridge? _remoteBridge;
Future<void> connect(String host, {String? serverId}) async {
try {
// Try local connection first
_localSocket = await WebSocket.connect('ws://$host/ws');
} catch (e) {
// Fall back to remote if serverId provided
if (serverId != null) {
_remoteBridge = RemoteBridge();
await _remoteBridge!.connect(serverId);
}
}
}
}
```
---
## Critical Implementation Details
### 1. Channel Creation Order
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
- `ma-api` - for API traffic
- `sendspin` - for audio streaming (optional)
### 2. Health Monitoring
Implement aggressive health checks because data channels can silently die:
```dart
// Track message counts
int _sentCount = 0;
int _recvCount = 0;
DateTime _lastRecvTime = DateTime.now();
// Health check every 10 seconds
void _healthCheck() {
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
if (timeSinceRecv.inSeconds > 30) {
// Channel is dead, trigger reconnection
_reconnect();
}
}
```
### 3. Keepalive Messages
Send periodic keepalive on the data channel itself (not just signaling):
```dart
void _sendKeepalive() {
_apiChannel?.send(RTCDataChannelMessage(
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
));
}
```
### 4. Flow Control
Monitor buffer to prevent overflow:
```dart
void sendMessage(String json) {
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
// Queue message, don't send
_sendQueue.add(json);
return;
}
_apiChannel!.send(RTCDataChannelMessage(json));
}
```
---
## Known Issues & Pitfalls
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
**Workaround options:**
- Detect via health check and force reconnection
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
- Report to MA team and wait for server-side fix
### 2. flutter_webrtc Quirks
- `RTCDataChannel.state` may show "open" when actually dead
- `bufferedAmount` accuracy varies by platform
- `onBufferedAmountLow` callback support inconsistent
### 3. Android Background Throttling
Android aggressively throttles background WebRTC. Consider:
- Foreground service for sustained connections
- Reconnection on app resume
### 4. ICE/TURN Issues
- aiortc (server-side) has known TURN + DataChannel bugs
- May need ICE restart on network changes
---
## Testing Checklist
- [ ] Connect via remote when local unavailable
- [ ] Verify API commands work (players/all, library queries)
- [ ] Test sustained connection (30+ minutes)
- [ ] Test app backgrounding and resume
- [ ] Test network transitions (WiFi ↔ cellular)
- [ ] Verify reconnection after channel death
- [ ] Test sendspin audio streaming (if implemented)
---
## Useful Resources
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
- Signaling server: `wss://signaling.music-assistant.io/ws`
---
## Files in This Directory
| File | Description |
|------|-------------|
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
| `WEBRTC_PROGRESS.md` | Progress log |
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
| `webrtc-failure-logs.txt` | Raw failure logs |
| `console-export-*.log` | Browser console exports |
---
## Quick Start for Future Implementation
1. Read this guide fully
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
3. Check if MA team has fixed server-side issues
4. Start with basic implementation, add health monitoring early
5. Test extensively before shipping - the failure mode is subtle
@@ -0,0 +1,127 @@
# WebRTC Data Channel Failure on Android - Bug Report
## Summary
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
## Environment
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
## Reproduction Steps
1. Connect to MA server via WebRTC remote access (not local network)
2. Use the app normally - browse library, view players
3. After 1-3 minutes, the connection degrades
4. Commands time out, but signaling ping/pong continues working
## Observed Behavior
### Timeline from logs:
```
07:14:20 - Working normally
Health: API sent:51 recv:54 (balanced)
07:15:57 - Receive stops
Health: API sent:97 recv:97, lastRecv:12s ago
07:16:02 - Timeouts begin
"TimeoutException: Command timeout: players/all"
07:16:17 - Sendspin still works!
"Sendspin: Received server/time response"
Health: API sent:114 recv:97, lastRecv:27s ago
07:17:30 - Forced reconnect
Health: API sent:137 recv:97, lastRecv:42s ago
"Health check detected stale connection"
```
### Key Observation
| Channel | Traffic Pattern | Status |
|---------|-----------------|--------|
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
## Testing Performed
### Test 1: flutter_webrtc (Native WebRTC)
- **Result**: ma-api channel dies after 1-3 minutes
- **Sendspin**: Continues working
### Test 2: WebView-based WebRTC (Browser engine)
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
- **Conclusion**: Not a flutter_webrtc bug
### Test 3: Various Data Channel Settings
- Tried `ordered: false` with `maxRetransmits: 3`
- Tried letting server create channels (failed - server expects client to create)
- No improvement
## Analysis
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
1. **Server-side** - Different handling of ma-api vs sendspin channels
2. **TURN relay** - High-traffic channel may hit limits
3. **Not client-specific** - Both implementations fail identically
### Questions for MA Team
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
3. Does the MA Web UI use different data channel configurations?
4. Are there known issues with the TURN relay for sustained high-traffic channels?
## Comparison: MA Web UI vs Android App
| Aspect | MA Web UI | Android App |
|--------|-----------|-------------|
| Platform | Browser tab (visible, active) | Background-capable app |
| WebRTC | Browser native | flutter_webrtc / WebView |
| Result | Works | Fails after 1-3 min |
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
## Logs
### Health Check Pattern (shows channel death)
```
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
```
### Sendspin Still Working During Failure
```
07:16:17 Sendspin: Received server/time response <- Still alive!
07:16:47 Sendspin: Received server/time response <- Still alive!
```
## Requested Information
1. Data channel creation code/settings for both channels
2. Any server-side differences in channel handling
3. TURN server configuration details
4. Whether this has been reported by other Android/mobile clients
## Workaround
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
---
**Reporter**: Ensemble Android App Developer
**Date**: 2026-01-24
@@ -0,0 +1,149 @@
# Plan B: Single Data Channel Multiplexing
## Why This Might Be Needed
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
Evidence:
- MA Web UI works fine with local playback (same server, browser WebRTC)
- Our app uses flutter_webrtc which has known issues:
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
- Pattern matches: second channel survives while first dies
## Current Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├────────────────────┬────────────────────────────┤
│ ma-api channel │ sendspin channel │
│ (JSON API) │ (JSON + binary audio) │
│ SCTP stream 0 │ SCTP stream 1 │
└────────────────────┴────────────────────────────┘
```
## Proposed Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├─────────────────────────────────────────────────┤
│ unified channel │
│ (multiplexed: API + Sendspin text + binary) │
│ SCTP stream 0 │
└─────────────────────────────────────────────────┘
```
## Multiplexing Protocol
### Message Framing
Each message has a 1-byte type prefix:
| Prefix | Type | Content |
|--------|------|---------|
| 0x00 | API | JSON text (MA API request/response) |
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
### Implementation
#### Sending (Client → Server)
```dart
void sendApiMessage(String json) {
final prefix = Uint8List.fromList([0x00]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x00;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinText(String json) {
final prefix = Uint8List.fromList([0x01]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x01;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinBinary(Uint8List audio) {
final message = Uint8List(1 + audio.length);
message[0] = 0x02;
message.setRange(1, message.length, audio);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
```
#### Receiving (Server → Client)
```dart
void _onMessage(RTCDataChannelMessage message) {
final data = message.binary;
if (data == null || data.isEmpty) return;
final type = data[0];
final payload = data.sublist(1);
switch (type) {
case 0x00: // API
final json = utf8.decode(payload);
_handleApiMessage(json);
break;
case 0x01: // Sendspin text
final json = utf8.decode(payload);
_handleSendspinText(json);
break;
case 0x02: // Sendspin binary
_handleSendspinBinary(payload);
break;
}
}
```
## Server-Side Changes Required
The Music Assistant server would need to:
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
2. Multiplex responses using the same framing protocol
3. This requires changes to the MA remote connection code
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
## Client-Only Approach (No Server Changes)
If we can't change the server, we could:
1. Still create two channels to the server (ma-api + sendspin)
2. But internally route them through a single "virtual" channel for flutter_webrtc
3. This is more complex and may not avoid the underlying bug
## Files to Modify
| File | Changes |
|------|---------|
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
| `remote_bridge.dart` | Route messages based on type prefix |
## Risks
1. **Server compatibility**: Requires MA server changes or complex client proxy
2. **Performance**: Extra byte per message, decode overhead
3. **Binary handling**: Must handle all messages as binary (not text)
## Testing Plan
1. Implement client-side changes
2. Test with modified server (if available) or mock
3. Verify both API and Sendspin traffic flows correctly
4. Long-duration stability test
## Decision Point
Try this approach if:
- flutter_webrtc 0.14.x doesn't fix the issue
- The one-way failure pattern continues
- We've confirmed the issue is multi-channel specific
@@ -0,0 +1,406 @@
# WebRTC Data Channel Disconnect Fixes
## Document Purpose
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
---
## Problem Summary
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
2. **Silent disconnect**: No error events fire, channel just stops working
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
---
## Key Files
| File | Purpose |
|------|---------|
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
---
## Implemented Fixes (Chronological)
### Fix 1: Data Channel Keepalive (Original)
**Problem**: Data channel could silently die without any error events firing.
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
**Code** (`webrtc_connection.dart`):
- `_keepaliveTimer`: Runs every 10 seconds
- `_checkKeepalive()`: Checks time since last data received
- `_sendKeepalivePing()`: Sends JSON ping message
- `_recordDataReceived()`: Called on every message receipt
**Constants**:
```dart
static const _keepaliveInterval = Duration(seconds: 10);
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
static const _maxKeepaliveMisses = 2; // Reduced from 3
```
### Fix 2: ICE Recovery
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
**Solution**: Added ICE recovery timer before declaring failure.
**Code** (`webrtc_connection.dart`):
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
### Fix 3: Flow Control (Buffer Overflow Prevention)
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
**Solution**: Added send-side flow control with buffering.
**Code** (`webrtc_connection.dart`):
```dart
static const _maxBufferedAmount = 1048576; // 1MB threshold
static const _bufferLowThreshold = 65536; // 64KB resume threshold
bool _sendPaused = false;
final _sendQueue = <String>[];
```
**Modified `sendRaw()`**:
- Checks `bufferedAmount` before sending
- Queues messages if buffer > 1MB
- `onBufferedAmountLow` callback drains queue
### Fix 4: Health Check Stale Detection
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
**Solution**: Reduced stale detection threshold from 45s to 30s.
**Code** (`remote_bridge.dart`):
```dart
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
// Force reconnection
}
```
### Fix 5: Reduced Keepalive Timeouts
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
### Fix 6: Recv Stagnation Detection
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
**Code** (`remote_bridge.dart`):
```dart
// Track across health checks
int _lastHealthCheckRecvCount = 0;
int _lastHealthCheckSentCount = 0;
int _recvStagnationCount = 0;
// In health check:
if (recvStuck && sentIncreased) {
_recvStagnationCount++;
if (_recvStagnationCount >= 2) {
// Force reconnection - channel is one-way
}
}
```
**Detection time**: ~20 seconds (2 × 10s health check interval)
---
## Log Patterns to Watch For
### Healthy Connection
```
Health check - API sent:93 recv:88 ... lastRecv:0s ago
```
- `sent` and `recv` both increasing
- `lastRecv` low (< 5s typically)
### One-Way Failure (CRITICAL)
```
Health check - API sent:206 recv:163 ... lastRecv:30s ago
Health check - API sent:218 recv:163 ... lastRecv:40s ago
```
- `sent` increases, `recv` STUCK
- `lastRecv` keeps increasing
- Data channel still shows "open"
### Signaling Disconnect
```
WebRTC: Signaling disconnected
... (6-16 seconds later) ...
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
WebRTC: ICE connection state: RTCIceConnectionStateFailed
```
### Network Failure (DNS)
```
Signaling: WebSocket closed
... (reconnection attempt) ...
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
```
This indicates the phone lost network connectivity entirely.
### Reconnection Storm (Signaling Instability)
```
Signaling: Connecting to wss://signaling.music-assistant.io/ws
Signaling: Sending connect-request
Signaling: WebSocket closed ← Closes within 100-500ms
WebRTC: Signaling disconnected
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
```
Rapid reconnection loop - signaling never stays connected long enough.
### Flow Control Engaged
```
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
```
### Keepalive Detection
```
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
WebRTC: ❌ Data channel appears dead - triggering reconnection
```
---
## Known Issues / Limitations
### 1. flutter_webrtc Limitations
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
- `bufferedAmount` may not be accurate on all platforms
- `onBufferedAmountLow` callback support varies
### 2. SCTP Receive Window
- If server sends faster than client processes, receive window fills
- This causes server-side to stop sending, client sees no data
- No way to monitor receive-side buffer from client
### 3. TURN Server Issues
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
- Data channel may fail silently when using TURN relay
### 4. Network Transitions
- WiFi → Cellular or vice versa can cause ICE to fail
- App backgrounding on mobile can close WebSocket/WebRTC
---
## Root Cause Categories
### Category A: Network Issues (Not Code Bugs)
These are environmental issues, not bugs in our code:
- **DNS failure**: `Failed host lookup` - Phone lost internet
- **WiFi/Cellular switch**: Network changed, connections dropped
- **Server unreachable**: Signaling server down or blocked
- **Firewall/NAT issues**: TURN relay needed but unavailable
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
### Category B: WebRTC/SCTP Issues (Partial Code Control)
These can be mitigated but not fully prevented:
- **One-way channel death**: Channel appears open but data stops flowing
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
- **ICE failure**: Network path lost, no recovery possible
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
### Category C: Code Bugs (Fixable)
Issues in our implementation:
- **Detection too slow**: Timeouts too long, misses too many
- **Recovery not triggered**: State checks wrong, reconnection not started
- **Resource leaks**: Timers not cancelled, connections not cleaned up
**Evidence**: Logs show problem but no action taken, or wrong action taken.
---
## Debugging Checklist
When investigating a disconnect:
1. **Find the last successful response**:
```
grep "WebRTC API → WS" logs.txt | head -5
```
2. **Check health check progression**:
```
grep "Health check" logs.txt
```
Look for `recv` getting stuck while `sent` increases.
3. **Check for signaling issues**:
```
grep -i "signaling" logs.txt
```
4. **Check ICE state changes**:
```
grep "ICE connection state" logs.txt
```
5. **Check for keepalive detection**:
```
grep -i "keepalive" logs.txt
```
6. **Check buffer status**:
```
grep -E "(buffer|queue|paused)" logs.txt
```
---
## Potential Future Improvements
### 1. Server-Side Backpressure
MA protocol doesn't have explicit backpressure. Could add:
- Client-side request throttling
- Application-level ACKs for critical messages
### 2. More Aggressive Detection
- Track recv count across health checks, detect stagnation
- Shorter keepalive interval (5s instead of 10s)
### 3. Proactive Health Signals
- Monitor `bufferedAmount` trend (increasing = problem)
- Track round-trip latency of keepalive pings
### 4. Connection Quality Metrics
- Expose packet loss, jitter from WebRTC stats
- Use to predict imminent failure
---
## Test Scenarios
| Scenario | How to Test | Expected Behavior |
|----------|-------------|-------------------|
| Normal usage | Browse library, play music | No warnings |
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
| Long session | Leave connected 2+ hours | Should stay connected |
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
| Network change | WiFi → Cellular | Should detect and reconnect |
| App background | Background for 5+ min | May disconnect, should reconnect |
---
## Observed Failure Patterns
### Pattern 1: One-Way Death After Library Sync
**Observed**: 2026-01-23 (multiple times)
```
17:13:06.175 - Last response (playlists sync)
17:13:06.264 - Local batch save
-- 5 seconds silence --
17:13:11.005 - Send players/all → NO RESPONSE
17:13:13.914 - ICE state: completed (looks healthy!)
17:13:28 - Keepalive detects timeout
17:13:38 - Reconnection triggered
```
**Characteristics**:
- Often happens after library sync (large batch responses)
- ICE shows "completed" even though data channel is dead
- Signaling ping/pong continues working
- Only data channel receive is affected
**Hypothesis**:
- Server-side SCTP buffer exhausted from sending large responses?
- Client receive window full, server blocks?
- flutter_webrtc onMessage callback stops firing?
### Pattern 2: Reconnection Storm / Signaling Instability
**Observed**: 2026-01-23 17:30-17:31
```
17:31:05.093 - Signaling: Connecting
17:31:05.216 - Signaling: Sending connect-request
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
17:31:05.218 - WebRTC: Signaling disconnected
```
**Characteristics**:
- Signaling WebSocket closes almost immediately after connect
- Multiple rapid reconnection attempts (storm)
- Connection never stable long enough for data flow
- Often happens after network instability
- Artist detail, albums etc don't load because connection keeps failing
**Possible Causes**:
- Network instability (WiFi/cellular issues)
- Signaling server rate limiting
- DNS resolution failures
- Server rejecting rapid reconnection attempts
**Side Effects**:
- UI shows errors like "Not connected to Music Assistant server"
- Artist/album detail screens fail to load
- Players list empty or stale
### Pattern 3: API Channel Dies While Sendspin Lives
**Observed**: 2026-01-23 17:53
```
17:53:35.755 - Send album_tracks request → NO RESPONSE
17:53:40.596 - Health check: recv stuck at 197, sent increased
17:53:55.204 - Sendspin channel RECEIVES server/time response!
17:54:02.790 - Keepalive detects dead API channel
```
**Characteristics**:
- API data channel (ma-api) stops receiving
- Sendspin data channel continues working normally
- Same WebRTC peer connection, different data channels
- This proves it's NOT a network issue - Sendspin traffic flows fine
**Hypothesis**:
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
- Possible memory/GC issue with the callback
- Server-side might be sending to wrong channel?
**Key Evidence**:
- Recv count stays at 197 for API
- Sendspin recv count increases (7→8)
- Both channels use same peer connection
---
## Version History
| Date | Build | Changes |
|------|-------|---------|
| 2026-01-23 | ~v49 | Initial flow control implementation |
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
---
## References
- flutter_webrtc Issue #843: Data channel state unreliable
- aiortc Issue #1156: TURN + DataChannel bugs
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
- Music Assistant remote protocol: No explicit flow control
+118
View File
@@ -0,0 +1,118 @@
# WebRTC Remote Connection Progress
## Problem Statement
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
## Approaches Tried
### 1. Original Implementation (flutter_webrtc)
- **Status**: Fails
- **Issue**: ma-api data channel goes one-way after some time
- **Files**: `lib/services/remote/webrtc_connection.dart`
### 2. WebView-based WebRTC Implementation
- **Status**: Same failure mode
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
- **Result**: Same one-way channel death occurs
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
## Key Observations
From the logs:
```
lastRecv:26s ago <- No messages received in 26 seconds
API sent:674 recv:618 <- Gap growing, responses stopped
```
- Signaling ping/pong continues working
- Sendspin channel continues receiving (`server/time` responses)
- Only ma-api channel becomes one-way
- WebRTC peer connection itself remains "connected"
## Differences from Working MA Web UI
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
1. flutter_webrtc: Native WebRTC in background app
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
Android aggressively throttles hidden WebViews to save battery.
## Current Implementation (WebView)
### Files Created
| File | Purpose |
|------|---------|
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
### Files Modified
| File | Changes |
|------|---------|
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
| `lib/main.dart` | Adds WebView to widget tree |
### Architecture
```
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
WebViewWebRTCConnection
|
Hidden InAppWebView
|
JavaScript WebRTC Engine
|
MA Server via TURN
```
## Current Fixes Being Tested
### 1. Prevent WebView Throttling
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
- Added various InAppWebViewSettings to prevent background throttling
### 2. Data Channel Settings
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
- Hypothesis: Ordered delivery may cause buffer bloat issues
### 3. Buffer Monitoring
- Added `bufferedAmount` checking in JavaScript
- Logs warning when buffer exceeds 1MB
- Added `getChannelHealth()` function for diagnostics
### 4. Reduced Logging
- Removed per-message logging (was filling logs too fast)
- Keep only state changes, errors, and warnings
## Experiments Completed
### 5. Server-Created Channels (Failed)
- **Hypothesis**: Let server create data channels to avoid ID collisions
- **Result**: Connection timeout - server expects CLIENT to create channels
- **Learning**: Client must create channels before sending offer
## Next Steps to Try
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
2. **Single data channel** - Use only ma-api, disable sendspin
3. **Periodic channel recreation** - Detect stale channel and recreate
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
## Test APK Location
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
## Useful Debug Commands
Check channel health (in JS console or via Dart):
```javascript
getChannelHealth()
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
```
## Open Questions
1. Why does sendspin channel survive when ma-api dies?
2. Is there a difference in how MA server handles the two channels?
3. Does MA Web UI use different data channel settings?
4. Is there a TURN relay issue specific to high-traffic channels?
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
New package ID (com.collotsspot.ensemble) - reinstall required.
Prepares for F-Droid and Play Store submission.
@@ -0,0 +1 @@
Add fastlane metadata for F-Droid.
@@ -0,0 +1 @@
Disable dependency metadata in APK for F-Droid compliance.
@@ -0,0 +1,13 @@
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
Features:
* Stream music directly to your phone with background playback
* Control any speaker or device connected to Music Assistant
* Multi-room grouping capabilities
* Queue management with drag-to-reorder
* Background playback with media notifications
* Fast ~300ms response for play/pause controls
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

@@ -0,0 +1 @@
Unofficial mobile client for Music Assistant
@@ -0,0 +1 @@
Ensemble
+6
View File
@@ -32,6 +32,12 @@ class HeroTags {
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
+91 -2
View File
@@ -190,12 +190,37 @@ class SearchHistory extends Table {
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
/// Cached detail tracks for album/playlist detail screens
/// Persists across app restarts so detail screens load instantly
class DetailTrackCache extends Table {
/// Composite key: "{parentType}_{parentKey}"
TextColumn get cacheKey => text()();
/// Type: 'album' or 'playlist'
TextColumn get parentType => text()();
/// The parent item's cache key (e.g., "provider_itemId")
TextColumn get parentKey => text()();
/// Serialized list of tracks as JSON array
TextColumn get tracksJson => text()();
/// When this was fetched from API
DateTimeColumn get lastFetched => dateTime()();
/// When this was last accessed (for LRU eviction)
DateTimeColumn get lastAccessed => dateTime()();
@override
Set<Column> get primaryKey => {cacheKey};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory, DetailTrackCache])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
@override
MigrationStrategy get migration {
@@ -234,6 +259,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
}
// Migration from v6 to v7: Add detail track cache table
if (from < 7) {
await m.createTable(detailTrackCache);
}
},
);
}
@@ -696,6 +725,66 @@ class AppDatabase extends _$AppDatabase {
await delete(searchHistory).go();
}
// ============================================
// Detail Track Cache Operations
// ============================================
/// Save detail tracks for an album or playlist
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) async {
final cacheKey = '${parentType}_$parentKey';
final now = DateTime.now();
await into(detailTrackCache).insertOnConflictUpdate(
DetailTrackCacheCompanion.insert(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: now,
lastAccessed: now,
),
);
// Evict old entries if cache grows too large
await _evictDetailTrackCache();
}
/// Get cached detail tracks for an album or playlist
/// Returns null if not cached, updates lastAccessed on hit
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) async {
final cacheKey = '${parentType}_$parentKey';
final result = await (select(detailTrackCache)
..where((d) => d.cacheKey.equals(cacheKey)))
.getSingleOrNull();
if (result != null) {
// Update last accessed time
await (update(detailTrackCache)..where((d) => d.cacheKey.equals(cacheKey)))
.write(DetailTrackCacheCompanion(lastAccessed: Value(DateTime.now())));
}
return result;
}
/// Evict oldest detail track cache entries when over limit
Future<void> _evictDetailTrackCache() async {
const maxEntries = 100;
final count = await (selectOnly(detailTrackCache)..addColumns([countAll()]))
.map((row) => row.read(countAll())!)
.getSingle();
if (count <= maxEntries) return;
// Delete oldest by lastAccessed
final oldest = await (select(detailTrackCache)
..orderBy([(d) => OrderingTerm.asc(d.lastAccessed)])
..limit(count - maxEntries))
.get();
for (final entry in oldest) {
await (delete(detailTrackCache)..where((d) => d.cacheKey.equals(entry.cacheKey))).go();
}
}
/// Clear all detail track cache
Future<void> clearDetailTrackCache() async {
await delete(detailTrackCache).go();
}
// ============================================
// Batch Cache Operations
// ============================================
+579 -3
View File
@@ -2916,6 +2916,381 @@ class SearchHistoryCompanion extends UpdateCompanion<SearchHistoryData> {
}
}
class $DetailTrackCacheTable extends DetailTrackCache
with TableInfo<$DetailTrackCacheTable, DetailTrackCacheData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$DetailTrackCacheTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _cacheKeyMeta =
const VerificationMeta('cacheKey');
@override
late final GeneratedColumn<String> cacheKey = GeneratedColumn<String>(
'cache_key', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _parentTypeMeta =
const VerificationMeta('parentType');
@override
late final GeneratedColumn<String> parentType = GeneratedColumn<String>(
'parent_type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _parentKeyMeta =
const VerificationMeta('parentKey');
@override
late final GeneratedColumn<String> parentKey = GeneratedColumn<String>(
'parent_key', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _tracksJsonMeta =
const VerificationMeta('tracksJson');
@override
late final GeneratedColumn<String> tracksJson = GeneratedColumn<String>(
'tracks_json', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _lastFetchedMeta =
const VerificationMeta('lastFetched');
@override
late final GeneratedColumn<DateTime> lastFetched = GeneratedColumn<DateTime>(
'last_fetched', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
static const VerificationMeta _lastAccessedMeta =
const VerificationMeta('lastAccessed');
@override
late final GeneratedColumn<DateTime> lastAccessed = GeneratedColumn<DateTime>(
'last_accessed', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns =>
[cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'detail_track_cache';
@override
VerificationContext validateIntegrity(
Insertable<DetailTrackCacheData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('cache_key')) {
context.handle(_cacheKeyMeta,
cacheKey.isAcceptableOrUnknown(data['cache_key']!, _cacheKeyMeta));
} else if (isInserting) {
context.missing(_cacheKeyMeta);
}
if (data.containsKey('parent_type')) {
context.handle(
_parentTypeMeta,
parentType.isAcceptableOrUnknown(
data['parent_type']!, _parentTypeMeta));
} else if (isInserting) {
context.missing(_parentTypeMeta);
}
if (data.containsKey('parent_key')) {
context.handle(_parentKeyMeta,
parentKey.isAcceptableOrUnknown(data['parent_key']!, _parentKeyMeta));
} else if (isInserting) {
context.missing(_parentKeyMeta);
}
if (data.containsKey('tracks_json')) {
context.handle(
_tracksJsonMeta,
tracksJson.isAcceptableOrUnknown(
data['tracks_json']!, _tracksJsonMeta));
} else if (isInserting) {
context.missing(_tracksJsonMeta);
}
if (data.containsKey('last_fetched')) {
context.handle(
_lastFetchedMeta,
lastFetched.isAcceptableOrUnknown(
data['last_fetched']!, _lastFetchedMeta));
} else if (isInserting) {
context.missing(_lastFetchedMeta);
}
if (data.containsKey('last_accessed')) {
context.handle(
_lastAccessedMeta,
lastAccessed.isAcceptableOrUnknown(
data['last_accessed']!, _lastAccessedMeta));
} else if (isInserting) {
context.missing(_lastAccessedMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {cacheKey};
@override
DetailTrackCacheData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return DetailTrackCacheData(
cacheKey: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}cache_key'])!,
parentType: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}parent_type'])!,
parentKey: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}parent_key'])!,
tracksJson: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}tracks_json'])!,
lastFetched: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_fetched'])!,
lastAccessed: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}last_accessed'])!,
);
}
@override
$DetailTrackCacheTable createAlias(String alias) {
return $DetailTrackCacheTable(attachedDatabase, alias);
}
}
class DetailTrackCacheData extends DataClass
implements Insertable<DetailTrackCacheData> {
/// Composite key: "{parentType}_{parentKey}"
final String cacheKey;
/// Type: 'album' or 'playlist'
final String parentType;
/// The parent item's cache key (e.g., "provider_itemId")
final String parentKey;
/// Serialized list of tracks as JSON array
final String tracksJson;
/// When this was fetched from API
final DateTime lastFetched;
/// When this was last accessed (for LRU eviction)
final DateTime lastAccessed;
const DetailTrackCacheData(
{required this.cacheKey,
required this.parentType,
required this.parentKey,
required this.tracksJson,
required this.lastFetched,
required this.lastAccessed});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['cache_key'] = Variable<String>(cacheKey);
map['parent_type'] = Variable<String>(parentType);
map['parent_key'] = Variable<String>(parentKey);
map['tracks_json'] = Variable<String>(tracksJson);
map['last_fetched'] = Variable<DateTime>(lastFetched);
map['last_accessed'] = Variable<DateTime>(lastAccessed);
return map;
}
DetailTrackCacheCompanion toCompanion(bool nullToAbsent) {
return DetailTrackCacheCompanion(
cacheKey: Value(cacheKey),
parentType: Value(parentType),
parentKey: Value(parentKey),
tracksJson: Value(tracksJson),
lastFetched: Value(lastFetched),
lastAccessed: Value(lastAccessed),
);
}
factory DetailTrackCacheData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return DetailTrackCacheData(
cacheKey: serializer.fromJson<String>(json['cacheKey']),
parentType: serializer.fromJson<String>(json['parentType']),
parentKey: serializer.fromJson<String>(json['parentKey']),
tracksJson: serializer.fromJson<String>(json['tracksJson']),
lastFetched: serializer.fromJson<DateTime>(json['lastFetched']),
lastAccessed: serializer.fromJson<DateTime>(json['lastAccessed']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'cacheKey': serializer.toJson<String>(cacheKey),
'parentType': serializer.toJson<String>(parentType),
'parentKey': serializer.toJson<String>(parentKey),
'tracksJson': serializer.toJson<String>(tracksJson),
'lastFetched': serializer.toJson<DateTime>(lastFetched),
'lastAccessed': serializer.toJson<DateTime>(lastAccessed),
};
}
DetailTrackCacheData copyWith(
{String? cacheKey,
String? parentType,
String? parentKey,
String? tracksJson,
DateTime? lastFetched,
DateTime? lastAccessed}) =>
DetailTrackCacheData(
cacheKey: cacheKey ?? this.cacheKey,
parentType: parentType ?? this.parentType,
parentKey: parentKey ?? this.parentKey,
tracksJson: tracksJson ?? this.tracksJson,
lastFetched: lastFetched ?? this.lastFetched,
lastAccessed: lastAccessed ?? this.lastAccessed,
);
DetailTrackCacheData copyWithCompanion(DetailTrackCacheCompanion data) {
return DetailTrackCacheData(
cacheKey: data.cacheKey.present ? data.cacheKey.value : this.cacheKey,
parentType:
data.parentType.present ? data.parentType.value : this.parentType,
parentKey: data.parentKey.present ? data.parentKey.value : this.parentKey,
tracksJson:
data.tracksJson.present ? data.tracksJson.value : this.tracksJson,
lastFetched:
data.lastFetched.present ? data.lastFetched.value : this.lastFetched,
lastAccessed: data.lastAccessed.present
? data.lastAccessed.value
: this.lastAccessed,
);
}
@override
String toString() {
return (StringBuffer('DetailTrackCacheData(')
..write('cacheKey: $cacheKey, ')
..write('parentType: $parentType, ')
..write('parentKey: $parentKey, ')
..write('tracksJson: $tracksJson, ')
..write('lastFetched: $lastFetched, ')
..write('lastAccessed: $lastAccessed')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is DetailTrackCacheData &&
other.cacheKey == this.cacheKey &&
other.parentType == this.parentType &&
other.parentKey == this.parentKey &&
other.tracksJson == this.tracksJson &&
other.lastFetched == this.lastFetched &&
other.lastAccessed == this.lastAccessed);
}
class DetailTrackCacheCompanion extends UpdateCompanion<DetailTrackCacheData> {
final Value<String> cacheKey;
final Value<String> parentType;
final Value<String> parentKey;
final Value<String> tracksJson;
final Value<DateTime> lastFetched;
final Value<DateTime> lastAccessed;
final Value<int> rowid;
const DetailTrackCacheCompanion({
this.cacheKey = const Value.absent(),
this.parentType = const Value.absent(),
this.parentKey = const Value.absent(),
this.tracksJson = const Value.absent(),
this.lastFetched = const Value.absent(),
this.lastAccessed = const Value.absent(),
this.rowid = const Value.absent(),
});
DetailTrackCacheCompanion.insert({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
this.rowid = const Value.absent(),
}) : cacheKey = Value(cacheKey),
parentType = Value(parentType),
parentKey = Value(parentKey),
tracksJson = Value(tracksJson),
lastFetched = Value(lastFetched),
lastAccessed = Value(lastAccessed);
static Insertable<DetailTrackCacheData> custom({
Expression<String>? cacheKey,
Expression<String>? parentType,
Expression<String>? parentKey,
Expression<String>? tracksJson,
Expression<DateTime>? lastFetched,
Expression<DateTime>? lastAccessed,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (cacheKey != null) 'cache_key': cacheKey,
if (parentType != null) 'parent_type': parentType,
if (parentKey != null) 'parent_key': parentKey,
if (tracksJson != null) 'tracks_json': tracksJson,
if (lastFetched != null) 'last_fetched': lastFetched,
if (lastAccessed != null) 'last_accessed': lastAccessed,
if (rowid != null) 'rowid': rowid,
});
}
DetailTrackCacheCompanion copyWith(
{Value<String>? cacheKey,
Value<String>? parentType,
Value<String>? parentKey,
Value<String>? tracksJson,
Value<DateTime>? lastFetched,
Value<DateTime>? lastAccessed,
Value<int>? rowid}) {
return DetailTrackCacheCompanion(
cacheKey: cacheKey ?? this.cacheKey,
parentType: parentType ?? this.parentType,
parentKey: parentKey ?? this.parentKey,
tracksJson: tracksJson ?? this.tracksJson,
lastFetched: lastFetched ?? this.lastFetched,
lastAccessed: lastAccessed ?? this.lastAccessed,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (cacheKey.present) {
map['cache_key'] = Variable<String>(cacheKey.value);
}
if (parentType.present) {
map['parent_type'] = Variable<String>(parentType.value);
}
if (parentKey.present) {
map['parent_key'] = Variable<String>(parentKey.value);
}
if (tracksJson.present) {
map['tracks_json'] = Variable<String>(tracksJson.value);
}
if (lastFetched.present) {
map['last_fetched'] = Variable<DateTime>(lastFetched.value);
}
if (lastAccessed.present) {
map['last_accessed'] = Variable<DateTime>(lastAccessed.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('DetailTrackCacheCompanion(')
..write('cacheKey: $cacheKey, ')
..write('parentType: $parentType, ')
..write('parentKey: $parentKey, ')
..write('tracksJson: $tracksJson, ')
..write('lastFetched: $lastFetched, ')
..write('lastAccessed: $lastAccessed, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -2928,6 +3303,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $CachedQueueTable cachedQueue = $CachedQueueTable(this);
late final $HomeRowCacheTable homeRowCache = $HomeRowCacheTable(this);
late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this);
late final $DetailTrackCacheTable detailTrackCache =
$DetailTrackCacheTable(this);
late final Index idxRecentlyPlayedProfile = Index(
'idx_recently_played_profile',
'CREATE INDEX idx_recently_played_profile ON recently_played (profile_username)');
@@ -2958,6 +3335,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
cachedQueue,
homeRowCache,
searchHistory,
detailTrackCache,
idxRecentlyPlayedProfile,
idxRecentlyPlayedProfilePlayed,
idxLibraryCacheType,
@@ -2996,7 +3374,8 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username($_item.username));
.filter((f) => f.profileUsername.username
.sqlEquals($_itemColumn<String>('username')!));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager(
@@ -3189,7 +3568,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async {
return [
if (recentlyPlayedRefs)
await $_getPrefetchedData(
await $_getPrefetchedData<Profile, $ProfilesTable,
RecentlyPlayedData>(
currentTable: table,
referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db),
@@ -3254,8 +3634,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username($_item.profileUsername));
.filter((f) => f.username.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
@@ -4697,6 +5079,198 @@ typedef $$SearchHistoryTableProcessedTableManager = ProcessedTableManager<
),
SearchHistoryData,
PrefetchHooks Function()>;
typedef $$DetailTrackCacheTableCreateCompanionBuilder
= DetailTrackCacheCompanion Function({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
Value<int> rowid,
});
typedef $$DetailTrackCacheTableUpdateCompanionBuilder
= DetailTrackCacheCompanion Function({
Value<String> cacheKey,
Value<String> parentType,
Value<String> parentKey,
Value<String> tracksJson,
Value<DateTime> lastFetched,
Value<DateTime> lastAccessed,
Value<int> rowid,
});
class $$DetailTrackCacheTableFilterComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get cacheKey => $composableBuilder(
column: $table.cacheKey, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get parentKey => $composableBuilder(
column: $table.parentKey, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed, builder: (column) => ColumnFilters(column));
}
class $$DetailTrackCacheTableOrderingComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get cacheKey => $composableBuilder(
column: $table.cacheKey, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get parentKey => $composableBuilder(
column: $table.parentKey, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed,
builder: (column) => ColumnOrderings(column));
}
class $$DetailTrackCacheTableAnnotationComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get cacheKey =>
$composableBuilder(column: $table.cacheKey, builder: (column) => column);
GeneratedColumn<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => column);
GeneratedColumn<String> get parentKey =>
$composableBuilder(column: $table.parentKey, builder: (column) => column);
GeneratedColumn<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => column);
GeneratedColumn<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => column);
GeneratedColumn<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed, builder: (column) => column);
}
class $$DetailTrackCacheTableTableManager extends RootTableManager<
_$AppDatabase,
$DetailTrackCacheTable,
DetailTrackCacheData,
$$DetailTrackCacheTableFilterComposer,
$$DetailTrackCacheTableOrderingComposer,
$$DetailTrackCacheTableAnnotationComposer,
$$DetailTrackCacheTableCreateCompanionBuilder,
$$DetailTrackCacheTableUpdateCompanionBuilder,
(
DetailTrackCacheData,
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
DetailTrackCacheData>
),
DetailTrackCacheData,
PrefetchHooks Function()> {
$$DetailTrackCacheTableTableManager(
_$AppDatabase db, $DetailTrackCacheTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$DetailTrackCacheTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$DetailTrackCacheTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$DetailTrackCacheTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<String> cacheKey = const Value.absent(),
Value<String> parentType = const Value.absent(),
Value<String> parentKey = const Value.absent(),
Value<String> tracksJson = const Value.absent(),
Value<DateTime> lastFetched = const Value.absent(),
Value<DateTime> lastAccessed = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
DetailTrackCacheCompanion(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: lastFetched,
lastAccessed: lastAccessed,
rowid: rowid,
),
createCompanionCallback: ({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
Value<int> rowid = const Value.absent(),
}) =>
DetailTrackCacheCompanion.insert(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: lastFetched,
lastAccessed: lastAccessed,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$DetailTrackCacheTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$DetailTrackCacheTable,
DetailTrackCacheData,
$$DetailTrackCacheTableFilterComposer,
$$DetailTrackCacheTableOrderingComposer,
$$DetailTrackCacheTableAnnotationComposer,
$$DetailTrackCacheTableCreateCompanionBuilder,
$$DetailTrackCacheTableUpdateCompanionBuilder,
(
DetailTrackCacheData,
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
DetailTrackCacheData>
),
DetailTrackCacheData,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -4719,4 +5293,6 @@ class $AppDatabaseManager {
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
$$SearchHistoryTableTableManager get searchHistory =>
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
$$DetailTrackCacheTableTableManager get detailTrackCache =>
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
}
+11 -1
View File
@@ -295,5 +295,15 @@
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
"swipeToAdjustVolume": "Wischen für Lautstärke",
"aboutAndSupport": "Über & Unterstützung",
"supportDevelopment": "Entwicklung unterstützen",
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
"sponsorOnGitHub": "Auf GitHub sponsoren",
"buyMeAKoFi": "Kauf mir einen Ko-fi",
"aboutEnsemble": "Über Ensemble",
"version": "Version",
"visitGitHubRepo": "GitHub-Repository besuchen",
"reportABug": "Einen Fehler melden"
}
+97 -32
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field"
},
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
"portDescription": "Leave blank unless using a custom port",
"@portDescription": {
"description": "Description for port field"
},
@@ -221,6 +221,36 @@
"description": "Home screen section title"
},
"discoveryMixes": "Discovery Mixes",
"@discoveryMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
},
"continueListening": "Continue Listening",
"@continueListening": {
"description": "Home screen section title"
@@ -604,6 +634,11 @@
"tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added"
},
@@ -1069,6 +1104,10 @@
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view",
"gridView": "Grid view",
"noBooksInSeries": "No books found in this series",
@@ -1087,11 +1126,12 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"addToLibrary": "Add",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1207,11 +1247,21 @@
"description": "Bottom sheet title for adding album to queue"
},
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...",
"@addToQueueOn": {
"description": "Bottom sheet title for adding to queue"
},
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...",
"@startRadioOn": {
"description": "Bottom sheet title for starting radio",
@@ -1357,6 +1407,16 @@
"description": "Description for show hints toggle"
},
"volumePrecisionMode": "Volume Precision Mode",
"@volumePrecisionMode": {
"description": "Setting for fine volume control"
},
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
"@volumePrecisionModeDescription": {
"description": "Description for volume precision mode setting"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
@@ -1655,53 +1715,58 @@
"description": "Hint for selecting player to add to queue"
},
"localConnection": "Server URL",
"@localConnection": {
"description": "Direct connection mode toggle label"
"aboutAndSupport": "About & Support",
"@aboutAndSupport": {
"description": "About and support section title"
},
"remoteConnection": "Remote Access ID",
"@remoteConnection": {
"description": "HA Remote Access connection mode toggle label"
"supportDevelopment": "Support Development",
"@supportDevelopment": {
"description": "Support development section"
},
"remoteAccessId": "Remote Access ID",
"@remoteAccessId": {
"description": "Remote access ID field label"
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
"@ensembleIsUnofficial": {
"description": "Description about Ensemble being unofficial"
},
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
"@remoteAccessIdHint": {
"description": "Hint for remote access ID field"
"sponsorOnGitHub": "Sponsor on GitHub",
"@sponsorOnGitHub": {
"description": "GitHub Sponsors button label"
},
"remoteAccessNote": "For Home Assistant add-on users only",
"@remoteAccessNote": {
"description": "Note about remote access availability"
"buyMeAKoFi": "Buy me a Ko-fi",
"@buyMeAKoFi": {
"description": "Ko-fi donation button label"
},
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
"@pleaseEnterRemoteId": {
"description": "Validation message for remote access ID"
"aboutEnsemble": "About Ensemble",
"@aboutEnsemble": {
"description": "About Ensemble section"
},
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
"@remoteConnectionFailed": {
"description": "Error message when remote connection fails"
"version": "Version",
"@version": {
"description": "Version label"
},
"scanQrCode": "Scan QR Code",
"@scanQrCode": {
"description": "Title for QR code scanner screen"
"visitGitHubRepo": "Visit GitHub Repository",
"@visitGitHubRepo": {
"description": "Link to GitHub repo"
},
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
"@pointCameraAtQr": {
"description": "Instructions for scanning QR code"
"reportABug": "Report a Bug",
"@reportABug": {
"description": "Link to report issues"
},
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
"@cameraPermissionDenied": {
"description": "Error message when camera permission is denied"
"noRowsEnabled": "No rows enabled",
"@noRowsEnabled": {
"description": "Empty state when no home screen rows are enabled"
},
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
"@pullToRefreshHint": {
"description": "Hint when home screen is empty"
}
}
+11 -1
View File
@@ -491,5 +491,15 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen"
"swipeToAdjustVolume": "Desliza para ajustar volumen",
"aboutAndSupport": "Acerca de y Soporte",
"supportDevelopment": "Apoyar el Desarrollo",
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
"sponsorOnGitHub": "Patrocina en GitHub",
"buyMeAKoFi": "Cómprame un Ko-fi",
"aboutEnsemble": "Acerca de Ensemble",
"version": "Versión",
"visitGitHubRepo": "Visitar Repositorio GitHub",
"reportABug": "Reportar un Error"
}
+11 -1
View File
@@ -1396,5 +1396,15 @@
"type": "String"
}
}
}
},
"aboutAndSupport": "À propos et Support",
"supportDevelopment": "Soutenir le Développement",
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
"sponsorOnGitHub": "Sponsoriser sur GitHub",
"buyMeAKoFi": "Offrez-moi un Ko-fi",
"aboutEnsemble": "À propos d'Ensemble",
"version": "Version",
"visitGitHubRepo": "Visiter le Dépôt GitHub",
"reportABug": "Signaler un Bug"
}
+135 -33
View File
@@ -164,7 +164,7 @@ abstract class S {
/// Description for port field
///
/// In en, this message translates to:
/// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
/// **'Leave blank unless using a custom port'**
String get portDescription;
/// Username field label
@@ -359,6 +359,42 @@ abstract class S {
/// **'Discover Albums'**
String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
///
/// In en, this message translates to:
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title
///
/// In en, this message translates to:
@@ -761,12 +797,18 @@ abstract class S {
/// **'Album added to queue'**
String get albumAddedToQueue;
/// Confirmation when tracks added
/// Snackbar message for tracks added to queue
///
/// In en, this message translates to:
/// **'Tracks added to queue'**
String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label
///
/// In en, this message translates to:
@@ -1229,6 +1271,30 @@ abstract class S {
/// **'Sort by series order'**
String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView.
///
/// In en, this message translates to:
@@ -1337,6 +1403,12 @@ abstract class S {
/// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites.
///
/// In en, this message translates to:
@@ -1364,7 +1436,7 @@ abstract class S {
/// No description provided for @addToLibrary.
///
/// In en, this message translates to:
/// **'Add to library'**
/// **'Add'**
String get addToLibrary;
/// No description provided for @unknown.
@@ -1673,12 +1745,24 @@ abstract class S {
/// **'Add album to queue on...'**
String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue
///
/// In en, this message translates to:
/// **'Add to queue on...'**
String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio
///
/// In en, this message translates to:
@@ -1835,6 +1919,18 @@ abstract class S {
/// **'Display helpful tips for discovering features'**
String get showHintsDescription;
/// Setting for fine volume control
///
/// In en, this message translates to:
/// **'Volume Precision Mode'**
String get volumePrecisionMode;
/// Description for volume precision mode setting
///
/// In en, this message translates to:
/// **'Hold still while adjusting volume for fine control'**
String get volumePrecisionModeDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
@@ -2123,65 +2219,71 @@ abstract class S {
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// Direct connection mode toggle label
/// About and support section title
///
/// In en, this message translates to:
/// **'Server URL'**
String get localConnection;
/// **'About & Support'**
String get aboutAndSupport;
/// HA Remote Access connection mode toggle label
/// Support development section
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteConnection;
/// **'Support Development'**
String get supportDevelopment;
/// Remote access ID field label
/// Description about Ensemble being unofficial
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteAccessId;
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
String get ensembleIsUnofficial;
/// Hint for remote access ID field
/// GitHub Sponsors button label
///
/// In en, this message translates to:
/// **'Find this in Music Assistant Settings → Remote Access'**
String get remoteAccessIdHint;
/// **'Sponsor on GitHub'**
String get sponsorOnGitHub;
/// Note about remote access availability
/// Ko-fi donation button label
///
/// In en, this message translates to:
/// **'For Home Assistant add-on users only'**
String get remoteAccessNote;
/// **'Buy me a Ko-fi'**
String get buyMeAKoFi;
/// Validation message for remote access ID
/// About Ensemble section
///
/// In en, this message translates to:
/// **'Please enter your Remote Access ID'**
String get pleaseEnterRemoteId;
/// **'About Ensemble'**
String get aboutEnsemble;
/// Error message when remote connection fails
/// Version label
///
/// In en, this message translates to:
/// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
String get remoteConnectionFailed;
/// **'Version'**
String get version;
/// Title for QR code scanner screen
/// Link to GitHub repo
///
/// In en, this message translates to:
/// **'Scan QR Code'**
String get scanQrCode;
/// **'Visit GitHub Repository'**
String get visitGitHubRepo;
/// Instructions for scanning QR code
/// Link to report issues
///
/// In en, this message translates to:
/// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
String get pointCameraAtQr;
/// **'Report a Bug'**
String get reportABug;
/// Error message when camera permission is denied
/// Empty state when no home screen rows are enabled
///
/// In en, this message translates to:
/// **'Camera permission is required to scan QR codes'**
String get cameraPermissionDenied;
/// **'No rows enabled'**
String get noRowsEnabled;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
}
class _SDelegate extends LocalizationsDelegate<S> {
+66 -15
View File
@@ -151,6 +151,25 @@ class SDe extends S {
@override
String get discoverAlbums => 'Alben entdecken';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Weiterhoren';
@@ -373,6 +392,9 @@ class SDe extends S {
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Abspielen';
@@ -646,6 +668,18 @@ class SDe extends S {
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Listenansicht';
@@ -705,6 +739,9 @@ class SDe extends S {
String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -718,7 +755,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unbekannt';
@@ -887,9 +924,15 @@ class SDe extends S {
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
@@ -984,6 +1027,13 @@ class SDe extends S {
String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1157,36 +1207,37 @@ class SDe extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'Über & Unterstützung';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Entwicklung unterstützen';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'Über Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'GitHub-Repository besuchen';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Einen Fehler melden';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+67 -17
View File
@@ -39,8 +39,7 @@ class SEn extends S {
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.';
String get portDescription => 'Leave blank unless using a custom port';
@override
String get username => 'Username';
@@ -149,6 +148,25 @@ class SEn extends S {
@override
String get discoverAlbums => 'Discover Albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continue Listening';
@@ -369,6 +387,9 @@ class SEn extends S {
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Play';
@@ -641,6 +662,18 @@ class SEn extends S {
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'List view';
@@ -698,6 +731,9 @@ class SEn extends S {
String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Added to favorites';
@@ -711,7 +747,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unknown';
@@ -879,9 +915,15 @@ class SEn extends S {
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
@@ -976,6 +1018,13 @@ class SEn extends S {
String get showHintsDescription =>
'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1149,36 +1198,37 @@ class SEn extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'About & Support';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Support Development';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Sponsor on GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Buy me a Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'About Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visit GitHub Repository';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Report a Bug';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+66 -15
View File
@@ -152,6 +152,25 @@ class SEs extends S {
@override
String get discoverAlbums => 'Descubrir álbumes';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuar escuchando';
@@ -376,6 +395,9 @@ class SEs extends S {
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Reproducir';
@@ -650,6 +672,18 @@ class SEs extends S {
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vista de lista';
@@ -712,6 +746,9 @@ class SEs extends S {
String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Añadido a favoritos';
@@ -725,7 +762,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Desconocido';
@@ -895,9 +932,15 @@ class SEs extends S {
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
@@ -992,6 +1035,13 @@ class SEs extends S {
String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1165,36 +1215,37 @@ class SEs extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'Acerca de y Soporte';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Apoyar el Desarrollo';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Patrocina en GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Cómprame un Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'Acerca de Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Versión';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Reportar un Error';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+65 -14
View File
@@ -153,6 +153,25 @@ class SFr extends S {
@override
String get discoverAlbums => 'Découvrir des albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuer l\'écoute';
@@ -377,6 +396,9 @@ class SFr extends S {
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Lire';
@@ -651,6 +673,18 @@ class SFr extends S {
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vue en liste';
@@ -716,6 +750,9 @@ class SFr extends S {
String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@@ -902,9 +939,15 @@ class SFr extends S {
String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
@@ -1000,6 +1043,13 @@ class SFr extends S {
String get showHintsDescription =>
'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1174,36 +1224,37 @@ class SFr extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
String get aboutAndSupport => 'À propos et Support';
@override
String get remoteConnection => 'Remote Access ID';
String get supportDevelopment => 'Soutenir le Développement';
@override
String get remoteAccessId => 'Remote Access ID';
String get ensembleIsUnofficial =>
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
String get aboutEnsemble => 'À propos d\'Ensemble';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
String get version => 'Version';
@override
String get scanQrCode => 'Scan QR Code';
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
String get reportABug => 'Signaler un Bug';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+141 -100
View File
@@ -31,82 +31,85 @@ late MassivAudioHandler audioHandler;
// Global debug logger instance
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');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// Create auth manager for streaming headers
final authManager = AuthManager();
// Initialize audio_service with our custom handler
audioHandler = await AudioService.init(
builder: () => MassivAudioHandler(authManager: authManager),
config: const AudioServiceConfig(
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
androidNotificationChannelName: 'Ensemble Audio',
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
// Set preferred orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
// 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
void main() {
// All initialization must run inside the same zone as runApp() to avoid
// "Zone mismatch" errors from Flutter's binding system
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
() 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');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// Create auth manager for streaming headers
final authManager = AuthManager();
// Initialize audio_service with our custom handler
audioHandler = await AudioService.init(
builder: () => MassivAudioHandler(authManager: authManager),
config: const AudioServiceConfig(
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
androidNotificationChannelName: 'Ensemble Audio',
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
// Set preferred orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
// 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
};
runApp(const MusicAssistantApp());
},
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
@@ -128,6 +131,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
StreamSubscription? _absoluteVolumeSub;
String? _lastSelectedPlayerId;
String? _builtinPlayerId;
@@ -140,6 +144,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
audioHandler.setProvider(_musicProvider);
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
@@ -158,6 +163,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
_adjustVolume(-_volumeStep);
});
// Listen for absolute volume changes from lockscreen/background
// (ContentObserver on system STREAM_MUSIC volume)
_absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) {
_setAbsoluteVolume(volume);
});
} catch (e, stack) {
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
}
@@ -173,18 +184,41 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
/// Enable/disable volume button interception based on selected player.
/// Also manages the volume observer for lockscreen volume routing.
Future<void> _updateVolumeInterception() async {
final player = _musicProvider.selectedPlayer;
final isBuiltinPlayer = _builtinPlayerId != null &&
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
player?.playerId == _builtinPlayerId;
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
// Start/stop volume observer for lockscreen volume changes.
// When a remote/group player is active, observe system volume changes
// so lockscreen hardware buttons route to the MA player.
if (!isBuiltinPlayer && player != null) {
// Get the effective volume to sync the system volume HUD
final effectiveVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
await _hardwareVolumeService.startVolumeObserver(initialVolume: effectiveVolume);
} else {
await _hardwareVolumeService.stopVolumeObserver();
}
}
Future<void> _adjustVolume(int delta) async {
final player = _musicProvider.selectedPlayer;
if (player == null) return;
final newVolume = (player.volume + delta).clamp(0, 100);
if (newVolume != player.volume) {
// Use effective volume for group players (player.volume returns 0
// because MA API returns null for group player volume_level)
final currentVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
final newVolume = (currentVolume + delta).clamp(0, 100);
if (newVolume != currentVolume) {
try {
await _musicProvider.setVolume(player.playerId, newVolume);
} catch (e) {
@@ -193,11 +227,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
}
/// Handle absolute volume change from lockscreen/background.
/// Called when the ContentObserver detects a system volume change
/// while a remote/group player is active.
Future<void> _setAbsoluteVolume(int volume) async {
final player = _musicProvider.selectedPlayer;
if (player == null) return;
// Don't route to builtin player - that's handled by the system directly
if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return;
try {
await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100));
} catch (e) {
_logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e);
}
}
@override
void dispose() {
_musicProvider.removeListener(_onProviderChanged);
_volumeUpSub?.cancel();
_volumeDownSub?.cancel();
_absoluteVolumeSub?.cancel();
_hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -329,28 +381,13 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
// Also include hidden WebView for remote WebRTC bridge
final musicProvider = context.read<MusicAssistantProvider>();
return Stack(
children: [
GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
// WebView for WebRTC - must be VISIBLE to avoid Android throttling
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
borderRadius: BorderRadius.circular(2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: musicProvider.remoteBridgeWebView,
),
// Wrap entire app with global player overlay.
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
return Overlay(
initialEntries: [
OverlayEntry(
builder: (context) => GlobalPlayerOverlay(
child: child ?? const SizedBox.shrink(),
),
),
],
@@ -499,6 +536,9 @@ class _AppStartupState extends State<AppStartup> {
Future<void> _checkAndConnect() async {
final serverUrl = await SettingsService.getServerUrl();
// DEBUG: Log where server URL is coming from
_logger.log('🐛 DEBUG: Server URL from SharedPreferences: $serverUrl');
if (!mounted) return;
setState(() {
@@ -507,6 +547,7 @@ class _AppStartupState extends State<AppStartup> {
// If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) {
_logger.log('🐛 DEBUG: Auto-connecting because server URL exists');
setState(() {
_isConnecting = true;
});
+32 -8
View File
@@ -31,20 +31,14 @@ class ProviderMapping {
});
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
// Parse in_library field - MA uses 1/0 or true/false
// Default to true if not present (items from library_items API are in library)
final inLibraryValue = json['in_library'];
final inLibrary = inLibraryValue == null
? true // Default: items fetched from library are in library
: inLibraryValue == true || inLibraryValue == 1;
return ProviderMapping(
itemId: json['item_id'] as String? ?? '',
providerDomain: json['provider_domain'] as String? ?? '',
providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?,
inLibrary: inLibrary,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
@@ -143,6 +137,12 @@ class MediaItem {
@override
int get hashCode => Object.hash(itemId, provider);
/// Check if this item is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
}
class Artist extends MediaItem {
@@ -171,6 +171,12 @@ class Artist extends MediaItem {
);
}
/// Check if this artist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() => super.toJson();
}
@@ -292,6 +298,12 @@ class Track extends MediaItem {
String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Check if this track is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
@@ -338,6 +350,12 @@ class Playlist extends MediaItem {
favorite: item.favorite,
);
}
/// Check if this playlist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
}
class Chapter {
@@ -534,6 +552,12 @@ class Audiobook extends MediaItem {
String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0)
double get progress {
if (fullyPlayed == true) return 1.0;
+41 -24
View File
@@ -17,6 +17,7 @@ class Player {
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({
required this.playerId,
@@ -35,6 +36,7 @@ class Player {
this.activeSource,
this.isExternalSource = false,
this.appId,
this.activeQueue,
});
/// Create a copy of this Player with some fields replaced
@@ -55,6 +57,7 @@ class Player {
String? activeSource,
bool? isExternalSource,
String? appId,
String? activeQueue,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -73,6 +76,7 @@ class Player {
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
);
}
@@ -218,50 +222,61 @@ class Player {
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
// Parse active_queue - this is the key indicator for MA-controlled playback
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
// When external source: active_queue = null
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
// If MA explicitly says it's playing, trust that
if (appId == 'music_assistant') {
isExternalSource = false;
}
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue
else if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, airplay://, etc. as external here -
// those should only be external if app_id indicates external control
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
isExternalSource = isSimpleExternalId;
}
// Also check media_type - 'unknown' often indicates external source
// Also check media_type - 'unknown' with non-MA URI indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
@@ -287,6 +302,7 @@ class Player {
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
activeQueue: activeQueue,
);
}
@@ -308,6 +324,7 @@ class Player {
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
};
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'media_item.dart';
class RecommendationFolder {
final String itemId;
final String provider;
final String name;
final String? uri;
final List<MediaItem> items;
RecommendationFolder({
required this.itemId,
required this.provider,
required this.name,
this.uri,
required this.items,
});
/// Unique ID for use in row ordering and settings
/// Uses 'discovery:' prefix to distinguish from static row IDs
String get rowId => 'discovery:$itemId';
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>()
.map(_parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
name: json['name'] as String? ?? '',
uri: json['uri'] as String?,
items: parsedItems,
);
}
Map<String, dynamic> toJson() {
return {
'item_id': itemId,
'provider': provider,
'name': name,
if (uri != null) 'uri': uri,
'items': items.map((item) => item.toJson()).toList(),
};
}
}
/// Polymorphic parser for media items in recommendation folders
/// Handles heterogeneous items (playlists, radio, etc.)
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
final mediaType = json['media_type'] as String? ?? '';
switch (mediaType) {
case 'playlist':
return Playlist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'artist':
return Artist.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
default:
// For radio, podcast, podcast_episode - use MediaItem base class
return MediaItem.fromJson(json);
}
}
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -17,14 +17,14 @@ class NavigationProvider extends ChangeNotifier {
if (previousIndex != index) {
_selectedIndex = index;
notifyListeners();
}
// Trigger search focus callback when switching to search tab
if (index == 2 && onSearchTabSelected != null) {
// Use post-frame callback to ensure SearchScreen is built first
WidgetsBinding.instance.addPostFrameCallback((_) {
onSearchTabSelected?.call();
});
}
// Trigger search focus callback when switching to or re-tapping search tab
if (index == 2 && onSearchTabSelected != null) {
// Use post-frame callback to ensure SearchScreen is built first
WidgetsBinding.instance.addPostFrameCallback((_) {
onSearchTabSelected?.call();
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+296 -73
View File
@@ -1,16 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/media_context_menu.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
@@ -33,6 +37,7 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -116,6 +121,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}
}
/// Toggle favorite status for an audiobook
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(book.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = book.provider;
String actualItemId = book.itemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => book.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success && mounted) {
setState(() {
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
if (index != -1) {
_audiobooks[index] = Audiobook(
itemId: book.itemId,
provider: book.provider,
name: book.name,
sortName: book.sortName,
uri: book.uri,
providerMappings: book.providerMappings,
favorite: newState,
metadata: book.metadata,
year: book.year,
);
}
});
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
}
}
/// Toggle library status for an audiobook
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !book.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
// Refresh would require reloading from parent - for now just show success
_logger.log('Audiobook library status toggled');
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
@override
Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -150,11 +298,13 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface,
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;
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
final expandedHeight = coverSize + 25;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -220,83 +370,112 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
// Author info on the left
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
// Three-dot Menu Button on the right
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
],
),
),
),
// Audiobooks Section Header with controls
// Audiobooks Section Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
child: Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
),
),
_buildAudiobookSliver(),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
@@ -344,10 +523,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0,
@@ -438,7 +638,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
@@ -450,9 +651,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -533,7 +755,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
);
}
+225 -42
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -35,6 +38,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger();
bool _isDescriptionExpanded = false;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedChapterIndex;
@@ -46,76 +50,103 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
void initState() {
super.initState();
_isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer detail loading until after hero animation completes
// This prevents setState with new data during animation → jank
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
_loadAudiobookDetails();
}
});
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
}
Future<void> _loadAudiobookDetails() async {
if (_isLoadingDetails) return;
setState(() {
_isLoadingDetails = true;
});
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached data immediately (if available)
final cached = maProvider.getCachedAudiobookDetail(
widget.audiobook.provider,
widget.audiobook.itemId,
);
if (cached != null && mounted) {
_logger.log('📚 Showing cached audiobook: ${cached.name}, chapters: ${cached.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = cached;
_isLoadingDetails = false;
});
} else {
setState(() {
_isLoadingDetails = true;
});
}
// 2. Fetch fresh data in background
try {
final maProvider = context.read<MusicAssistantProvider>();
final fullBook = await maProvider.getAudiobookDetailsWithCache(
widget.audiobook.provider,
widget.audiobook.itemId,
forceRefresh: cached != null,
);
if (maProvider.api != null) {
final fullBook = await maProvider.api!.getAudiobookDetails(
widget.audiobook.provider,
widget.audiobook.itemId,
);
if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = fullBook;
});
}
}
if (mounted) {
if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = fullBook;
_isLoadingDetails = false;
});
}
} catch (e) {
_logger.log('Error loading audiobook details: $e');
if (mounted) {
setState(() {
_isLoadingDetails = false;
});
}
}
if (mounted && _isLoadingDetails) {
setState(() {
_isLoadingDetails = false;
});
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final imageUrl = maProvider.getImageUrl(_audiobook, size: 512);
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for audiobook: $e');
@@ -209,6 +240,88 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.audiobook.provider != 'library') {
actualProvider = widget.audiobook.provider;
actualItemId = widget.audiobook.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -384,7 +497,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final providerImageUrl = maProvider.getImageUrl(_audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
@@ -427,7 +540,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -556,7 +671,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -652,7 +767,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
// Play/Resume Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -686,26 +800,33 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.replay),
child: const Icon(Icons.replay, size: 25),
),
),
],
const SizedBox(width: 12),
// Play On Button
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
),
@@ -724,10 +845,50 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
@@ -787,7 +948,29 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
),
+132 -71
View File
@@ -44,24 +44,30 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
// Extracted colors from book covers
List<Color> _extractedColors = [];
// Cover URLs - start with initial covers, update when books load
// Cover URLs - set once from initialCovers or first book load, then frozen
List<String> _coverUrls = [];
// Cached cover widget - built once, never rebuilt
Widget? _cachedCoverWidget;
@override
void initState() {
super.initState();
// Use initial covers immediately for smooth Hero animation
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
_coverUrls = List.from(widget.initialCovers!);
// Start color extraction from initial covers
_extractColorsFromUrls(widget.initialCovers!);
}
_loadViewPreferences();
// Defer loading until after first frame to ensure UI renders first
// Defer color extraction and loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_loadSeriesBooks();
}
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
if (_coverUrls.isNotEmpty) {
_extractColorsFromUrls(_coverUrls);
}
_loadSeriesBooks();
}
});
});
}
@@ -158,35 +164,68 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Future<void> _loadSeriesBooks() async {
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
try {
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached data immediately (if available)
final cached = maProvider.getCachedSeriesAudiobooks(widget.series.id);
if (cached != null && cached.isNotEmpty && mounted) {
_logger.log('📚 Showing ${cached.length} cached series books');
_applyBooks(cached, maProvider);
} else {
setState(() {
_isLoading = true;
_error = null;
});
_logger.log('📚 SeriesScreen setState done, getting provider...');
final maProvider = context.read<MusicAssistantProvider>();
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
}
// 2. Fetch fresh data in background
try {
if (maProvider.api == null) {
setState(() {
_error = S.of(context)!.notConnected;
_isLoading = false;
});
if (cached == null || cached.isEmpty) {
setState(() {
_error = S.of(context)!.notConnected;
_isLoading = false;
});
}
return;
}
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
final books = await maProvider.getSeriesAudiobooksWithCache(
widget.series.id,
forceRefresh: cached != null && cached.isNotEmpty,
);
_logger.log('📚 SeriesScreen got ${books.length} books');
// Debug: Log sequence data for each book
for (final book in books) {
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
if (mounted && books.isNotEmpty) {
final changed = _audiobooks.length != books.length;
if (changed || _audiobooks.isEmpty) {
_applyBooks(books, maProvider);
}
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted && _audiobooks.isEmpty) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
}
if (mounted) {
// Update cover URLs from loaded books
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _applyBooks(List<Audiobook> books, MusicAssistantProvider maProvider) {
// Only populate covers if we have none yet (initialCovers was empty)
final needsCovers = _coverUrls.isEmpty;
setState(() {
_audiobooks = books;
_sortAudiobooks();
if (needsCovers) {
final newCovers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
@@ -194,34 +233,15 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
newCovers.add(imageUrl);
}
}
setState(() {
_audiobooks = books;
_sortAudiobooks(); // Sort inside setState so UI updates
// Update covers if we got new ones (may have more than initial)
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
_isLoading = false;
});
// Debug: Log sorted order
_logger.log('📚 Sorted order ($_sortOrder):');
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
}
_isLoading = false;
});
// Extract colors from book covers asynchronously
_extractCoverColors(maProvider);
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
if (needsCovers && _extractedColors.isEmpty) {
_extractCoverColors(maProvider);
}
}
@@ -271,7 +291,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
// Use read since we only need the provider for non-reactive operations (getImageUrl)
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
@@ -282,7 +303,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
@@ -300,25 +323,31 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
children: [
const SizedBox(height: 60),
// Series cover collage - responsive size
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
// Shadow container outside Hero to avoid widget tree mismatch
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
// Hero child matches source: RepaintBoundary > AspectRatio > ClipRRect
child: Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider),
),
),
),
),
@@ -456,7 +485,29 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
);
@@ -490,6 +541,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}
// Return cached cover widget if already built (prevents rebuilds from unrelated setState)
if (_cachedCoverWidget != null) return _cachedCoverWidget!;
final covers = _coverUrls;
// Dynamic grid size based on number of covers (matches library screen)
@@ -513,7 +567,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
}
// Build grid using Column/Row for proper sizing (no scrolling issues)
return Column(
// Cache the widget so it never rebuilds from unrelated setState calls
_cachedCoverWidget = Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
@@ -524,8 +579,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Expanded(
child: coverUrl != null
? CachedNetworkImage(
key: ValueKey(coverUrl),
imageUrl: coverUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
@@ -547,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}),
);
return _cachedCoverWidget!;
}
/// Builds an empty cell with either a solid color or a nested grid
@@ -664,6 +723,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
+2 -6
View File
@@ -41,12 +41,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() {
// Copy only the filtered entries that are currently displayed
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: filteredLogs));
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -63,8 +61,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
report,
subject: S.of(context)!.ensembleBugReport,
);
} catch (e) {
_logger.error('Share failed', error: e);
} finally {
if (mounted) {
setState(() => _isGeneratingReport = false);
+171 -1
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart';
import 'new_library_screen.dart';
@@ -19,6 +22,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
DateTime? _lastBackPress;
final _cachedNavColor = _CachedNavColor();
@override
void initState() {
@@ -105,7 +109,15 @@ class _HomeScreenState extends State<HomeScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
// No bottomNavigationBar here - it's now in GlobalPlayerOverlay
// extendBody: true preserves the existing layout where content fills the
// full screen and screens manage their own bottom padding (BottomSpacing).
// Without this, Scaffold would constrain the body above the nav bar,
// causing double bottom spacing.
extendBody: true,
// BottomNavigationBar requires an Overlay ancestor (for Tooltip);
// Scaffold inside Navigator provides one. Previously it was in
// GlobalPlayerOverlay's Stack (outside Navigator) causing crashes.
bottomNavigationBar: _buildBottomNavigationBar(context, colorScheme),
body: Stack(
children: [
// Home and Library use IndexedStack for state preservation
@@ -131,4 +143,162 @@ class _HomeScreenState extends State<HomeScreen> {
},
);
}
/// Build the bottom navigation bar with adaptive colors and player expansion animation.
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
/// (required by BottomNavigationBar's internal Tooltip widgets).
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
// Hide nav bar when not connected and no cached players
final showNavBar = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected || p.hasCachedPlayers,
);
if (!showNavBar) return null;
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
}
}
/// Cached color with contrast adjustment for nav bar icon colors.
/// Avoids expensive HSL conversions during scroll/animation.
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
+4 -3
View File
@@ -43,7 +43,6 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -66,11 +65,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
centerTitle: true,
),
body: _buildPlaylistsList(context, provider),
body: _buildPlaylistsList(context),
);
}
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
Widget _buildPlaylistsList(BuildContext context) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
+16 -11
View File
@@ -11,7 +11,6 @@ class LibraryTracksScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -34,35 +33,41 @@ class LibraryTracksScreen extends StatelessWidget {
),
centerTitle: true,
),
body: _buildTracksList(context, provider),
body: _buildTracksList(context),
);
}
Widget _buildTracksList(
BuildContext context, MusicAssistantProvider provider) {
Widget _buildTracksList(BuildContext context) {
// Use select to only rebuild when tracks or isLoading changes
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
(p) => (p.tracks, p.isLoading),
);
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
if (tracks.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.tracks.isEmpty) {
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
if (tracks.isEmpty) {
return EmptyState.tracks(
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
}
return ListView.builder(
itemCount: provider.tracks.length,
itemCount: tracks.length,
padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) {
final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index);
final track = tracks[index] as Track;
return _buildTrackTile(context, track, index);
},
);
}
@@ -70,9 +75,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile(
BuildContext context,
Track track,
MusicAssistantProvider maProvider,
int index,
) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128)
: null;
File diff suppressed because it is too large Load Diff
+271 -59
View File
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
@@ -17,6 +18,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -28,9 +30,9 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
static final _logger = DebugLogger();
Key _refreshKey = UniqueKey();
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
@@ -48,6 +50,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Discovery folders (dynamic rows from provider recommendations)
List<RecommendationFolder> _discoveryFolders = [];
// Discovery row preferences (itemId -> enabled)
Map<String, bool> _discoveryRowEnabled = {};
@override
bool get wantKeepAlive => true;
@@ -57,25 +63,82 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
// Reload settings only when switching back to home from settings tab
navigationProvider.addListener(_onNavigationChanged);
}
int _lastNavIndex = 0;
void _onNavigationChanged() {
final newIndex = navigationProvider.selectedIndex;
// Reload preferences when leaving settings tab (3) for home tab (0)
if (_lastNavIndex == 3 && newIndex == 0) {
_reloadPreferencesOnly();
}
_lastNavIndex = newIndex;
}
/// Reload only local preferences (no API calls). Used when switching back from settings.
Future<void> _reloadPreferencesOnly() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
}
@override
void dispose() {
navigationProvider.removeListener(_onNavigationChanged);
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
// Clean up provider listener
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
_refreshSignal.dispose();
super.dispose();
}
@@ -97,21 +160,31 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
if (!mounted || _providerListeningTo == null) return;
final provider = _providerListeningTo!;
final currentRefreshCounter = provider.homeRefreshCounter;
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
if (currentRefreshCounter != _lastHomeRefreshCounter) {
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
_lastHomeRefreshCounter = currentRefreshCounter;
_refreshSignal.value++;
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_refreshSignal.value++;
}
_lastArtistCount = currentCount;
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
@@ -127,21 +200,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
_refreshSignal.value++;
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums();
@@ -157,6 +221,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -172,8 +237,63 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders dynamically (after settings load)
// Skip if already loaded to avoid unnecessary rebuilds on settings reload
if (_discoveryFolders.isEmpty) {
await _loadDiscoveryFolders(forceRefresh: false);
} else {
// Just re-sync the row order with current enabled state (no API call)
setState(() {
_syncDiscoveryRowOrder(_discoveryFolders);
});
}
}
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
try {
var folders = await provider.getDiscoveryFoldersWithCache();
// If cached result is empty, force a refresh to get from API/database
if (folders.isEmpty && !forceRefresh) {
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
}
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_syncDiscoveryRowOrder(folders);
});
}
} catch (e) {
_logger.log('⚠️ Failed to load discovery folders: $e');
}
}
/// Sync discovery row order without calling setState. Only adds new folders, never removes.
/// Visibility is controlled by _discoveryRowEnabled / _isRowEnabled().
bool _syncDiscoveryRowOrder(List<RecommendationFolder> folders) {
bool didModify = false;
for (final folder in folders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
_homeRowOrder.add(discoveryRowId);
didModify = true;
}
}
if (didModify) {
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return didModify;
}
Future<void> _onRefresh() async {
@@ -188,9 +308,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
await _loadSettings();
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
_refreshSignal.value++;
}
}
@@ -227,21 +345,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0,
centerTitle: false,
actions: [
// Copy logs button for debugging
IconButton(
icon: const Icon(Icons.bug_report_outlined, size: 20),
onPressed: () {
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${DebugLogger().entries.length} log entries copied'),
duration: const Duration(seconds: 2),
),
);
},
tooltip: 'Copy logs',
),
// Sync indicator - shows when library is syncing in background
ListenableBuilder(
listenable: SyncService.instance,
@@ -327,6 +430,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
],
);
},
@@ -347,6 +473,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
// Discovery folders use the pattern 'discovery:{itemId}'
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
// Default to true if not explicitly set
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
@@ -371,15 +503,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
// With extendBody: true, constraints include the area behind the nav bar.
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
// Account for margins between rows (2px each, 2 margins for 3 rows).
// Always use the 3-row margin calculation so rowHeight stays stable
// regardless of how many rows are currently enabled (avoids jitter on reload).
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
final rowHeight = (availableHeight - marginsInView) / 3 + 2;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -401,11 +536,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight),
// For ≤3 rows: no padding needed (rows fit within viewport)
// For 4+ rows: pad so last row scrolls above mini player
padding: enabledRows >= 4
? EdgeInsets.only(bottom: miniPlayerSpace)
: EdgeInsets.zero,
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
),
),
),
),
@@ -415,9 +557,51 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
final rows = <Widget>[];
// Show empty state with refresh hint when no rows are enabled
if (isEmpty) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
rows.add(
SizedBox.expand(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
S.of(context)!.noRowsEnabled,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
S.of(context)!.pullToRefreshHint,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
return rows;
}
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
@@ -442,6 +626,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -451,6 +636,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -460,6 +646,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'continue-listening':
@@ -495,6 +682,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-albums'),
title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-artists':
@@ -503,6 +691,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-artists'),
title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-tracks':
@@ -541,6 +730,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight,
);
default:
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final folderId = rowId.substring('discovery:'.length);
// Check per-row preference
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
if (!isEnabled) return null;
try {
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == folderId,
);
return DiscoveryRow(
key: ValueKey(rowId),
title: folder.name,
loadItems: () => provider.getDiscoveryFolderItems(folderId),
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
} catch (e) {
// Folder not found, skip this row
}
}
return null;
}
}
File diff suppressed because it is too large Load Diff
+456 -333
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -10,6 +11,8 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
@@ -41,9 +44,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -55,13 +58,21 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() {
super.initState();
_isFavorite = widget.playlist.favorite ?? false;
_loadTracks();
_isInLibrary = widget.playlist.inLibrary;
// Defer color extraction until after hero animation completes
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer track loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
_loadTracks();
}
});
});
@@ -74,15 +85,18 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for playlist: $e');
@@ -227,6 +241,86 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.playlist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.playlist.provider != 'library') {
actualProvider = widget.playlist.provider;
actualItemId = widget.playlist.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.playlist.provider == 'library') {
libraryItemId = int.tryParse(widget.playlist.itemId);
} else if (widget.playlist.providerMappings != null) {
final libraryMapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.playlist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
// Silent failure
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -324,6 +418,89 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
}
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _playPlaylist() async {
if (_tracks.isEmpty) return;
@@ -387,158 +564,130 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
}
void _addPlaylistToQueue() {
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
},
),
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
});
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addPlaylistToQueue();
},
),
const SizedBox(height: 8),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -656,7 +805,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -741,7 +892,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -782,7 +933,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -802,42 +952,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add to Queue" Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addPlaylistToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
@@ -851,10 +965,53 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
),
),
@@ -888,168 +1045,112 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 28,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: track,
isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 12, right: 20),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 24,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
),
const SizedBox(width: 16),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
// Expandable action buttons
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
onTap: () => _playTrack(index),
),
);
},
childCount: _tracks.length,
@@ -1057,6 +1158,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
+457 -235
View File
@@ -1,16 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
@@ -36,7 +43,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
bool _isFavorite = false;
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,16 +52,117 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
_isFavorite = widget.podcast.favorite ?? false;
_loadSortPreference();
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer episode loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
_loadEpisodes();
}
});
_loadEpisodes();
});
}
Future<void> _loadSortPreference() async {
final sortOrder = await SettingsService.getPodcastEpisodesSortOrder();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
});
}
}
void _toggleSortOrder() {
// Cycle through sort orders: newest -> oldest -> alpha -> duration -> newest
String newOrder;
switch (_sortOrder) {
case 'newest':
newOrder = 'oldest';
break;
case 'oldest':
newOrder = 'alpha';
break;
case 'alpha':
newOrder = 'duration';
break;
default:
newOrder = 'newest';
}
setState(() {
_sortOrder = newOrder;
_sortEpisodes();
});
SettingsService.setPodcastEpisodesSortOrder(newOrder);
}
void _sortEpisodes() {
switch (_sortOrder) {
case 'newest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return bDate.compareTo(aDate); // Newest first
});
break;
case 'oldest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return aDate.compareTo(bDate); // Oldest first
});
break;
case 'alpha':
_episodes.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case 'duration':
_episodes.sort((a, b) {
final aDur = a.duration ?? Duration.zero;
final bDur = b.duration ?? Duration.zero;
return aDur.compareTo(bDur); // Shortest first
});
break;
}
}
DateTime? _getPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
if (dateValue is String) {
return DateTime.tryParse(dateValue);
} else if (dateValue is int) {
return DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
} catch (e) {
// Silent failure
}
return null;
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
@@ -186,6 +295,71 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
/// Toggle favorite status
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
try {
bool success;
if (newState) {
String actualProvider = widget.podcast.provider;
String actualItemId = widget.podcast.itemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final mapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.podcast.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.podcast.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'podcast',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
setState(() => _isFavorite = newState);
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling podcast favorite: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
@@ -193,14 +367,17 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
@@ -211,67 +388,76 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached episodes immediately (if available)
final cached = maProvider.getCachedPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (cached != null && cached.isNotEmpty && mounted) {
_logger.log('🎙️ Showing ${cached.length} cached episodes for ${widget.podcast.name}');
setState(() {
_episodes = cached;
_sortEpisodes();
_isLoadingEpisodes = false;
});
} else {
setState(() {
_isLoadingEpisodes = true;
});
}
// 2. Fetch fresh data in background
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
List<MediaItem> episodes = [];
List<MediaItem> episodes = [];
// Try loading with the podcast's own ID and provider first
try {
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// Try loading with the podcast's own ID and provider first
episodes = await maProvider.getPodcastEpisodesWithCache(
widget.podcast.itemId,
provider: widget.podcast.provider,
forceRefresh: cached != null && cached.isNotEmpty,
);
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.getPodcastEpisodesWithCache(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
}
}
}
}
if (mounted) {
if (mounted && episodes.isNotEmpty) {
final changed = _episodes.length != episodes.length;
if (changed || _episodes.isEmpty) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
_sortEpisodes();
});
}
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
if (mounted && _isLoadingEpisodes) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
@@ -406,6 +592,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
/// Show context menu for an episode
void _showEpisodeContextMenu(BuildContext context, Offset position, MediaItem episode, String? episodeImageUrl) {
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcastEpisode,
item: episode,
isFavorite: false,
isInLibrary: false,
onPlay: () => _playEpisode(episode),
onPlayOn: () => _showPlayOnMenu(context, episode),
onAddToQueue: () => _addToQueue(episode),
onViewDetails: () {
final maProvider = this.context.read<MusicAssistantProvider>();
final initialImageUrl = episodeImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
Navigator.push(
this.context,
FadeSlidePageRoute(
child: PodcastEpisodeDetailScreen(
episode: episode,
podcast: widget.podcast,
initialImageUrl: initialImageUrl,
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -455,7 +670,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -537,7 +754,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -569,7 +786,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -591,24 +807,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
@@ -622,10 +820,76 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Favorite button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoadingEpisodes || _episodes.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoadingEpisodes || _episodes.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
),
),
@@ -732,190 +996,126 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
return GestureDetector(
onTap: () => _playEpisode(episode),
onLongPressStart: (details) => _showEpisodeContextMenu(
context,
details.globalPosition,
episode,
episodeImageUrl,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover
Hero(
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
],
],
),
),
),
],
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
);
},
childCount: _episodes.length,
@@ -927,6 +1127,28 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
@@ -0,0 +1,528 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastEpisodeDetailScreen extends StatefulWidget {
final MediaItem episode;
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastEpisodeDetailScreen({
super.key,
required this.episode,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastEpisodeDetailScreen> createState() => _PodcastEpisodeDetailScreenState();
}
class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors();
}
});
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Try episode image first, fallback to podcast image
final imageUrl = widget.initialImageUrl ??
maProvider.getImageUrl(widget.episode, size: 512) ??
maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Error extracting colors for episode: $e');
}
}
}
void _playEpisode() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, widget.episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${widget.episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu();
}
}
void _showPlayOnMenu() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, widget.episode);
},
);
}
Future<void> _addToQueue() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
final track = Track(
itemId: widget.episode.itemId,
provider: widget.episode.provider,
name: widget.episode.name,
uri: widget.episode.uri,
duration: widget.episode.duration,
metadata: widget.episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '$minutes min';
}
String? _formatPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('Error parsing release date: $e');
}
return null;
}
String _stripHtml(String htmlText) {
final withoutTags = htmlText.replaceAll(RegExp(r'<[^>]*>'), '');
final decoded = withoutTags
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
return decoded.replaceAll(RegExp(r'\s+'), ' ').trim();
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final episodeImageUrl = maProvider.getImageUrl(widget.episode, size: 512);
final podcastImageUrl = maProvider.getPodcastImageUrl(widget.podcast);
final imageUrl = widget.initialImageUrl ?? episodeImageUrl ?? podcastImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.episode.metadata?['description'] as String?;
final duration = widget.episode.duration;
final publishDate = _formatPublishDate(widget.episode.metadata);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastEpisodeCover +
(widget.episode.uri ?? widget.episode.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: coverSize,
height: coverSize,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
if (widget.episode.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.episode.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
],
),
),
),
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
widget.episode.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
// Podcast name (tappable to go back)
InkWell(
onTap: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.podcast.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
),
Spacing.vGap16,
// Duration and publish date
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (duration != null && duration > Duration.zero && publishDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (publishDate != null)
Text(
publishDate,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 24),
// Action buttons row
Row(
children: [
// Main Play Button
Expanded(
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _playEpisode,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// Play On... Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _showPlayOnMenu,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 25),
),
),
const SizedBox(width: 12),
// Add to Queue Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 25),
),
),
],
),
],
),
),
),
// Full scrollable description (key feature)
if (description != null && description.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap12,
// Full description - no maxLines, fully scrollable with selectable text
SelectableText(
_stripHtml(description),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
),
],
),
),
),
// Bottom spacing for mini player
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
),
);
}
}
-257
View File
@@ -1,257 +0,0 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../l10n/app_localizations.dart';
/// A screen for scanning QR codes to get the Remote Access ID.
class QrScannerScreen extends StatefulWidget {
const QrScannerScreen({super.key});
@override
State<QrScannerScreen> createState() => _QrScannerScreenState();
}
class _QrScannerScreenState extends State<QrScannerScreen> {
late final MobileScannerController _controller;
bool _hasScanned = false;
String _statusMessage = '';
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
formats: [BarcodeFormat.qrCode], // Only scan QR codes
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_hasScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
final String? code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
final remoteId = _extractRemoteId(code);
if (remoteId != null && remoteId.isNotEmpty) {
_hasScanned = true;
Navigator.of(context).pop(remoteId);
return;
} else {
// Show what was scanned for debugging
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Scanned: $code'),
duration: const Duration(seconds: 5),
),
);
}
}
}
}
/// Extract the remote ID from various QR code formats
String? _extractRemoteId(String code) {
// Format 1: Raw remote ID (alphanumeric)
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
return code;
}
// Format 2: ma://remote/<id>
if (code.startsWith('ma://remote/')) {
return code.replaceFirst('ma://remote/', '');
}
// Format 3: URL from app.music-assistant.io
// Examples:
// - https://app.music-assistant.io/?cloudInstanceId=<id>
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
if (code.contains('music-assistant.io')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check query parameters in main URL
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
for (final param in queryParams) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
final fragment = uri.fragment;
if (fragment.contains('?')) {
final fragmentQuery = fragment.split('?').last;
final fragmentParams = Uri.splitQueryString(fragmentQuery);
for (final param in queryParams) {
final value = fragmentParams[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
}
// Check if ID is in path segments
for (final segment in uri.pathSegments) {
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
return segment;
}
}
}
}
// Format 4: Other URL formats - try to extract from query params or path
if (code.contains('://')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check common query param names
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Last path segment if it looks like an ID
if (uri.pathSegments.isNotEmpty) {
final last = uri.pathSegments.last;
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
return last;
}
}
}
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(S.of(context)!.scanQrCode),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
// Toggle torch
IconButton(
icon: ValueListenableBuilder(
valueListenable: _controller,
builder: (context, state, child) {
return Icon(
state.torchState == TorchState.on
? Icons.flash_on
: Icons.flash_off,
);
},
),
onPressed: () => _controller.toggleTorch(),
),
],
),
body: Stack(
children: [
// Camera preview
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt_outlined,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Camera error: ${error.errorCode.name}',
style: TextStyle(color: colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
textAlign: TextAlign.center,
),
],
),
);
},
),
// Scan overlay with instruction
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Scan frame
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.8),
width: 2,
),
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 32),
// Instruction text
Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context)!.pointCameraAtQr,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
if (_statusMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 12,
),
),
],
],
),
),
],
),
),
],
),
);
}
}
+6 -5
View File
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
// Use select to only rebuild when selectedPlayer changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
@@ -153,7 +153,7 @@ class _QueueScreenState extends State<QueueScreen> {
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider),
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()),
),
IconButton(
icon: const Icon(Icons.refresh),
@@ -161,11 +161,12 @@ class _QueueScreenState extends State<QueueScreen> {
),
],
),
body: _buildBody(maProvider),
body: _buildBody(context),
);
}
Widget _buildBody(MusicAssistantProvider maProvider) {
Widget _buildBody(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
File diff suppressed because it is too large Load Diff
+576 -77
View File
@@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -19,8 +25,10 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
final _scrollController = ScrollController();
final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
@@ -39,19 +47,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
rows.add(discoveryRowId);
}
}
return rows;
}
@override
void initState() {
super.initState();
_loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
}
Future<void> _loadSettings() async {
@@ -85,9 +151,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder();
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
@@ -96,6 +160,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -111,26 +178,104 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
}
@override
void dispose() {
_scrollController.dispose();
_lastFmApiKeyController.dispose();
_audioDbApiKeyController.dispose();
super.dispose();
}
/// Load discovery folders from Music Assistant
Future<void> _loadDiscoveryFolders() async {
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
final provider = context.read<MusicAssistantProvider>();
try {
final folders = await provider.getDiscoveryFoldersWithCache();
// Also reload discovery row preferences to ensure they're up to date
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Refresh discovery folders from Music Assistant
Future<void> _refreshDiscoveryFolders() async {
final provider = context.read<MusicAssistantProvider>();
try {
setState(() {
_isLoadingDiscoveryFolders = true;
});
await provider.refreshDiscoveryFolders();
final folders = provider.getCachedDiscoveryFolders() ?? [];
if (mounted) {
setState(() {
_discoveryFolders = folders;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Toggle a specific discovery row on/off
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
setState(() {
_discoveryRowEnabled[itemId] = enabled;
});
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == itemId,
orElse: () => RecommendationFolder(
itemId: itemId,
provider: 'unknown',
name: rowId,
items: [],
),
);
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
}
switch (rowId) {
case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -138,6 +283,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
@@ -163,6 +310,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
@@ -196,6 +348,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
_discoveryRowEnabled[itemId] = value;
SettingsService.setDiscoveryRowPreference(itemId, value);
// Always add to home row order for position tracking
if (!_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return;
}
// Save the setting
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
@@ -245,6 +411,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
default:
break;
}
// When enabling a row, add it to home row order if not already present
if (value && !_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
});
}
@@ -267,7 +441,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
// Use select to only rebuild when connectionState changes
final connectionState = context.select<MusicAssistantProvider, MAConnectionState>((p) => p.connectionState);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -278,7 +453,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
onPressed: () {
// Settings is rendered conditionally in HomeScreen (not pushed as a route),
// so Navigator.pop would pop the entire HomeScreen causing a black screen.
// Instead, switch tab back to Home and restore the mini player.
GlobalPlayerOverlay.showPlayer();
navigationProvider.setSelectedIndex(0);
},
color: colorScheme.onBackground,
),
title: Text(
@@ -295,9 +476,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DebugLogScreen(),
),
FadeSlidePageRoute(child: const DebugLogScreen()),
);
},
color: colorScheme.onBackground,
@@ -306,6 +485,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -345,15 +525,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getStatusIcon(provider.connectionState),
color: _getStatusColor(provider.connectionState, colorScheme),
_getStatusIcon(connectionState),
color: _getStatusColor(connectionState, colorScheme),
size: 20,
),
const SizedBox(width: 8),
Text(
_getStatusText(provider.connectionState),
_getStatusText(connectionState),
style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(provider.connectionState, colorScheme),
color: _getStatusColor(connectionState, colorScheme),
fontWeight: FontWeight.bold,
),
),
@@ -703,33 +883,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -754,7 +907,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -763,36 +916,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
S.of(context)!.disableAutoSwitch,
S.of(context)!.volumePrecisionMode,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
S.of(context)!.volumePrecisionModeDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
@@ -903,7 +1031,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Reorderable home rows list
// Reorderable home rows list (exclude discovery-mixes from list)
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -914,24 +1042,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _homeRowOrder.length,
itemCount: _filteredRowOrder.length,
onReorder: (oldIndex, newIndex) {
// Create a mutable copy of the filtered list for reordering
final displayRows = List<String>.from(_filteredRowOrder);
if (newIndex > oldIndex) newIndex--;
final item = displayRows.removeAt(oldIndex);
displayRows.insert(newIndex, item);
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
// Update _homeRowOrder with the new order (all rows, including discovery)
_homeRowOrder = displayRows;
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _homeRowOrder[index];
final rowId = _filteredRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _homeRowOrder.length - 1
border: index < _filteredRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
@@ -1062,6 +1195,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// About & Support section
Text(
S.of(context)!.aboutAndSupport,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Support Development banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFEA4AAA).withOpacity(0.15),
Color(0xFF1F6FEB).withOpacity(0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.favorite_rounded,
color: Color(0xFFEA4AAA),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
S.of(context)!.supportDevelopment,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
S.of(context)!.ensembleIsUnofficial,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
// GitHub Sponsors button
Expanded(
child: _DonationButton(
label: S.of(context)!.sponsorOnGitHub,
color: Color(0xFF1F6FEB),
iconWidget: Icon(MdiIcons.github, size: 20),
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
),
),
const SizedBox(width: 12),
// Ko-fi button
Expanded(
child: _DonationButton(
icon: null,
label: S.of(context)!.buyMeAKoFi,
color: Color(0xFFFF5E5B),
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// About Ensemble card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.aboutEnsemble,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
S.of(context)!.version,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(width: 4),
Text(
_appVersion,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
_LinkButton(
icon: Icons.code_rounded,
label: S.of(context)!.visitGitHubRepo,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
),
const SizedBox(height: 8),
_LinkButton(
icon: Icons.bug_report_outlined,
label: S.of(context)!.reportABug,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -1139,4 +1415,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
/// Build the discovery section with individual row toggles
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
final s = S.of(context)!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and refresh button
Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
s.discoveryRows,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
if (_isLoadingDiscoveryFolders)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _refreshDiscoveryFolders,
tooltip: s.refreshDiscoveryRows,
color: colorScheme.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
const SizedBox(height: 12),
// Discovery rows list
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
s.noDiscoveryRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
)
else
..._discoveryFolders.map((folder) {
final itemId = folder.itemId;
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SwitchListTile(
title: Text(
folder.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
subtitle: folder.provider.isNotEmpty
? Text(
folder.provider,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 11,
),
)
: null,
value: isEnabled,
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
dense: true,
),
);
}).toList(),
],
),
);
}
}
// Custom donation button widget
class _DonationButton extends StatelessWidget {
final String? icon;
final String label;
final Color color;
final VoidCallback onTap;
final Widget? iconWidget;
const _DonationButton({
this.icon,
required this.label,
required this.color,
required this.onTap,
this.iconWidget,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconWidget != null)
iconWidget!
else if (icon != null)
Image.asset(
icon!,
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
// Custom link button widget
class _LinkButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _LinkButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.primary.withOpacity(0.7),
),
],
),
),
),
);
}
}
+770 -1
View File
@@ -2,12 +2,17 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
import '../debug_logger.dart';
import '../auth/auth_manager.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' as ma;
/// Custom AudioHandler for Ensemble that provides full control over
/// notification actions and metadata updates.
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
final AuthManager authManager;
final _logger = DebugLogger();
@@ -32,6 +37,16 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Track whether we're in remote control mode (controlling MA player, not playing locally)
bool _isRemoteMode = false;
// Android Auto: provider reference (set after app initialises)
MusicAssistantProvider? _autoProvider;
// Android Auto: track queue cache — maps context key to ordered track list.
// Populated during getChildren so playFromMediaId can queue the full album/playlist.
final Map<String, List<ma.Track>> _autoTrackCache = {};
// Android Auto: subjects for subscribeToChildren
final Map<String, BehaviorSubject<Map<String, dynamic>>> _autoChildrenSubjects = {};
// Custom control for switching players (uses stop action with custom icon)
static const _switchPlayerControl = MediaControl(
androidIcon: 'drawable/ic_switch_player',
@@ -312,6 +327,760 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
MediaItem? get currentMediaItem => _currentMediaItem;
// ---------------------------------------------------------------------------
// Android Auto — media browsing and playback
// ---------------------------------------------------------------------------
/// Wire the provider in after the app has initialised.
/// Called from _MusicAssistantAppState.initState().
void setProvider(MusicAssistantProvider provider) {
_autoProvider = provider;
_logger.log('AndroidAuto: provider set');
}
// Media ID constants — root categories
static const _autoIdHome = 'cat|home';
static const _autoIdMusic = 'cat|music';
static const _autoIdAudiobooks = 'cat|audiobooks';
static const _autoIdPodcasts = 'cat|podcasts';
static const _autoIdRadio = 'cat|radio';
// Media ID constants — Music subcategories
static const _autoIdPlaylists = 'cat|playlists';
static const _autoIdArtists = 'cat|artists';
static const _autoIdAlbums = 'cat|albums';
static const _autoIdFavorites = 'cat|favorites';
// Media ID constants — Audiobook subcategories
static const _autoIdAbAuthors = 'cat|ab_authors';
static const _autoIdAbBooks = 'cat|ab_books';
static const _autoIdAbSeries = 'cat|ab_series';
// Android Auto content style hints
static const _gridHints = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
// Icon URIs for category items
static const _iconPkg = 'com.collotsspot.ensemble';
static final _iconHome = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_home');
static final _iconMusic = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_music');
static final _iconBook = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_book');
static final _iconPodcast = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_podcast');
static final _iconRadio = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_radio');
static final _iconArtist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_artist');
static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album');
static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist');
static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite');
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet');
return [];
}
// Ensure provider is connected — AA may browse before app is fully ready
if (!provider.isConnected) {
_logger.log('AndroidAuto: provider disconnected, starting reconnect');
// Don't await — reconnection can take seconds and AA may timeout
// getChildren. Cached data is served immediately; live API calls
// inside builders will await their own reconnect if needed.
provider.checkAndReconnect();
}
try {
final result = await _autoGetChildren(provider, parentMediaId);
_logger.log('AndroidAuto: getChildren "$parentMediaId" → ${result.length} items');
return result;
} catch (e, st) {
_logger.log('AndroidAuto: getChildren error for "$parentMediaId": $e\n$st');
return [];
}
}
Future<List<MediaItem>> _autoGetChildren(
MusicAssistantProvider provider, String parentMediaId) async {
switch (parentMediaId) {
// Root
case AudioService.browsableRootId:
return _autoBuildRoot();
// Home — subcategory folders matching user's homescreen settings
case _autoIdHome:
return _autoBuildHome();
// Music
case _autoIdMusic:
return _autoBuildMusicCategories();
case _autoIdPlaylists:
return _autoBuildPlaylistList(provider);
case _autoIdArtists:
return _autoBuildArtistList(provider);
case _autoIdAlbums:
return _autoBuildAlbumList(provider);
case _autoIdFavorites:
return _autoBuildFavorites(provider);
// Audiobooks
case _autoIdAudiobooks:
return _autoBuildAudiobookCategories();
case _autoIdAbAuthors:
return _autoBuildAudiobookAuthorList(provider);
case _autoIdAbBooks:
return _autoBuildAudiobookList(provider);
case _autoIdAbSeries:
return _autoBuildAudiobookSeriesList(provider);
// Podcasts
case _autoIdPodcasts:
return _autoBuildPodcastList(provider);
// Radio
case _autoIdRadio:
return _autoBuildRadioList(provider);
default:
// Home row content (home|recent-albums, etc.)
if (parentMediaId.startsWith('home|')) {
return _autoBuildHomeRowContent(provider, parentMediaId.substring(5));
}
return _autoBuildDynamicChildren(provider, parentMediaId);
}
}
/// Route prefix-based dynamic media IDs (albums, playlists, artists, etc.)
Future<List<MediaItem>> _autoBuildDynamicChildren(
MusicAssistantProvider provider, String parentMediaId) async {
final parts = parentMediaId.split('|');
if (parts.length < 2) return [];
switch (parts[0]) {
case 'playlist':
if (parts.length >= 3) {
return _autoBuildPlaylistTracks(provider, parts[1], parts[2]);
}
case 'album':
if (parts.length >= 3) {
return _autoBuildAlbumTracks(provider, parts[1], parts[2]);
}
case 'artist':
final name = parts.sublist(1).join('|');
return _autoBuildArtistAlbums(provider, name);
case 'podcast':
if (parts.length >= 3) {
return _autoBuildPodcastEpisodes(provider, parts[1], parts[2]);
}
case 'ab_author':
final authorName = parts.sublist(1).join('|');
return _autoBuildAuthorAudiobooks(provider, authorName);
case 'ab_series':
final seriesPath = parts.sublist(1).join('|');
return _autoBuildSeriesAudiobooks(provider, seriesPath);
}
_logger.log('AndroidAuto: unhandled parentMediaId "$parentMediaId"');
return [];
}
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_autoChildrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _autoChildrenSubjects[parentMediaId]!.stream;
}
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: playFromMediaId called before provider is set');
return;
}
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) {
_logger.log('AndroidAuto: no builtin player ID — cannot play');
return;
}
// Auto-switch to the builtin (local) player when AA triggers playback
if (provider.selectedPlayer?.playerId != playerId) {
final builtinPlayer = provider.availablePlayersUnfiltered
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('AndroidAuto: switching to builtin player "${builtinPlayer.name}"');
provider.selectPlayer(builtinPlayer);
}
}
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _autoTrackCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
_logger.log('AndroidAuto: cache miss for $ctxKey, playing single track');
await provider.playTrack(
playerId,
ma.Track(itemId: tItemId, provider: tProvider, name: ''),
);
return;
}
final index = trackList.indexWhere(
(t) => t.provider == tProvider && t.itemId == tItemId,
);
await provider.playTracks(
playerId,
trackList,
startIndex: index < 0 ? 0 : index,
);
return;
}
if (mediaId.startsWith('radio|')) {
// Format: radio|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play radio');
return;
}
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => provider.radioStationsUnfiltered.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Radio station not found: $mediaId'),
),
);
await provider.api!.playRadioStation(playerId, station);
return;
}
if (mediaId.startsWith('audiobook|')) {
// Format: audiobook|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) {
await provider.checkAndReconnect();
}
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play audiobook');
return;
}
final book = SyncService.instance.cachedAudiobooks.firstWhere(
(b) => b.provider == parts[1] && b.itemId == parts[2],
orElse: () => throw Exception('Audiobook not found: $mediaId'),
);
await provider.api!.playAudiobook(playerId, book);
return;
}
if (mediaId.startsWith('podcast_ep|')) {
// Format: podcast_ep|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play podcast');
return;
}
// Use provider-specific URI (e.g. spotify--xxx://podcast_episode/id)
// instead of library:// which fails for non-library items
final uri = '${parts[1]}://podcast_episode/${parts[2]}';
final episode = ma.MediaItem(
itemId: parts[2],
provider: parts[1],
name: '',
mediaType: ma.MediaType.podcastEpisode,
uri: uri,
);
_logger.log('AndroidAuto: playing podcast episode URI: $uri');
await provider.api!.playPodcastEpisode(playerId, episode);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
@override
Future<List<MediaItem>> search(String query,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_autoTrackCache[ctxKey] = tracks;
return tracks
.map((t) => _autoTrackItem(provider, t, ctxKey))
.toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
// --- Root & category builders ---
List<MediaItem> _autoBuildRoot() {
return [
MediaItem(id: _autoIdHome, title: 'Home', playable: false,
artUri: _iconHome),
MediaItem(id: _autoIdMusic, title: 'Music', playable: false,
artUri: _iconMusic),
MediaItem(id: _autoIdAudiobooks, title: 'Audiobooks', playable: false,
artUri: _iconBook),
MediaItem(id: _autoIdPodcasts, title: 'Podcasts', playable: false,
artUri: _iconPodcast, extras: _gridHints),
MediaItem(id: _autoIdRadio, title: 'Radio', playable: false,
artUri: _iconRadio, extras: _gridHints),
];
}
// Row ID → display title mapping
static const _homeRowTitles = {
'recent-albums': 'Recent Albums',
'discover-artists': 'Discover Artists',
'discover-albums': 'Discover Albums',
'continue-listening': 'Continue Listening',
'discover-audiobooks': 'Discover Audiobooks',
'discover-series': 'Discover Series',
'favorite-albums': 'Favourite Albums',
'favorite-artists': 'Favourite Artists',
'favorite-tracks': 'Favourite Tracks',
'favorite-playlists': 'Favourite Playlists',
'favorite-radio-stations': 'Favourite Radio',
'favorite-podcasts': 'Favourite Podcasts',
};
/// Home shows subcategory folders matching the user's homescreen settings.
Future<List<MediaItem>> _autoBuildHome() async {
final rowOrder = await SettingsService.getHomeRowOrder();
final items = <MediaItem>[];
for (final rowId in rowOrder) {
if (!await _isHomeRowEnabled(rowId)) continue;
final title = _homeRowTitles[rowId];
if (title == null) continue; // skip discovery rows for now
items.add(MediaItem(
id: 'home|$rowId',
title: title,
playable: false,
extras: _gridHints,
));
}
_logger.log('AndroidAuto: Home built ${items.length} rows');
return items;
}
Future<bool> _isHomeRowEnabled(String rowId) async {
switch (rowId) {
case 'recent-albums': return SettingsService.getShowRecentAlbums();
case 'discover-artists': return SettingsService.getShowDiscoverArtists();
case 'discover-albums': return SettingsService.getShowDiscoverAlbums();
case 'continue-listening': return SettingsService.getShowContinueListeningAudiobooks();
case 'discover-audiobooks': return SettingsService.getShowDiscoverAudiobooks();
case 'discover-series': return SettingsService.getShowDiscoverSeries();
case 'favorite-albums': return SettingsService.getShowFavoriteAlbums();
case 'favorite-artists': return SettingsService.getShowFavoriteArtists();
case 'favorite-tracks': return SettingsService.getShowFavoriteTracks();
case 'favorite-playlists': return SettingsService.getShowFavoritePlaylists();
case 'favorite-radio-stations': return SettingsService.getShowFavoriteRadioStations();
case 'favorite-podcasts': return SettingsService.getShowFavoritePodcasts();
default: return false;
}
}
/// Build content for a specific home row.
Future<List<MediaItem>> _autoBuildHomeRowContent(
MusicAssistantProvider provider, String rowId) async {
switch (rowId) {
case 'recent-albums':
var albums = await provider.getRecentAlbumsWithCache();
if (albums.isEmpty) albums = SyncService.instance.cachedAlbums.take(20).toList();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-artists':
var artists = await provider.getDiscoverArtistsWithCache();
if (artists.isEmpty) artists = SyncService.instance.cachedArtists.take(10).toList();
return artists.take(10).map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-albums':
final albums = await provider.getDiscoverAlbumsWithCache();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'continue-listening':
final books = await provider.getInProgressAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-audiobooks':
final books = await provider.getDiscoverAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-series':
return _autoBuildAudiobookSeriesList(provider);
case 'favorite-albums':
final albums = await provider.getFavoriteAlbums();
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-artists':
final artists = await provider.getFavoriteArtists();
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-tracks':
final tracks = await provider.getFavoriteTracks();
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
case 'favorite-playlists':
final playlists = await provider.getFavoritePlaylists();
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name, artist: p.owner,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
case 'favorite-radio-stations':
final stations = await provider.getFavoriteRadioStations();
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s), playable: true,
)).toList();
case 'favorite-podcasts':
// Use cached podcasts filtered by favorite
final podcasts = SyncService.instance.cachedPodcasts
.where((p) => p.favorite == true).toList();
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
default:
return [];
}
}
List<MediaItem> _autoBuildMusicCategories() {
return [
MediaItem(id: _autoIdArtists, title: 'Artists', playable: false,
artUri: _iconArtist, extras: _gridHints),
MediaItem(id: _autoIdAlbums, title: 'Albums', playable: false,
artUri: _iconAlbum, extras: _gridHints),
MediaItem(id: _autoIdPlaylists, title: 'Playlists', playable: false,
artUri: _iconPlaylist, extras: _gridHints),
MediaItem(id: _autoIdFavorites, title: 'Favourites', playable: false,
artUri: _iconFavorite),
];
}
List<MediaItem> _autoBuildAudiobookCategories() {
return [
MediaItem(id: _autoIdAbAuthors, title: 'Authors', playable: false,
artUri: _iconArtist),
MediaItem(id: _autoIdAbBooks, title: 'Books', playable: false,
artUri: _iconBook, extras: _gridHints),
MediaItem(id: _autoIdAbSeries, title: 'Series', playable: false,
artUri: _iconBook, extras: _gridHints),
];
}
// --- Music builders ---
Future<List<MediaItem>> _autoBuildFavorites(
MusicAssistantProvider provider) async {
final tracks = await provider.getFavoriteTracks();
_logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks');
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildPlaylistList(MusicAssistantProvider provider) {
final playlists = SyncService.instance.cachedPlaylists;
_logger.log('AndroidAuto: Playlists: ${playlists.length}');
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name,
artist: p.owner,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPlaylistTracks(
MusicAssistantProvider provider, String plProvider, String plItemId) async {
final tracks =
await provider.getPlaylistTracksWithCache(plProvider, plItemId);
final ctxKey = 'plist|$plProvider|$plItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildArtistList(MusicAssistantProvider provider) {
final artists = SyncService.instance.cachedArtists;
_logger.log('AndroidAuto: Artists: ${artists.length}');
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildArtistAlbums(
MusicAssistantProvider provider, String artistName) async {
var albums = await provider.getArtistAlbumsWithCache(artistName);
if (albums.isEmpty) {
// Fallback to library if API unavailable
albums = provider.getArtistAlbumsFromLibrary(artistName);
}
_logger.log('AndroidAuto: Artist "$artistName" albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
List<MediaItem> _autoBuildAlbumList(MusicAssistantProvider provider) {
final albums = SyncService.instance.cachedAlbums;
_logger.log('AndroidAuto: Albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildAlbumTracks(
MusicAssistantProvider provider, String alProvider, String alItemId) async {
final tracks =
await provider.getAlbumTracksWithCache(alProvider, alItemId);
_logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}');
final ctxKey = 'album|$alProvider|$alItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
// --- Audiobook builders ---
List<MediaItem> _autoBuildAudiobookAuthorList(
MusicAssistantProvider provider) {
final books = SyncService.instance.cachedAudiobooks;
final seen = <String>{};
final items = <MediaItem>[];
for (final b in books) {
final author = b.authorsString;
if (seen.add(author)) {
items.add(MediaItem(
id: 'ab_author|$author',
title: author,
artUri: _autoArtUri(provider, b),
playable: false,
));
}
}
_logger.log('AndroidAuto: Audiobook authors: ${items.length}');
return items;
}
List<MediaItem> _autoBuildAuthorAudiobooks(
MusicAssistantProvider provider, String authorName) {
final books = SyncService.instance.cachedAudiobooks
.where((b) => b.authorsString == authorName)
.toList();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookList(MusicAssistantProvider provider) async {
var books = SyncService.instance.cachedAudiobooks;
if (books.isEmpty) {
// Sync cache may not be loaded yet — wait for it
_logger.log('AndroidAuto: cachedAudiobooks empty, loading from cache');
await SyncService.instance.loadFromCache();
books = SyncService.instance.cachedAudiobooks;
}
_logger.log('AndroidAuto: Books: ${books.length}');
// Sort alphabetically and cap to avoid overwhelming Android Auto
books.sort((a, b) => a.name.compareTo(b.name));
return books.take(500).map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookSeriesList(
MusicAssistantProvider provider) async {
final series = await provider.getDiscoverSeriesWithCache();
_logger.log('AndroidAuto: Audiobook series: ${series.length}');
final items = <MediaItem>[];
for (final s in series) {
// Use first book's artwork instead of series mosaic thumbnail
Uri? artUri;
var cachedBooks = provider.getCachedSeriesAudiobooks(s.id);
if (cachedBooks == null || cachedBooks.isEmpty) {
// Fetch books for this series (result is cached for future use)
try {
cachedBooks = await provider.getSeriesAudiobooksWithCache(s.id);
} catch (_) {}
}
if (cachedBooks != null && cachedBooks.isNotEmpty) {
artUri = _autoArtUri(provider, cachedBooks.first);
}
items.add(MediaItem(
id: 'ab_series|${s.id}',
title: s.name,
artUri: artUri,
playable: false,
));
}
return items;
}
Future<List<MediaItem>> _autoBuildSeriesAudiobooks(
MusicAssistantProvider provider, String seriesPath) async {
final books = await provider.getSeriesAudiobooksWithCache(seriesPath);
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
// --- Podcast builders ---
List<MediaItem> _autoBuildPodcastList(MusicAssistantProvider provider) {
final podcasts = SyncService.instance.cachedPodcasts;
_logger.log('AndroidAuto: Podcasts: ${podcasts.length}');
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPodcastEpisodes(
MusicAssistantProvider provider, String podProvider, String podItemId) async {
final episodes = await provider.getPodcastEpisodesWithCache(
podItemId,
provider: podProvider,
);
return episodes.map((e) => MediaItem(
id: 'podcast_ep|${e.provider}|${e.itemId}',
title: e.name,
duration: e.duration,
artUri: _autoArtUri(provider, e),
playable: true,
)).toList();
}
// --- Radio builder ---
Future<List<MediaItem>> _autoBuildRadioList(MusicAssistantProvider provider) async {
// Radio stations are lazily loaded — trigger load if empty
if (provider.radioStations.isEmpty) {
_logger.log('AndroidAuto: Radio empty, loading stations');
await provider.loadRadioStations();
}
final stations = provider.radioStations;
_logger.log('AndroidAuto: Radio stations: ${stations.length}');
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s),
playable: true,
)).toList();
}
// --- Helpers ---
MediaItem _autoTrackItem(
MusicAssistantProvider provider, ma.Track t, String ctxKey) {
return MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _autoArtUri(provider, t),
playable: true,
);
}
Uri? _autoArtUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
// ---------------------------------------------------------------------------
/// Dispose of resources and cancel all subscriptions
Future<void> dispose() async {
await _interruptionSubscription?.cancel();
+126 -482
View File
@@ -1,542 +1,186 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
import 'basic_auth_strategy.dart';
import 'authelia_strategy.dart';
import 'ma_auth_strategy.dart';
import '../debug_logger.dart';
import '../../constants/network.dart';
/// Central authentication manager
/// Manages auth strategy selection, auto-detection, and credential lifecycle
/// Simple authentication manager for Music Assistant
/// MA now uses its own built-in authentication system
class AuthManager {
final _logger = DebugLogger();
// Available auth strategies
final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
String? _accessToken;
String? _longLivedToken;
AuthStrategy? _currentStrategy;
AuthCredentials? _currentCredentials;
/// Get the best available token (prefer long-lived)
String? get token => _longLivedToken ?? _accessToken;
/// Get current auth strategy (null if none selected)
AuthStrategy? get currentStrategy => _currentStrategy;
/// Check if we have valid credentials
bool get hasCredentials => token != null;
/// Get current credentials (null if none stored)
AuthCredentials? get currentCredentials => _currentCredentials;
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// Normalize a server URL with appropriate protocol and port
/// For local IP addresses without an explicit port, defaults to MA port 8095
String _normalizeUrl(String url) {
var baseUrl = url.trim();
// Check if URL already has a port specified (before adding protocol)
// Port can be specified as host:port or after protocol as http://host:port
bool hasExplicitPort = _hasExplicitPort(baseUrl);
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (_isLocalAddress(baseUrl)) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
baseUrl = 'https://$baseUrl';
}
}
// For local addresses without explicit port, add default MA port
// This handles the common case of MA as HAOS add-on where port 8095 is needed
if (!hasExplicitPort) {
final uri = Uri.parse(baseUrl);
if (_isLocalAddress(uri.host)) {
baseUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: NetworkConstants.defaultWsPort,
path: uri.path.isEmpty ? null : uri.path,
).toString();
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
}
}
return baseUrl;
}
/// Check if a URL string has an explicit port specified
bool _hasExplicitPort(String url) {
// Remove protocol if present
var hostPart = url;
if (hostPart.startsWith('http://')) {
hostPart = hostPart.substring(7);
} else if (hostPart.startsWith('https://')) {
hostPart = hostPart.substring(8);
}
// Remove path if present
final pathIndex = hostPart.indexOf('/');
if (pathIndex != -1) {
hostPart = hostPart.substring(0, pathIndex);
}
// Check for port (host:port pattern)
// Need to handle IPv6 addresses like [::1]:8095
if (hostPart.startsWith('[')) {
// IPv6 address
final closeBracket = hostPart.indexOf(']');
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
return hostPart[closeBracket + 1] == ':';
}
return false;
} else {
// IPv4 or hostname - check for colon followed by digits
final colonIndex = hostPart.lastIndexOf(':');
if (colonIndex != -1) {
final portPart = hostPart.substring(colonIndex + 1);
return int.tryParse(portPart) != null;
}
return false;
}
}
/// Check if an address is a local/private address (without protocol)
bool _isLocalAddress(String address) {
// Remove port if present for checking
var host = address;
final colonIndex = host.lastIndexOf(':');
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
host = host.substring(0, colonIndex);
}
return host.startsWith('192.168.') ||
host.startsWith('192.') ||
host.startsWith('10.') ||
host.startsWith('172.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
// First, check if this is a Music Assistant server with native auth
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) {
if (maAuthResult == 'required') {
_logger.log('✓ Detected Music Assistant native authentication');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
} else if (maAuthResult == 'none') {
_logger.log('✓ Music Assistant server - no auth required');
return _strategies.firstWhere((s) => s.name == 'none');
}
}
// Try no-auth (direct connection without any auth)
_logger.log('Testing no-auth strategy...');
final canConnect = await _canConnectWithoutAuth(baseUrl);
_logger.log('No-auth result: $canConnect');
if (canConnect) {
_logger.log('✓ Server does not require authentication');
return _strategies.firstWhere((s) => s.name == 'none');
}
// Probe server for auth requirements (reverse proxy scenarios)
/// Login to Music Assistant and get an access token
Future<String?> login(String serverUrl, String username, String password) async {
try {
_logger.log('Probing server for auth headers...');
// Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final apiUrl = _buildApiUrl(serverUrl);
_logger.log('🔐 Logging in to Music Assistant at $apiUrl');
final response = await http.get(
Uri.parse(probeUrl),
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}');
// Check for redirect (common with reverse proxy auth)
if (response.statusCode == 302 || response.statusCode == 307) {
final location = response.headers['location'];
_logger.log('Redirect to: $location');
// If redirects to Authelia login, it's Authelia
if (location != null && location.contains('authelia')) {
_logger.log('✓ Detected Authelia (redirect)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
}
// Server returned 401 Unauthorized - check WWW-Authenticate header
if (response.statusCode == 401) {
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
_logger.log('401 with WWW-Authenticate: $wwwAuth');
// Check for Basic Auth
if (wwwAuth != null && wwwAuth.contains('basic')) {
_logger.log('✓ Detected Basic Authentication');
return _strategies.firstWhere((s) => s.name == 'basic');
}
// Check for Bearer token - could be MA native auth
if (wwwAuth != null && wwwAuth.contains('bearer')) {
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
}
}
// If we got here and there's any auth challenge, assume Authelia
// (since we're behind a reverse proxy that requires auth)
if (response.statusCode >= 300) {
_logger.log('Server requires authentication (status ${response.statusCode})');
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
return null;
}
/// Check if server is a Music Assistant server with native auth
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
// 401/403 means auth required - check this first
if (response.statusCode == 401 || response.statusCode == 403) {
_logger.log('MA API returned ${response.statusCode} - auth required');
return 'required';
}
// Also check for "Authentication required" text response
if (response.body.toLowerCase().contains('authentication required')) {
_logger.log('MA API returned auth required message');
return 'required';
}
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
// Try to parse as JSON
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check if we got server info successfully (no auth required)
if (data.containsKey('result')) {
final result = data['result'] as Map<String, dynamic>?;
final serverVersion = result?['server_version'] as String?;
final schemaVersion = result?['schema_version'] as int?;
if (serverVersion != null) {
_logger.log('MA server version: $serverVersion, schema: $schemaVersion');
// Schema 28+ requires auth, but if we got here without auth, it's not enabled
return 'none';
}
}
// Check for auth error in JSON
if (data.containsKey('error_code')) {
final errorCode = data['error_code'] as int?;
// Error 50 = AuthenticationRequired
if (errorCode == 50) {
_logger.log('MA server requires authentication (error 50)');
return 'required';
}
}
} catch (jsonError) {
_logger.log('MA API response not JSON: $jsonError');
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
final result = data['result'] as Map<String, dynamic>?;
_accessToken = result?['access_token'] as String?;
if (_accessToken != null) {
_logger.log('✓ Got access token from MA');
// Try to create a long-lived token
_longLivedToken = await _createLongLivedToken(apiUrl, _accessToken!);
return _longLivedToken ?? _accessToken;
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
return null;
} catch (e) {
_logger.log('MA auth check error: $e');
_logger.log('✗ Login error: $e');
return null;
}
}
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
_logger.log('Creating long-lived token...');
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
// 200 = no auth required
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (statusCode == 200) {
return true;
/// Validate a stored token
Future<bool> validateToken(String serverUrl, String storedToken) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $storedToken',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
_longLivedToken = storedToken;
return true;
}
}
return false;
} catch (e) {
_logger.log('No-auth error: $e');
_logger.log('Token validation failed: $e');
return false;
}
}
/// Attempt login with specified strategy
/// Returns true if login successful and credentials stored
/// authServerUrl is optional - only used for Authelia when auth is on a different domain
Future<bool> login(
String serverUrl,
String username,
String password,
AuthStrategy strategy, {
String? authServerUrl,
}) async {
_logger.log('Attempting login with ${strategy.name} strategy');
// Use auth server URL if provided, otherwise use main server URL
final loginUrl = authServerUrl?.isNotEmpty == true ? authServerUrl! : serverUrl;
_logger.log('Login URL: $loginUrl');
final credentials = await strategy.login(loginUrl, username, password);
if (credentials != null) {
_currentStrategy = strategy;
_currentCredentials = credentials;
_logger.log('✓ Login successful with ${strategy.name}');
return true;
}
_logger.log('✗ Login failed with ${strategy.name}');
return false;
}
/// Validate current credentials are still valid
Future<bool> validateCurrentCredentials(String serverUrl) async {
if (_currentStrategy == null || _currentCredentials == null) {
return false;
}
return await _currentStrategy!.validateCredentials(
serverUrl,
_currentCredentials!,
);
}
/// Get WebSocket connection headers for current credentials
/// Build headers for WebSocket connection
Map<String, dynamic> getWebSocketHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
return {};
}
/// Get HTTP streaming headers for current credentials
/// Build headers for HTTP streaming requests
Map<String, String> getStreamingHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
return {};
}
/// Serialize current credentials for persistent storage
Map<String, dynamic>? serializeCredentials() {
if (_currentStrategy == null || _currentCredentials == null) {
return null;
}
return {
'strategy': _currentStrategy!.name,
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
};
/// Set token from external source (e.g., restored from settings)
void setToken(String? storedToken) {
_longLivedToken = storedToken;
}
/// Deserialize and restore credentials from persistent storage
void deserializeCredentials(Map<String, dynamic> stored) {
final strategyName = stored['strategy'] as String?;
final data = stored['data'] as Map<String, dynamic>?;
if (strategyName == null || data == null) {
return;
}
// Find matching strategy
try {
final strategy = _strategies.firstWhere((s) => s.name == strategyName);
_currentStrategy = strategy;
_currentCredentials = strategy.deserializeCredentials(data);
_logger.log('✓ Restored ${strategy.name} credentials from storage');
} catch (e) {
_logger.log('✗ Could not restore credentials: $e');
}
}
/// Clear current authentication state
/// Clear authentication state
void logout() {
_currentStrategy = null;
_currentCredentials = null;
_accessToken = null;
_longLivedToken = null;
_logger.log('Logged out - cleared auth state');
}
/// Get strategy by name (for manual selection)
AuthStrategy? getStrategyByName(String name) {
try {
return _strategies.firstWhere((s) => s.name == name);
} catch (e) {
return null;
/// Build API URL from server URL
Uri _buildApiUrl(String serverUrl) {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'http://$baseUrl';
}
final uri = Uri.parse(baseUrl);
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
}
}
-42
View File
@@ -1,42 +0,0 @@
/// Authentication credentials container
class AuthCredentials {
final String strategyName;
final Map<String, dynamic> data;
AuthCredentials(this.strategyName, this.data);
}
/// Abstract authentication strategy interface
/// Implementations handle different auth methods (none, basic, Authelia, HA, etc.)
abstract class AuthStrategy {
/// Strategy name identifier (e.g., 'none', 'basic', 'authelia')
String get name;
/// Attempt login and return auth credentials/token
/// Returns null if login fails
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
);
/// Test if current credentials are still valid
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
);
/// Build headers for WebSocket connection
/// Example: {'Authorization': 'Basic xyz'} or {'Cookie': 'session=abc'}
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials);
/// Build headers for HTTP streaming requests
/// Example: {'Authorization': 'Basic xyz'}
Map<String, String> buildStreamingHeaders(AuthCredentials credentials);
/// Serialize credentials for persistent storage
Map<String, dynamic> serializeCredentials(AuthCredentials credentials);
/// Deserialize credentials from persistent storage
AuthCredentials deserializeCredentials(Map<String, dynamic> data);
}
-172
View File
@@ -1,172 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Authelia authentication strategy
/// Used when Music Assistant is behind Authelia reverse proxy authentication
/// Authelia-specific endpoints: /api/firstfactor, /api/verify
class AutheliaStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'authelia';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
_logger.log('🔐 Attempting Authelia login to $baseUrl');
// Parse URL and construct Authelia firstfactor endpoint
final uri = Uri.parse(baseUrl);
final authUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/firstfactor',
);
_logger.log('Auth URL: $authUrl');
// POST to Authelia's firstfactor endpoint
final response = await http.post(
authUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
'keepMeLoggedIn': true,
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
// Check for successful authentication
if (response.statusCode == 200) {
_logger.log('✓ Authentication successful');
// Extract session cookie from Set-Cookie header
final cookies = response.headers['set-cookie'];
if (cookies != null && cookies.isNotEmpty) {
_logger.log('✓ Received session cookie');
final sessionCookie = _extractSessionCookie(cookies);
if (sessionCookie != null) {
_logger.log('✓ Extracted session cookie');
return AuthCredentials('authelia', {
'session_cookie': sessionCookie,
'username': username,
});
}
}
// If 200 but no cookie, something is wrong
_logger.log('✗ No session cookie in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final sessionCookie = credentials.data['session_cookie'] as String?;
if (sessionCookie == null) return false;
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
// Parse URL and construct Authelia verify endpoint
final uri = Uri.parse(baseUrl);
final verifyUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/verify',
);
final response = await http.get(
verifyUrl,
headers: {
'Cookie': 'authelia_session=$sessionCookie',
},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
_logger.log('Auth validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('authelia', data);
}
/// Extract authelia_session cookie value from Set-Cookie header
/// Migrated from auth_service.dart:81-98
String? _extractSessionCookie(String setCookieHeader) {
final cookies = setCookieHeader.split(',');
for (final cookie in cookies) {
if (cookie.trim().startsWith('authelia_session=')) {
final parts = cookie.split(';');
if (parts.isNotEmpty) {
final value = parts[0].trim();
if (value.startsWith('authelia_session=')) {
return value.substring('authelia_session='.length);
}
}
}
}
return null;
}
}
@@ -1,82 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// Basic HTTP Authentication strategy
/// Used by reverse proxies (nginx, Apache, Caddy, etc.) configured with basic auth
/// Example: Authorization: Basic base64(username:password)
class BasicAuthStrategy implements AuthStrategy {
@override
String get name => 'basic';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// Encode credentials to base64
final credentials = base64Encode(utf8.encode('$username:$password'));
// Test credentials against server root endpoint
// (Music Assistant doesn't have /api/info)
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $credentials'},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('basic', {
'credentials': credentials,
'username': username, // Store for re-login if needed
});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final base64Creds = credentials.data['credentials'] as String?;
if (base64Creds == null) return false;
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $base64Creds'},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('basic', data);
}
}
-254
View File
@@ -1,254 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Music Assistant native authentication strategy
/// Used when MA server has built-in authentication enabled (schema 28+)
///
/// Flow:
/// 1. Connect WebSocket (receives server_info with auth requirements)
/// 2. If auth required, login with username/password to get access token
/// 3. Send 'auth' command with token to authenticate the session
/// 4. Optionally create long-lived token for persistent storage
class MusicAssistantAuthStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'music_assistant';
/// Login to Music Assistant and get an access token
/// This is called AFTER WebSocket connection is established
/// Returns credentials containing the access token
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Determine protocol based on URL pattern
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl';
} else {
baseUrl = 'https://$baseUrl';
}
}
_logger.log('🔐 Attempting Music Assistant login to $baseUrl');
// MA auth endpoint - POST /api with JSON-RPC style command
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
_logger.log('Auth API URL: $apiUrl');
// Login request to get access token
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check for error in response
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
// Extract access token from result
final result = data['result'] as Map<String, dynamic>?;
final accessToken = result?['access_token'] as String?;
if (accessToken != null) {
_logger.log('✓ Got access token from MA');
// Now try to create a long-lived token for persistent auth
final longLivedToken = await _createLongLivedToken(apiUrl, accessToken);
return AuthCredentials('music_assistant', {
'access_token': accessToken,
'long_lived_token': longLivedToken,
'username': username,
'server_url': baseUrl,
});
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
_logger.log('Creating long-lived token...');
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
}
}
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Try long-lived token first, then access token
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token == null) return false;
try {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
// Test token by getting server info
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return !data.containsKey('error_code');
}
return false;
} catch (e) {
_logger.log('Token validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// MA auth happens AFTER WebSocket connection, not via headers
// But we can still include token if available for initial handshake
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('music_assistant', data);
}
/// Get the best available token (prefer long-lived)
String? getToken(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
return longLivedToken ?? accessToken;
}
}
-64
View File
@@ -1,64 +0,0 @@
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// No authentication strategy for open Music Assistant servers
/// Typical scenarios:
/// - Local network deployment (http://192.168.1.100:8095)
/// - Home network without reverse proxy
/// - Development servers
class NoAuthStrategy implements AuthStrategy {
@override
String get name => 'none';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// No authentication required - just test server connectivity
try {
final response = await http.get(
Uri.parse('$serverUrl/api/info'),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('none', {});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Always valid for no-auth
return true;
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return {};
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('none', {});
}
}
+299 -1
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
@@ -10,6 +11,7 @@ import '../constants/timings.dart';
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -20,12 +22,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
@@ -51,10 +55,21 @@ class CacheService {
final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {};
// Last-known track cache (survives idle-out, for resume after Chromecast idle)
final Map<String, Track> _lastKnownTrackCache = {};
// ============================================================================
// HOME SCREEN ROW CACHING
// ============================================================================
/// Ensure home rows are loaded from database before accessing cache
/// This ensures the cache is populated on first access if not already loaded
Future<void> ensureHomeRowsLoaded() async {
if (!_homeRowsLoaded) {
await loadHomeRowsFromDatabase();
}
}
/// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -69,6 +84,11 @@ class CacheService {
/// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty recent albums result');
return;
}
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
@@ -90,6 +110,11 @@ class CacheService {
/// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (artists.isEmpty) {
_logger.log('⚠️ Not caching empty discover artists result');
return;
}
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
@@ -111,6 +136,11 @@ class CacheService {
/// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty discover albums result');
return;
}
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
@@ -118,6 +148,32 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Set cached discovery folders
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (folders.isEmpty) {
_logger.log('⚠️ Not caching empty discovery folders result');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -132,6 +188,11 @@ class CacheService {
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
return;
}
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -151,6 +212,11 @@ class CacheService {
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty discover audiobooks result');
return;
}
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -170,6 +236,11 @@ class CacheService {
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
// Don't cache empty results
if (series.isEmpty) {
_logger.log('⚠️ Not caching empty discover series result');
return;
}
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
@@ -191,6 +262,7 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
@@ -250,6 +322,7 @@ class CacheService {
_albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
_persistDetailTracks('album', cacheKey, tracks);
}
/// Invalidate album tracks cache for a specific album
@@ -284,6 +357,7 @@ class CacheService {
_playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
_persistDetailTracks('playlist', cacheKey, tracks);
}
/// Invalidate playlist tracks cache for a specific playlist
@@ -415,6 +489,14 @@ class CacheService {
_playerTrackCacheTime.remove(playerId);
}
/// Get last-known track for a player (survives idle-out)
Track? getLastKnownTrack(String playerId) => _lastKnownTrackCache[playerId];
/// Save last-known track for a player (only called when a real track is playing/paused)
void setLastKnownTrack(String playerId, Track track) {
_lastKnownTrackCache[playerId] = track;
}
// ============================================================================
// CLEAR ALL
// ============================================================================
@@ -431,7 +513,21 @@ class CacheService {
_searchCacheTime.clear();
_playerTrackCache.clear();
_playerTrackCacheTime.clear();
_audiobookDetailCache.clear();
_audiobookDetailCacheTime.clear();
_podcastEpisodesCache.clear();
_podcastEpisodesCacheTime.clear();
_logger.log('🗑️ All detail caches cleared');
// Also clear DB detail cache
() async {
try {
if (DatabaseService.instance.isInitialized) {
await DatabaseService.instance.clearDetailTrackCache();
}
} catch (e) {
_logger.log('⚠️ Failed to clear detail track DB cache: $e');
}
}();
}
/// Clear all caches
@@ -448,10 +544,22 @@ class CacheService {
// ============================================================================
/// Load home row data from database for instant display on startup
/// Returns the existing future if already loading, to prevent duplicate loads
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
// Create the load future
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
await _homeRowsLoadFuture;
_homeRowsLoadFuture = null; // Clear after completion
}
/// Internal implementation of home row loading
Future<void> _doLoadHomeRowsFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
@@ -499,8 +607,25 @@ class CacheService {
_logger.log('⚠️ Failed to parse discover albums: $e');
}
}
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
try {
final items = (jsonDecode(foldersData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoveryFolders = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
}
}
@@ -546,4 +671,177 @@ class CacheService {
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
}
}
// ============================================================================
// AUDIOBOOK DETAIL CACHING
// ============================================================================
final Map<String, String> _audiobookDetailCache = {}; // cacheKey -> JSON
final Map<String, DateTime> _audiobookDetailCacheTime = {};
bool isAudiobookDetailCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final cacheTime = _audiobookDetailCacheTime[cacheKey];
return _audiobookDetailCache.containsKey(cacheKey) &&
cacheTime != null &&
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
}
String? getCachedAudiobookDetail(String cacheKey) => _audiobookDetailCache[cacheKey];
void setCachedAudiobookDetail(String cacheKey, String audiobookJson) {
_audiobookDetailCache[cacheKey] = audiobookJson;
_audiobookDetailCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_audiobookDetailCache, _audiobookDetailCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached audiobook detail for $cacheKey');
_persistDetailData('audiobook', cacheKey, audiobookJson);
}
Future<String?> getAudiobookDetailWithDbFallback(String cacheKey) async {
final memCached = _audiobookDetailCache[cacheKey];
if (memCached != null) return memCached;
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('audiobook', cacheKey);
if (dbResult == null) return null;
_audiobookDetailCache[cacheKey] = dbResult.tracksJson;
_audiobookDetailCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded audiobook detail from database for $cacheKey');
return dbResult.tracksJson;
} catch (e) {
_logger.log('⚠️ Failed to load audiobook detail from database: $e');
return null;
}
}
// ============================================================================
// PODCAST EPISODE CACHING
// ============================================================================
final Map<String, List<MediaItem>> _podcastEpisodesCache = {};
final Map<String, DateTime> _podcastEpisodesCacheTime = {};
bool isPodcastEpisodesCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final cacheTime = _podcastEpisodesCacheTime[cacheKey];
return _podcastEpisodesCache.containsKey(cacheKey) &&
cacheTime != null &&
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
}
List<MediaItem>? getCachedPodcastEpisodes(String cacheKey) => _podcastEpisodesCache[cacheKey];
void setCachedPodcastEpisodes(String cacheKey, List<MediaItem> episodes) {
_podcastEpisodesCache[cacheKey] = episodes;
_podcastEpisodesCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_podcastEpisodesCache, _podcastEpisodesCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${episodes.length} episodes for podcast $cacheKey');
_persistDetailMediaItems('podcast', cacheKey, episodes);
}
Future<List<MediaItem>?> getPodcastEpisodesWithDbFallback(String cacheKey) async {
final memCached = _podcastEpisodesCache[cacheKey];
if (memCached != null) return memCached;
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('podcast', cacheKey);
if (dbResult == null) return null;
final episodes = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => MediaItem.fromJson(json as Map<String, dynamic>))
.toList();
_podcastEpisodesCache[cacheKey] = episodes;
_podcastEpisodesCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${episodes.length} podcast episodes from database for $cacheKey');
return episodes;
} catch (e) {
_logger.log('⚠️ Failed to load podcast episodes from database: $e');
return null;
}
}
// ============================================================================
// DATABASE PERSISTENCE FOR DETAIL TRACKS
// ============================================================================
/// Persist detail tracks to database (fire-and-forget)
void _persistDetailTracks(String parentType, String cacheKey, List<Track> tracks) {
_persistDetailData(parentType, cacheKey, jsonEncode(tracks.map((t) => t.toJson()).toList()));
}
/// Persist detail media items to database (fire-and-forget)
void _persistDetailMediaItems(String parentType, String cacheKey, List<MediaItem> items) {
_persistDetailData(parentType, cacheKey, jsonEncode(items.map((i) => i.toJson()).toList()));
}
/// Persist raw JSON detail data to database (fire-and-forget)
void _persistDetailData(String parentType, String cacheKey, String dataJson) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.saveDetailTracks(parentType, cacheKey, dataJson);
_logger.log('💾 Persisted detail data ($parentType/$cacheKey) to database');
} catch (e) {
_logger.log('⚠️ Failed to persist detail data: $e');
}
}();
}
/// Get album tracks with database fallback (memory → DB → null)
Future<List<Track>?> getAlbumTracksWithDbFallback(String cacheKey) async {
// Check memory cache first
final memCached = _albumTracksCache[cacheKey];
if (memCached != null) return memCached;
// Try database
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('album', cacheKey);
if (dbResult == null) return null;
final tracks = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
// Warm memory cache
_albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${tracks.length} album tracks from database for $cacheKey');
return tracks;
} catch (e) {
_logger.log('⚠️ Failed to load album tracks from database: $e');
return null;
}
}
/// Get playlist tracks with database fallback (memory → DB → null)
Future<List<Track>?> getPlaylistTracksWithDbFallback(String cacheKey) async {
// Check memory cache first
final memCached = _playlistTracksCache[cacheKey];
if (memCached != null) return memCached;
// Try database
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('playlist', cacheKey);
if (dbResult == null) return null;
final tracks = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
// Warm memory cache
_playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${tracks.length} playlist tracks from database for $cacheKey');
return tracks;
} catch (e) {
_logger.log('⚠️ Failed to load playlist tracks from database: $e');
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More