Compare commits

..
29 Commits
Author SHA1 Message Date
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
ChrisandClaude Opus 4.5 d7b7bbcf0d Fix Sendspin proxy auth for local network connections
Two issues fixed:
1. Version check was looking for MA 2.8.0+ but proxy was added in 2.7.1
2. Local proxy fallback wasn't sending auth token, causing connection rejection

Now local IP connections properly authenticate with the MA token when
falling back to the /sendspin proxy endpoint.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:11:02 +00:00
ChrisandClaude Opus 4.5 3503978185 Fix nav bar hidden when no player selected
Show the bottom navigation bar when connected even without a player
selected. This allows users to access Settings and Library to
troubleshoot connection issues or browse content.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:59:32 +00:00
ChrisandClaude Opus 4.5 020ed250f4 Fix library sorting, scrollbar display, and provider management
- Fix audiobook year sorting (ascending/descending support)
- Fix server-handled sort orders (play_count, last_played, etc.) to preserve server order
- Fix letter scrollbar showing irrelevant letters for non-alphabetical sorts
- Add ScrollbarDisplayMode enum for contextual scrollbar popups
- Fix playlists sorting by requesting sorted data from server
- Fix multiple accounts of same service not showing in provider toggles
- Fix stale provider IDs persisting in settings
- Use context.watch instead of Selector for albums/artists tabs
- Use Consumer for menu provider toggles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:10:27 +00:00
ChrisandClaude Opus 4.5 e5be64a6cd fix: Auto-stop foreground service after 30 min idle (fixes #51)
Add idle service timeout to stop the Android foreground service when no
playback occurs for 30 minutes. This saves battery by allowing the
service to be fully stopped rather than persisting indefinitely.

- Add stopService() method to MassivAudioHandler
- Add 30-minute idle timer in MusicAssistantProvider
- Timer starts when playback stops, cancels when playback resumes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:22:48 +00:00
Chris b85fadc551 Update swipe gestures and add provider filters 2026-01-20 20:12:10 +00:00
ChrisandClaude Opus 4.5 3b8339f94f docs: Add screenshots 11 and 12 to README
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:31:09 +00:00
98 changed files with 12548 additions and 5480 deletions
+4 -17
View File
@@ -31,8 +31,8 @@ jobs:
- name: Setup Flutter - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.24.0' flutter-version: '3.38.0'
channel: 'stable' channel: 'master'
cache: true cache: true
- name: Cache Gradle dependencies - name: Cache Gradle dependencies
@@ -63,10 +63,7 @@ jobs:
- name: Setup signing keystore - name: Setup signing keystore
run: | run: |
# Decode keystore from secret (persistent key for consistent signatures)
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
# Create key.properties file
cat > android/key.properties << EOF cat > android/key.properties << EOF
storePassword=${{ secrets.KEYSTORE_PASSWORD }} storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }} keyPassword=${{ secrets.KEY_PASSWORD }}
@@ -75,12 +72,7 @@ jobs:
EOF EOF
- name: Build APK - name: Build APK
run: | run: flutter build apk --release
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -91,10 +83,5 @@ jobs:
- name: Upload APK summary - name: Upload APK summary
run: | run: |
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
+17 -26
View File
@@ -36,7 +36,8 @@ This application was built with AI-assisted development using **Claude Code** an
### Queue Management ### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue - **View & Manage Queue** - See upcoming tracks in the playback queue
- **Drag to Reorder** - Instant drag handles for reordering tracks - **Drag to Reorder** - Instant drag handles for reordering tracks
- **Swipe to Delete** - Remove tracks with a simple swipe gesture - **Swipe left to Delete** - Remove tracks with a simple swipe gesture
- **Swipe right to play next** - Move tracks with a simple swipe gesture
### Home Screen ### Home Screen
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums - **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
@@ -50,7 +51,9 @@ This application was built with AI-assisted development using **Claude Code** an
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates - **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
- **Audiobooks** - Browse by title, series, or author with progress tracking - **Audiobooks** - Browse by title, series, or author with progress tracking
- **Favorites Filter** - Toggle to show only your favorite items - **Favorites Filter** - Toggle to show only your favorite items
- **Providers Filter** - Toggle to show only specific provider items
- **Letter Scrollbar** - Fast navigation through long lists - **Letter Scrollbar** - Fast navigation through long lists
- **Provider Icons** - Provider icons on library covers
### Search ### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks - **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
@@ -86,16 +89,18 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150"> <img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150"> <img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150"> <img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150"> <img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150"> <img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150"> <img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150"> <img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150"> <img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150"> <img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150"> <img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
</div> </div>
## Download ## Download
@@ -122,25 +127,11 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on: If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server - 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 - Do **not** use your Home Assistant URL or ingress URL
### Remote Access ### 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). 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).
## 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.
## Requirements ## Requirements
+78
View File
@@ -0,0 +1,78 @@
# Release Notes Draft
## 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
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
- Sort handler now properly supports ascending and descending year sorts
#### Library Sorting
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
#### 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()
+3 -3
View File
@@ -29,7 +29,7 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
namespace "com.musicassistant.music_assistant" namespace "com.collotsspot.ensemble"
compileSdk 35 compileSdk 35
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
@@ -58,8 +58,8 @@ android {
} }
defaultConfig { defaultConfig {
applicationId "com.musicassistant.music_assistant" applicationId "com.collotsspot.ensemble"
minSdk 21 minSdkVersion flutter.minSdkVersion
targetSdk 35 targetSdk 35
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@@ -1,4 +1,4 @@
package com.musicassistant.music_assistant package com.collotsspot.ensemble
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
// while also intercepting volume button events // while also intercepting volume button events
class MainActivity: AudioServiceActivity() { class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume" private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons" private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var isListening = false private var isListening = false
+2 -2
View File
@@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.9.10' ext.kotlin_version = '2.1.0'
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors // Use Maven Central mirrors as fallback for 403 errors
@@ -9,7 +9,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.1.0' classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+2 -2
View File
@@ -22,8 +22,8 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.10" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false
} }
include ":app" include ":app"
@@ -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.
@@ -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,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 /// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_'; 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 /// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_'; static const String playlistCover = 'playlist_cover_';
+32 -2
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field" "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": { "@portDescription": {
"description": "Description for port field" "description": "Description for port field"
}, },
@@ -604,6 +604,11 @@
"tracksAddedToQueue": "Tracks added to queue", "tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": { "@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added" "description": "Confirmation when tracks added"
}, },
@@ -1069,6 +1074,10 @@
"sortAlphabetically": "Sort alphabetically", "sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year", "sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order", "sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view", "listView": "List view",
"gridView": "Grid view", "gridView": "Grid view",
"noBooksInSeries": "No books found in this series", "noBooksInSeries": "No books found in this series",
@@ -1087,11 +1096,12 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts", "showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork", "extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen", "chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites", "addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites", "removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library", "addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library", "removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library", "addToLibrary": "Add",
"unknown": "Unknown", "unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks", "noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all", "showAll": "Show all",
@@ -1207,11 +1217,21 @@
"description": "Bottom sheet title for adding album to queue" "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": "Add to queue on...",
"@addToQueueOn": { "@addToQueueOn": {
"description": "Bottom sheet title for adding to queue" "description": "Bottom sheet title for adding to queue"
}, },
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...", "startRadioOn": "Start {name} radio on...",
"@startRadioOn": { "@startRadioOn": {
"description": "Bottom sheet title for starting radio", "description": "Bottom sheet title for starting radio",
@@ -1357,6 +1377,16 @@
"description": "Description for show hints toggle" "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": "Display",
"@display": { "@display": {
"description": "Settings section title for display options" "description": "Settings section title for display options"
+81 -17
View File
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
/// be consistent with the languages listed in the S.supportedLocales /// be consistent with the languages listed in the S.supportedLocales
/// property. /// property.
abstract class S { abstract class S {
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); S(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName; final String localeName;
@@ -84,7 +85,8 @@ abstract class S {
/// Additional delegates can be added by appending to this list in /// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list /// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required. /// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[ static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate, delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
@@ -162,7 +164,7 @@ abstract class S {
/// Description for port field /// Description for port field
/// ///
/// In en, this message translates to: /// 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; String get portDescription;
/// Username field label /// Username field label
@@ -759,12 +761,18 @@ abstract class S {
/// **'Album added to queue'** /// **'Album added to queue'**
String get albumAddedToQueue; String get albumAddedToQueue;
/// Confirmation when tracks added /// Snackbar message for tracks added to queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Tracks added to queue'** /// **'Tracks added to queue'**
String get tracksAddedToQueue; String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label /// Play button label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1227,6 +1235,30 @@ abstract class S {
/// **'Sort by series order'** /// **'Sort by series order'**
String get sortBySeriesOrder; 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. /// No description provided for @listView.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1335,6 +1367,12 @@ abstract class S {
/// **'Choose which rows to display on the home screen'** /// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows; String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites. /// No description provided for @addedToFavorites.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1362,7 +1400,7 @@ abstract class S {
/// No description provided for @addToLibrary. /// No description provided for @addToLibrary.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add to library'** /// **'Add'**
String get addToLibrary; String get addToLibrary;
/// No description provided for @unknown. /// No description provided for @unknown.
@@ -1671,12 +1709,24 @@ abstract class S {
/// **'Add album to queue on...'** /// **'Add album to queue on...'**
String get addAlbumToQueueOn; 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 /// Bottom sheet title for adding to queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add to queue on...'** /// **'Add to queue on...'**
String get addToQueueOn; 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 /// Bottom sheet title for starting radio
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1833,6 +1883,18 @@ abstract class S {
/// **'Display helpful tips for discovering features'** /// **'Display helpful tips for discovering features'**
String get showHintsDescription; 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 /// Settings section title for display options
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2131,27 +2193,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
} }
@override @override
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode); bool isSupported(Locale locale) =>
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
@override @override
bool shouldReload(_SDelegate old) => false; bool shouldReload(_SDelegate old) => false;
} }
S lookupS(Locale locale) { S lookupS(Locale locale) {
// Lookup logic when only language code is specified. // Lookup logic when only language code is specified.
switch (locale.languageCode) { switch (locale.languageCode) {
case 'de': return SDe(); case 'de':
case 'en': return SEn(); return SDe();
case 'es': return SEs(); case 'en':
case 'fr': return SFr(); return SEn();
case 'es':
return SEs();
case 'fr':
return SFr();
} }
throw FlutterError( throw FlutterError(
'S.delegate failed to load unsupported locale "$locale". This is likely ' 'S.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue ' 'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration ' 'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.' 'that was used.');
);
} }
+119 -45
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SDe extends S {
String get portHint => 'z.B. 8095 oder leer lassen'; String get portHint => 'z.B. 8095 oder leer lassen';
@override @override
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.'; String get portDescription =>
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
@override @override
String get username => 'Benutzername'; String get username => 'Benutzername';
@@ -60,10 +61,12 @@ class SDe extends S {
String get authServerUrlOptional => 'Auth-Server URL (Optional)'; String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@override @override
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)'; String get authServerUrlHint =>
'z.B. auth.example.com (wenn abweichend vom Server)';
@override @override
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server'; String get authServerUrlDescription =>
'Leer lassen, wenn Authentifizierung auf demselben Server';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -80,40 +83,49 @@ class SDe extends S {
String get unencryptedConnection => 'Unverschlusselte Verbindung'; String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override @override
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)'; String get usingHttpOverTailscale =>
'HTTP uber Tailscale (verschlusselter Tunnel)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP'; String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override @override
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt'; String get httpNotEncrypted =>
'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override @override
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein'; String get pleaseEnterServerAddress =>
'Bitte gib deine Music Assistant Serveradresse ein';
@override @override
String get pleaseEnterName => 'Bitte gib deinen Namen ein'; String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override @override
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)'; String get pleaseEnterValidPort =>
'Bitte gib eine gultige Portnummer ein (1-65535)';
@override @override
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein'; String get pleaseEnterCredentials =>
'Bitte gib Benutzername und Passwort ein';
@override @override
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.'; String get authFailed =>
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override @override
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.'; String get maLoginFailed =>
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override @override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.'; String get connectionFailed =>
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override @override
String get detectingAuth => 'Erkenne Authentifizierung...'; String get detectingAuth => 'Erkenne Authentifizierung...';
@override @override
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.'; String get cannotDetermineAuth =>
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override @override
String get noAuthentication => 'Keine Authentifizierung'; String get noAuthentication => 'Keine Authentifizierung';
@@ -221,10 +233,12 @@ class SDe extends S {
String get noRadioStations => 'Keine Radiosender'; String get noRadioStations => 'Keine Radiosender';
@override @override
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu'; String get addRadioStationsHint =>
'Füge Radiosender in Music Assistant hinzu';
@override @override
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.'; String get searchFailed =>
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override @override
String get queue => 'Warteschlange'; String get queue => 'Warteschlange';
@@ -359,6 +373,9 @@ class SDe extends S {
@override @override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt'; String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Abspielen'; String get play => 'Abspielen';
@@ -471,37 +488,43 @@ class SDe extends S {
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher'; String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override @override
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen'; String get tapHeartAudiobook =>
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override @override
String get noAudiobooks => 'Keine Hörbücher'; String get noAudiobooks => 'Keine Hörbücher';
@override @override
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen'; String get addAudiobooksHint =>
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override @override
String get noFavoriteArtists => 'Keine Lieblingskünstler'; String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override @override
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen'; String get tapHeartArtist =>
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override @override
String get noFavoriteAlbums => 'Keine Lieblingsalben'; String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override @override
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen'; String get tapHeartAlbum =>
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override @override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten'; String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override @override
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen'; String get tapHeartPlaylist =>
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override @override
String get noFavoriteTracks => 'Keine Lieblingssongs'; String get noFavoriteTracks => 'Keine Lieblingssongs';
@override @override
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen'; String get longPressTrackHint =>
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override @override
String get loadSeries => 'Serien laden'; String get loadSeries => 'Serien laden';
@@ -513,7 +536,8 @@ class SDe extends S {
String get notConnectedTitle => 'Nicht verbunden'; String get notConnectedTitle => 'Nicht verbunden';
@override @override
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören'; String get connectHint =>
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
@override @override
String get configureServer => 'Server konfigurieren'; String get configureServer => 'Server konfigurieren';
@@ -549,7 +573,8 @@ class SDe extends S {
String get noLogsToCopy => 'Keine Protokolle zum Kopieren'; String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override @override
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.'; String get noDebugLogsYet =>
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override @override
String get showDebug => 'Debug anzeigen'; String get showDebug => 'Debug anzeigen';
@@ -624,6 +649,18 @@ class SDe extends S {
@override @override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren'; 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 @override
String get listView => 'Listenansicht'; String get listView => 'Listenansicht';
@@ -649,10 +686,12 @@ class SDe extends S {
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen'; String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override @override
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen'; String get showRandomAudiobooks =>
'Zufällige Hörbücher zum Entdecken anzeigen';
@override @override
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen'; String get showRandomSeries =>
'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override @override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen'; String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@@ -667,16 +706,22 @@ class SDe extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations'; String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren'; String get extractColorsFromArtwork =>
'Farben aus Album- und Künstlercover extrahieren';
@override @override
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden'; String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Zu Favoriten hinzugefügt'; String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -691,7 +736,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add to library'; String get addToLibrary => 'Add';
@override @override
String get unknown => 'Unbekannt'; String get unknown => 'Unbekannt';
@@ -785,13 +830,15 @@ class SDe extends S {
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel'; String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@override @override
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel'; String get theAudioDbApiKeyHint =>
'\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
@override @override
String get audiobookLibraries => 'Hörbuch-Bibliotheken'; String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override @override
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden'; String get chooseAudiobookLibraries =>
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override @override
String get unknownLibrary => 'Unbekannte Bibliothek'; String get unknownLibrary => 'Unbekannte Bibliothek';
@@ -800,7 +847,8 @@ class SDe extends S {
String get musicProviders => 'Music Providers'; String get musicProviders => 'Music Providers';
@override @override
String get musicProvidersDescription => 'Choose which accounts to show in your library'; String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override @override
String get libraryRefreshing => 'Refreshing library...'; String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +868,19 @@ class SDe extends S {
} }
@override @override
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.'; String get noSeriesAvailable =>
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
@override @override
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden'; String get pullToLoadSeries =>
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
@override @override
String get search => 'Suche'; String get search => 'Suche';
@override @override
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.'; String get metadataApisDescription =>
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override @override
String get lastFmApiKey => 'Last.fm API-Schlüssel'; String get lastFmApiKey => 'Last.fm API-Schlüssel';
@@ -854,9 +905,15 @@ class SDe extends S {
@override @override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...'; String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...'; String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return '$name Radio starten auf...'; return '$name Radio starten auf...';
@@ -901,19 +958,22 @@ class SDe extends S {
String get preferLocalPlayer => 'Lokales Gerät bevorzugen'; String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@override @override
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar'; String get preferLocalPlayerDescription =>
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
@override @override
String get smartSortPlayers => 'Intelligente Sortierung'; String get smartSortPlayers => 'Intelligente Sortierung';
@override @override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch'; String get smartSortPlayersDescription =>
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override @override
String get disableAutoSwitch => 'Disable Auto-Switch'; String get disableAutoSwitch => 'Disable Auto-Switch';
@override @override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player'; String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Nicht verfügbar'; String get playerStateUnavailable => 'Nicht verfügbar';
@@ -945,7 +1005,15 @@ class SDe extends S {
String get showHints => 'Hinweise anzeigen'; String get showHints => 'Hinweise anzeigen';
@override @override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen'; 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 @override
String get display => 'Display'; String get display => 'Display';
@@ -954,7 +1022,8 @@ class SDe extends S {
String get showProviderIcons => 'Show Provider Icons'; String get showProviderIcons => 'Show Provider Icons';
@override @override
String get showProviderIconsDescription => 'Display provider logos on album art'; String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen'; String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@@ -972,13 +1041,15 @@ class SDe extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.'; String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return'; String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -994,19 +1065,22 @@ class SDe extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => 'Add some music to your library to see tracks here'; String get addTracksHint =>
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites'; String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites'; String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override @override
String failedToPlayRadioStation(String error) { String failedToPlayRadioStation(String error) {
+102 -37
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,7 +39,7 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank'; String get portHint => 'e.g., 8095 or leave blank';
@override @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 @override
String get username => 'Username'; String get username => 'Username';
@@ -60,10 +60,12 @@ class SEn extends S {
String get authServerUrlOptional => 'Auth Server URL (Optional)'; String get authServerUrlOptional => 'Auth Server URL (Optional)';
@override @override
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)'; String get authServerUrlHint =>
'e.g., auth.example.com (if different from server)';
@override @override
String get authServerUrlDescription => 'Leave empty if authentication is on the same server'; String get authServerUrlDescription =>
'Leave empty if authentication is on the same server';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -80,7 +82,8 @@ class SEn extends S {
String get unencryptedConnection => 'Unencrypted Connection'; String get unencryptedConnection => 'Unencrypted Connection';
@override @override
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)'; String get usingHttpOverTailscale =>
'Using HTTP over Tailscale (encrypted tunnel)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback'; String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@@ -89,31 +92,37 @@ class SEn extends S {
String get httpNotEncrypted => 'HTTP connection - data is not encrypted'; String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override @override
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address'; String get pleaseEnterServerAddress =>
'Please enter your Music Assistant server address';
@override @override
String get pleaseEnterName => 'Please enter your name'; String get pleaseEnterName => 'Please enter your name';
@override @override
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)'; String get pleaseEnterValidPort =>
'Please enter a valid port number (1-65535)';
@override @override
String get pleaseEnterCredentials => 'Please enter username and password'; String get pleaseEnterCredentials => 'Please enter username and password';
@override @override
String get authFailed => 'Authentication failed. Please check your credentials.'; String get authFailed =>
'Authentication failed. Please check your credentials.';
@override @override
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.'; String get maLoginFailed =>
'Music Assistant login failed. Please check your credentials.';
@override @override
String get connectionFailed => 'Could not connect to server. Please check the address and try again.'; String get connectionFailed =>
'Could not connect to server. Please check the address and try again.';
@override @override
String get detectingAuth => 'Detecting authentication...'; String get detectingAuth => 'Detecting authentication...';
@override @override
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.'; String get cannotDetermineAuth =>
'Cannot determine authentication requirements. Please check server URL.';
@override @override
String get noAuthentication => 'No Authentication'; String get noAuthentication => 'No Authentication';
@@ -359,6 +368,9 @@ class SEn extends S {
@override @override
String get tracksAddedToQueue => 'Tracks added to queue'; String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Play'; String get play => 'Play';
@@ -471,37 +483,43 @@ class SEn extends S {
String get noFavoriteAudiobooks => 'No favorite audiobooks'; String get noFavoriteAudiobooks => 'No favorite audiobooks';
@override @override
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites'; String get tapHeartAudiobook =>
'Tap the heart on an audiobook to add it to favorites';
@override @override
String get noAudiobooks => 'No audiobooks'; String get noAudiobooks => 'No audiobooks';
@override @override
String get addAudiobooksHint => 'Add audiobooks to your library to see them here'; String get addAudiobooksHint =>
'Add audiobooks to your library to see them here';
@override @override
String get noFavoriteArtists => 'No favorite artists'; String get noFavoriteArtists => 'No favorite artists';
@override @override
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites'; String get tapHeartArtist =>
'Tap the heart on an artist to add them to favorites';
@override @override
String get noFavoriteAlbums => 'No favorite albums'; String get noFavoriteAlbums => 'No favorite albums';
@override @override
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites'; String get tapHeartAlbum =>
'Tap the heart on an album to add it to favorites';
@override @override
String get noFavoritePlaylists => 'No favorite playlists'; String get noFavoritePlaylists => 'No favorite playlists';
@override @override
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites'; String get tapHeartPlaylist =>
'Tap the heart on a playlist to add it to favorites';
@override @override
String get noFavoriteTracks => 'No favorite tracks'; String get noFavoriteTracks => 'No favorite tracks';
@override @override
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites'; String get longPressTrackHint =>
'Long-press a track and tap the heart to add it to favorites';
@override @override
String get loadSeries => 'Load Series'; String get loadSeries => 'Load Series';
@@ -513,7 +531,8 @@ class SEn extends S {
String get notConnectedTitle => 'Not Connected'; String get notConnectedTitle => 'Not Connected';
@override @override
String get connectHint => 'Connect to your Music Assistant server to start listening'; String get connectHint =>
'Connect to your Music Assistant server to start listening';
@override @override
String get configureServer => 'Configure Server'; String get configureServer => 'Configure Server';
@@ -624,6 +643,18 @@ class SEn extends S {
@override @override
String get sortBySeriesOrder => 'Sort by series order'; 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 @override
String get listView => 'List view'; String get listView => 'List view';
@@ -667,16 +698,22 @@ class SEn extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations'; String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork'; String get extractColorsFromArtwork =>
'Extract colors from album and artist artwork';
@override @override
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen'; String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Added to favorites'; String get addedToFavorites => 'Added to favorites';
@@ -691,7 +728,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add to library'; String get addToLibrary => 'Add';
@override @override
String get unknown => 'Unknown'; String get unknown => 'Unknown';
@@ -791,7 +828,8 @@ class SEn extends S {
String get audiobookLibraries => 'Audiobook Libraries'; String get audiobookLibraries => 'Audiobook Libraries';
@override @override
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include'; String get chooseAudiobookLibraries =>
'Choose which Audiobookshelf libraries to include';
@override @override
String get unknownLibrary => 'Unknown Library'; String get unknownLibrary => 'Unknown Library';
@@ -800,7 +838,8 @@ class SEn extends S {
String get musicProviders => 'Music Providers'; String get musicProviders => 'Music Providers';
@override @override
String get musicProvidersDescription => 'Choose which accounts to show in your library'; String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override @override
String get libraryRefreshing => 'Refreshing library...'; String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +859,19 @@ class SEn extends S {
} }
@override @override
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.'; String get noSeriesAvailable =>
'No series available from your audiobook library.\nPull to refresh.';
@override @override
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant'; String get pullToLoadSeries =>
'Pull down to load series\nfrom Music Assistant';
@override @override
String get search => 'Search'; String get search => 'Search';
@override @override
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.'; String get metadataApisDescription =>
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override @override
String get lastFmApiKey => 'Last.fm API Key'; String get lastFmApiKey => 'Last.fm API Key';
@@ -854,9 +896,15 @@ class SEn extends S {
@override @override
String get addAlbumToQueueOn => 'Add album to queue on...'; String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Add to queue on...'; String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Start $name radio on...'; return 'Start $name radio on...';
@@ -901,19 +949,22 @@ class SEn extends S {
String get preferLocalPlayer => 'Prefer Local Player'; String get preferLocalPlayer => 'Prefer Local Player';
@override @override
String get preferLocalPlayerDescription => 'Always select this device first when available'; String get preferLocalPlayerDescription =>
'Always select this device first when available';
@override @override
String get smartSortPlayers => 'Smart Sort'; String get smartSortPlayers => 'Smart Sort';
@override @override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically'; String get smartSortPlayersDescription =>
'Sort by status (playing, on, off) instead of alphabetically';
@override @override
String get disableAutoSwitch => 'Disable Auto-Switch'; String get disableAutoSwitch => 'Disable Auto-Switch';
@override @override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player'; String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Unavailable'; String get playerStateUnavailable => 'Unavailable';
@@ -945,7 +996,15 @@ class SEn extends S {
String get showHints => 'Show Hints'; String get showHints => 'Show Hints';
@override @override
String get showHintsDescription => 'Display helpful tips for discovering features'; 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 @override
String get display => 'Display'; String get display => 'Display';
@@ -954,7 +1013,8 @@ class SEn extends S {
String get showProviderIcons => 'Show Provider Icons'; String get showProviderIcons => 'Show Provider Icons';
@override @override
String get showProviderIconsDescription => 'Display provider logos on album art'; String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Pull to select players'; String get pullToSelectPlayers => 'Pull to select players';
@@ -972,13 +1032,15 @@ class SEn extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.'; String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return'; String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -994,19 +1056,22 @@ class SEn extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => 'Add some music to your library to see tracks here'; String get addTracksHint =>
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites'; String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites'; String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override @override
String failedToPlayRadioStation(String error) { String failedToPlayRadioStation(String error) {
+135 -53
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SEs extends S {
String get portHint => 'ej., 8095 o dejar en blanco'; String get portHint => 'ej., 8095 o dejar en blanco';
@override @override
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.'; String get portDescription =>
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override @override
String get username => 'Usuario'; String get username => 'Usuario';
@@ -57,13 +58,16 @@ class SEs extends S {
String get disconnect => 'Desconectar'; String get disconnect => 'Desconectar';
@override @override
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)'; String get authServerUrlOptional =>
'URL del servidor de autenticación (Opcional)';
@override @override
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)'; String get authServerUrlHint =>
'ej., auth.example.com (si es diferente del servidor)';
@override @override
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor'; String get authServerUrlDescription =>
'Dejar vacío si la autenticación está en el mismo servidor';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -80,40 +84,49 @@ class SEs extends S {
String get unencryptedConnection => 'Conexión sin cifrar'; String get unencryptedConnection => 'Conexión sin cifrar';
@override @override
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)'; String get usingHttpOverTailscale =>
'Usando HTTP sobre Tailscale (túnel cifrado)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa'; String get httpsFailedUsingHttp =>
'HTTPS falló, usando HTTP como alternativa';
@override @override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados'; String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override @override
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant'; String get pleaseEnterServerAddress =>
'Por favor, introduce la dirección de tu servidor Music Assistant';
@override @override
String get pleaseEnterName => 'Por favor, introduce tu nombre'; String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override @override
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)'; String get pleaseEnterValidPort =>
'Por favor, introduce un número de puerto válido (1-65535)';
@override @override
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña'; String get pleaseEnterCredentials =>
'Por favor, introduce usuario y contraseña';
@override @override
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.'; String get authFailed =>
'Autenticación fallida. Por favor, verifica tus credenciales.';
@override @override
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.'; String get maLoginFailed =>
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override @override
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.'; String get connectionFailed =>
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override @override
String get detectingAuth => 'Detectando autenticación...'; String get detectingAuth => 'Detectando autenticación...';
@override @override
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.'; String get cannotDetermineAuth =>
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override @override
String get noAuthentication => 'Sin autenticación'; String get noAuthentication => 'Sin autenticación';
@@ -221,10 +234,12 @@ class SEs extends S {
String get noRadioStations => 'No hay emisoras de radio'; String get noRadioStations => 'No hay emisoras de radio';
@override @override
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant'; String get addRadioStationsHint =>
'Añade emisoras de radio en Music Assistant';
@override @override
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.'; String get searchFailed =>
'Búsqueda fallida. Por favor, verifica tu conexión.';
@override @override
String get queue => 'Cola'; String get queue => 'Cola';
@@ -264,7 +279,8 @@ class SEs extends S {
String get system => 'Sistema'; String get system => 'Sistema';
@override @override
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios'; String get pullToRefresh =>
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
@override @override
String get viewDebugLogs => 'Ver registros de depuración'; String get viewDebugLogs => 'Ver registros de depuración';
@@ -301,7 +317,8 @@ class SEs extends S {
String get logsCleared => 'Registros borrados'; String get logsCleared => 'Registros borrados';
@override @override
String get playerListCopied => 'Lista de reproductores copiada al portapapeles'; String get playerListCopied =>
'Lista de reproductores copiada al portapapeles';
@override @override
String get noLogsYet => 'Aún no hay registros'; String get noLogsYet => 'Aún no hay registros';
@@ -359,6 +376,9 @@ class SEs extends S {
@override @override
String get tracksAddedToQueue => 'Canciones añadidas a la cola'; String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Reproducir'; String get play => 'Reproducir';
@@ -471,37 +491,43 @@ class SEs extends S {
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos'; String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@override @override
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos'; String get tapHeartAudiobook =>
'Toca el corazón en un audiolibro para añadirlo a favoritos';
@override @override
String get noAudiobooks => 'No hay audiolibros'; String get noAudiobooks => 'No hay audiolibros';
@override @override
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí'; String get addAudiobooksHint =>
'Añade audiolibros a tu biblioteca para verlos aquí';
@override @override
String get noFavoriteArtists => 'No hay artistas favoritos'; String get noFavoriteArtists => 'No hay artistas favoritos';
@override @override
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos'; String get tapHeartArtist =>
'Toca el corazón en un artista para añadirlo a favoritos';
@override @override
String get noFavoriteAlbums => 'No hay álbumes favoritos'; String get noFavoriteAlbums => 'No hay álbumes favoritos';
@override @override
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos'; String get tapHeartAlbum =>
'Toca el corazón en un álbum para añadirlo a favoritos';
@override @override
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas'; String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override @override
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos'; String get tapHeartPlaylist =>
'Toca el corazón en una lista para añadirla a favoritos';
@override @override
String get noFavoriteTracks => 'No hay canciones favoritas'; String get noFavoriteTracks => 'No hay canciones favoritas';
@override @override
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos'; String get longPressTrackHint =>
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override @override
String get loadSeries => 'Cargar series'; String get loadSeries => 'Cargar series';
@@ -513,7 +539,8 @@ class SEs extends S {
String get notConnectedTitle => 'No conectado'; String get notConnectedTitle => 'No conectado';
@override @override
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar'; String get connectHint =>
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
@override @override
String get configureServer => 'Configurar servidor'; String get configureServer => 'Configurar servidor';
@@ -549,7 +576,8 @@ class SEs extends S {
String get noLogsToCopy => 'No hay registros para copiar'; String get noLogsToCopy => 'No hay registros para copiar';
@override @override
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.'; String get noDebugLogsYet =>
'Aún no hay registros de depuración. Intenta detectar autenticación.';
@override @override
String get showDebug => 'Mostrar depuración'; String get showDebug => 'Mostrar depuración';
@@ -564,7 +592,8 @@ class SEs extends S {
String get noChapters => 'Sin capítulos'; String get noChapters => 'Sin capítulos';
@override @override
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos'; String get noChapterInfo =>
'Este audiolibro no tiene información de capítulos';
@override @override
String errorSeeking(String error) { String errorSeeking(String error) {
@@ -624,6 +653,18 @@ class SEs extends S {
@override @override
String get sortBySeriesOrder => 'Ordenar por orden de serie'; 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 @override
String get listView => 'Vista de lista'; String get listView => 'Vista de lista';
@@ -637,7 +678,8 @@ class SEs extends S {
String get artist => 'Artista'; String get artist => 'Artista';
@override @override
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente'; String get showRecentlyPlayedAlbums =>
'Mostrar álbumes reproducidos recientemente';
@override @override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir'; String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@@ -649,34 +691,44 @@ class SEs extends S {
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso'; String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override @override
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir'; String get showRandomAudiobooks =>
'Mostrar audiolibros aleatorios para descubrir';
@override @override
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir'; String get showRandomSeries =>
'Mostrar series de audiolibros aleatorias para descubrir';
@override @override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos'; String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override @override
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos'; String get showFavoriteArtists =>
'Mostrar una fila de tus artistas favoritos';
@override @override
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas'; String get showFavoriteTracks =>
'Mostrar una fila de tus canciones favoritas';
@override @override
String get showFavoritePlaylists => 'Show a row of your favorite playlists'; String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override @override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations'; String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override @override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts'; String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override @override
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas'; String get extractColorsFromArtwork =>
'Extraer colores de las portadas de álbumes y artistas';
@override @override
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio'; String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Añadido a favoritos'; String get addedToFavorites => 'Añadido a favoritos';
@@ -691,7 +743,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add to library'; String get addToLibrary => 'Add';
@override @override
String get unknown => 'Desconocido'; String get unknown => 'Desconocido';
@@ -774,7 +826,8 @@ class SEs extends S {
String get loadingChapters => 'Cargando capítulos...'; String get loadingChapters => 'Cargando capítulos...';
@override @override
String get noChapterInfoAvailable => 'No hay información de capítulos disponible'; String get noChapterInfoAvailable =>
'No hay información de capítulos disponible';
@override @override
String percentComplete(int percent) { String percentComplete(int percent) {
@@ -785,13 +838,15 @@ class SEs extends S {
String get theAudioDbApiKey => 'Clave API de TheAudioDB'; String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override @override
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium'; String get theAudioDbApiKeyHint =>
'Usa \"2\" para nivel gratuito o clave premium';
@override @override
String get audiobookLibraries => 'Bibliotecas de audiolibros'; String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override @override
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir'; String get chooseAudiobookLibraries =>
'Elige qué bibliotecas de Audiobookshelf incluir';
@override @override
String get unknownLibrary => 'Biblioteca desconocida'; String get unknownLibrary => 'Biblioteca desconocida';
@@ -800,7 +855,8 @@ class SEs extends S {
String get musicProviders => 'Music Providers'; String get musicProviders => 'Music Providers';
@override @override
String get musicProvidersDescription => 'Choose which accounts to show in your library'; String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override @override
String get libraryRefreshing => 'Refreshing library...'; String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +876,19 @@ class SEs extends S {
} }
@override @override
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.'; String get noSeriesAvailable =>
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
@override @override
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant'; String get pullToLoadSeries =>
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
@override @override
String get search => 'Buscar'; String get search => 'Buscar';
@override @override
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.'; String get metadataApisDescription =>
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override @override
String get lastFmApiKey => 'Clave API de Last.fm'; String get lastFmApiKey => 'Clave API de Last.fm';
@@ -854,9 +913,15 @@ class SEs extends S {
@override @override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...'; String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Añadir a la cola en...'; String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Iniciar radio de $name en...'; return 'Iniciar radio de $name en...';
@@ -901,19 +966,22 @@ class SEs extends S {
String get preferLocalPlayer => 'Preferir dispositivo local'; String get preferLocalPlayer => 'Preferir dispositivo local';
@override @override
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible'; String get preferLocalPlayerDescription =>
'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override @override
String get smartSortPlayers => 'Orden inteligente'; String get smartSortPlayers => 'Orden inteligente';
@override @override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente'; String get smartSortPlayersDescription =>
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override @override
String get disableAutoSwitch => 'Disable Auto-Switch'; String get disableAutoSwitch => 'Disable Auto-Switch';
@override @override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player'; String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'No disponible'; String get playerStateUnavailable => 'No disponible';
@@ -945,7 +1013,15 @@ class SEs extends S {
String get showHints => 'Mostrar Sugerencias'; String get showHints => 'Mostrar Sugerencias';
@override @override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones'; 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 @override
String get display => 'Display'; String get display => 'Display';
@@ -954,7 +1030,8 @@ class SEs extends S {
String get showProviderIcons => 'Show Provider Icons'; String get showProviderIcons => 'Show Provider Icons';
@override @override
String get showProviderIconsDescription => 'Display provider logos on album art'; String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos'; String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@@ -972,13 +1049,15 @@ class SEs extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble'; String get welcomeToEnsemble => 'Welcome to Ensemble';
@override @override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.'; String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override @override
String get skip => 'Skip'; String get skip => 'Skip';
@override @override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return'; String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -994,19 +1073,22 @@ class SEs extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => 'Add some music to your library to see tracks here'; String get addTracksHint =>
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites'; String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites'; String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override @override
String failedToPlayRadioStation(String error) { String failedToPlayRadioStation(String error) {
+152 -61
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart'; import 'app_localizations.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SFr extends S {
String get portHint => 'ex., 8095 ou laisser vide'; String get portHint => 'ex., 8095 ou laisser vide';
@override @override
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.'; String get portDescription =>
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override @override
String get username => 'Nom d\'utilisateur'; String get username => 'Nom d\'utilisateur';
@@ -60,10 +61,12 @@ class SFr extends S {
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)'; String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override @override
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)'; String get authServerUrlHint =>
'ex., auth.example.com (si different du serveur)';
@override @override
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur'; String get authServerUrlDescription =>
'Laisser vide si l\'authentification est sur le meme serveur';
@override @override
String detectedAuthType(String authType) { String detectedAuthType(String authType) {
@@ -80,40 +83,50 @@ class SFr extends S {
String get unencryptedConnection => 'Connexion non chiffrée'; String get unencryptedConnection => 'Connexion non chiffrée';
@override @override
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)'; String get usingHttpOverTailscale =>
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override @override
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli'; String get httpsFailedUsingHttp =>
'HTTPS a échoué, utilisation de HTTP en repli';
@override @override
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées'; String get httpNotEncrypted =>
'Connexion HTTP - les donnees ne sont pas chiffrées';
@override @override
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant'; String get pleaseEnterServerAddress =>
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
@override @override
String get pleaseEnterName => 'Veuillez entrer votre nom'; String get pleaseEnterName => 'Veuillez entrer votre nom';
@override @override
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)'; String get pleaseEnterValidPort =>
'Veuillez entrer un numero de port valide (1-65535)';
@override @override
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe'; String get pleaseEnterCredentials =>
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
@override @override
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.'; String get authFailed =>
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override @override
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.'; String get maLoginFailed =>
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override @override
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.'; String get connectionFailed =>
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override @override
String get detectingAuth => 'Detection de l\'authentification...'; String get detectingAuth => 'Detection de l\'authentification...';
@override @override
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.'; String get cannotDetermineAuth =>
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override @override
String get noAuthentication => 'Pas d\'authentification'; String get noAuthentication => 'Pas d\'authentification';
@@ -128,7 +141,8 @@ class SFr extends S {
String get musicAssistantLogin => 'Connexion Music Assistant'; String get musicAssistantLogin => 'Connexion Music Assistant';
@override @override
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser'; String get pressBackToMinimize =>
'Appuyez a nouveau sur retour pour minimiser';
@override @override
String get recentlyPlayed => 'Ecoutes recemment'; String get recentlyPlayed => 'Ecoutes recemment';
@@ -221,10 +235,12 @@ class SFr extends S {
String get noRadioStations => 'Aucune station de radio'; String get noRadioStations => 'Aucune station de radio';
@override @override
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant'; String get addRadioStationsHint =>
'Ajouter des stations de radio dans Music Assistant';
@override @override
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.'; String get searchFailed =>
'La recherche a échoué. Veuillez verifier votre connexion.';
@override @override
String get queue => 'File d\'attente'; String get queue => 'File d\'attente';
@@ -264,7 +280,8 @@ class SFr extends S {
String get system => 'Système'; String get system => 'Système';
@override @override
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications'; String get pullToRefresh =>
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
@override @override
String get viewDebugLogs => 'Voir les journaux de debogage'; String get viewDebugLogs => 'Voir les journaux de debogage';
@@ -301,7 +318,8 @@ class SFr extends S {
String get logsCleared => 'Journaux effaces'; String get logsCleared => 'Journaux effaces';
@override @override
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !'; String get playerListCopied =>
'Liste des lecteurs copiee dans le presse-papiers !';
@override @override
String get noLogsYet => 'Pas encore de journaux'; String get noLogsYet => 'Pas encore de journaux';
@@ -359,6 +377,9 @@ class SFr extends S {
@override @override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente'; String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Lire'; String get play => 'Lire';
@@ -471,37 +492,43 @@ class SFr extends S {
String get noFavoriteAudiobooks => 'Aucun livre audio favori'; String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override @override
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris'; String get tapHeartAudiobook =>
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override @override
String get noAudiobooks => 'Aucun livre audio'; String get noAudiobooks => 'Aucun livre audio';
@override @override
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici'; String get addAudiobooksHint =>
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override @override
String get noFavoriteArtists => 'Aucun artiste favori'; String get noFavoriteArtists => 'Aucun artiste favori';
@override @override
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris'; String get tapHeartArtist =>
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override @override
String get noFavoriteAlbums => 'Aucun album favori'; String get noFavoriteAlbums => 'Aucun album favori';
@override @override
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris'; String get tapHeartAlbum =>
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override @override
String get noFavoritePlaylists => 'Aucune playlist favorite'; String get noFavoritePlaylists => 'Aucune playlist favorite';
@override @override
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris'; String get tapHeartPlaylist =>
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override @override
String get noFavoriteTracks => 'Aucune piste favorite'; String get noFavoriteTracks => 'Aucune piste favorite';
@override @override
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris'; String get longPressTrackHint =>
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override @override
String get loadSeries => 'Charger les séries'; String get loadSeries => 'Charger les séries';
@@ -513,7 +540,8 @@ class SFr extends S {
String get notConnectedTitle => 'Not Connected'; String get notConnectedTitle => 'Not Connected';
@override @override
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter'; String get connectHint =>
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
@override @override
String get configureServer => 'Configurer le serveur'; String get configureServer => 'Configurer le serveur';
@@ -549,7 +577,8 @@ class SFr extends S {
String get noLogsToCopy => 'Aucun journal a copier'; String get noLogsToCopy => 'Aucun journal a copier';
@override @override
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.'; String get noDebugLogsYet =>
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
@override @override
String get showDebug => 'Afficher le debogage'; String get showDebug => 'Afficher le debogage';
@@ -564,7 +593,8 @@ class SFr extends S {
String get noChapters => 'Aucun chapitre'; String get noChapters => 'Aucun chapitre';
@override @override
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre'; String get noChapterInfo =>
'Ce livre audio n\'a pas d\'informations de chapitre';
@override @override
String errorSeeking(String error) { String errorSeeking(String error) {
@@ -624,6 +654,18 @@ class SFr extends S {
@override @override
String get sortBySeriesOrder => 'Trier par ordre de série'; 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 @override
String get listView => 'Vue en liste'; String get listView => 'Vue en liste';
@@ -637,10 +679,12 @@ class SFr extends S {
String get artist => 'Artiste'; String get artist => 'Artiste';
@override @override
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment'; String get showRecentlyPlayedAlbums =>
'Afficher les albums écoutes recemment';
@override @override
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir'; String get showRandomArtists =>
'Afficher des artistes aléatoires a découvrir';
@override @override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir'; String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@@ -649,34 +693,46 @@ class SFr extends S {
String get showAudiobooksInProgress => 'Afficher les livres audio en cours'; String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@override @override
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir'; String get showRandomAudiobooks =>
'Afficher des livres audio aléatoires a découvrir';
@override @override
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir'; String get showRandomSeries =>
'Afficher des séries de livres audio aléatoires a découvrir';
@override @override
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris'; String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override @override
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris'; String get showFavoriteArtists =>
'Afficher une rangee de vos artistes favoris';
@override @override
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites'; String get showFavoriteTracks =>
'Afficher une rangee de vos pistes favorites';
@override @override
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites'; String get showFavoritePlaylists =>
'Afficher une rangee de vos playlists favorites';
@override @override
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites'; String get showFavoriteRadioStations =>
'Afficher une rangee de vos stations de radio favorites';
@override @override
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris'; String get showFavoritePodcasts =>
'Afficher une rangee de vos podcasts favoris';
@override @override
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes'; String get extractColorsFromArtwork =>
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
@override @override
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil'; String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Ajoute aux favoris'; String get addedToFavorites => 'Ajoute aux favoris';
@@ -721,13 +777,15 @@ class SFr extends S {
String get podcasts => 'Podcasts'; String get podcasts => 'Podcasts';
@override @override
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible'; String get podcastSupportComingSoon =>
'Prise en charge des podcasts bientot disponible';
@override @override
String get noPodcasts => 'Aucun podcast'; String get noPodcasts => 'Aucun podcast';
@override @override
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant'; String get addPodcastsHint =>
'Abonnez-vous a des podcasts dans Music Assistant';
@override @override
String get episodes => 'Episodes'; String get episodes => 'Episodes';
@@ -774,7 +832,8 @@ class SFr extends S {
String get loadingChapters => 'Chargement des chapitres...'; String get loadingChapters => 'Chargement des chapitres...';
@override @override
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible'; String get noChapterInfoAvailable =>
'Aucune information de chapitre disponible';
@override @override
String percentComplete(int percent) { String percentComplete(int percent) {
@@ -785,13 +844,15 @@ class SFr extends S {
String get theAudioDbApiKey => 'Cle API TheAudioDB'; String get theAudioDbApiKey => 'Cle API TheAudioDB';
@override @override
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium'; String get theAudioDbApiKeyHint =>
'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
@override @override
String get audiobookLibraries => 'Bibliothèques de livres audio'; String get audiobookLibraries => 'Bibliothèques de livres audio';
@override @override
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure'; String get chooseAudiobookLibraries =>
'Choisir quelles bibliothèques Audiobookshelf inclure';
@override @override
String get unknownLibrary => 'Bibliothèque inconnue'; String get unknownLibrary => 'Bibliothèque inconnue';
@@ -800,7 +861,8 @@ class SFr extends S {
String get musicProviders => 'Music Providers'; String get musicProviders => 'Music Providers';
@override @override
String get musicProvidersDescription => 'Choose which accounts to show in your library'; String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override @override
String get libraryRefreshing => 'Refreshing library...'; String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +882,19 @@ class SFr extends S {
} }
@override @override
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.'; String get noSeriesAvailable =>
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
@override @override
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant'; String get pullToLoadSeries =>
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
@override @override
String get search => 'Rechercher'; String get search => 'Rechercher';
@override @override
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.'; String get metadataApisDescription =>
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override @override
String get lastFmApiKey => 'Cle API Last.fm'; String get lastFmApiKey => 'Cle API Last.fm';
@@ -852,11 +917,18 @@ class SFr extends S {
String get playOn => 'Lire sur...'; String get playOn => 'Lire sur...';
@override @override
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...'; String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...'; String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Demarrer la radio $name sur...'; return 'Demarrer la radio $name sur...';
@@ -889,7 +961,8 @@ class SFr extends S {
} }
@override @override
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)'; String get materialYouDescription =>
'Utiliser les couleurs systeme (Android 12+)';
@override @override
String get accentColor => 'Couleur d\'accentuation'; String get accentColor => 'Couleur d\'accentuation';
@@ -901,19 +974,22 @@ class SFr extends S {
String get preferLocalPlayer => 'Préférer le lecteur local'; String get preferLocalPlayer => 'Préférer le lecteur local';
@override @override
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible'; String get preferLocalPlayerDescription =>
'Toujours sélectionner cet appareil en premier si disponible';
@override @override
String get smartSortPlayers => 'Tri intelligent'; String get smartSortPlayers => 'Tri intelligent';
@override @override
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement'; String get smartSortPlayersDescription =>
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override @override
String get disableAutoSwitch => 'Disable Auto-Switch'; String get disableAutoSwitch => 'Disable Auto-Switch';
@override @override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player'; String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override @override
String get playerStateUnavailable => 'Indisponible'; String get playerStateUnavailable => 'Indisponible';
@@ -945,7 +1021,15 @@ class SFr extends S {
String get showHints => 'Afficher les astuces'; String get showHints => 'Afficher les astuces';
@override @override
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites'; 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 @override
String get display => 'Display'; String get display => 'Display';
@@ -954,7 +1038,8 @@ class SFr extends S {
String get showProviderIcons => 'Show Provider Icons'; String get showProviderIcons => 'Show Provider Icons';
@override @override
String get showProviderIconsDescription => 'Display provider logos on album art'; String get showProviderIconsDescription =>
'Display provider logos on album art';
@override @override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs'; String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@@ -966,19 +1051,22 @@ class SFr extends S {
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume'; String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@override @override
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas'; String get selectPlayerHint =>
'Choisissez un lecteur ou fermez en balayant vers le bas';
@override @override
String get welcomeToEnsemble => 'Bienvenue dans Ensemble'; String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override @override
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.'; String get welcomeMessage =>
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override @override
String get skip => 'Passer'; String get skip => 'Passer';
@override @override
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir'; String get dismissPlayerHint =>
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override @override
String playingAlbum(String albumName) { String playingAlbum(String albumName) {
@@ -994,19 +1082,22 @@ class SFr extends S {
String get noTracks => 'No tracks'; String get noTracks => 'No tracks';
@override @override
String get addTracksHint => 'Add some music to your library to see tracks here'; String get addTracksHint =>
'Add some music to your library to see tracks here';
@override @override
String get noFavoriteRadioStations => 'No favorite radio stations'; String get noFavoriteRadioStations => 'No favorite radio stations';
@override @override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites'; String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override @override
String get noFavoritePodcasts => 'No favorite podcasts'; String get noFavoritePodcasts => 'No favorite podcasts';
@override @override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites'; String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override @override
String failedToPlayRadioStation(String error) { String failedToPlayRadioStation(String error) {
+43 -4
View File
@@ -137,6 +137,12 @@ class MediaItem {
@override @override
int get hashCode => Object.hash(itemId, provider); 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 { class Artist extends MediaItem {
@@ -165,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 @override
Map<String, dynamic> toJson() => super.toJson(); Map<String, dynamic> toJson() => super.toJson();
} }
@@ -286,6 +298,12 @@ class Track extends MediaItem {
String get artistsString => String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist'; artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// 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 @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = super.toJson(); final json = super.toJson();
@@ -332,6 +350,12 @@ class Playlist extends MediaItem {
favorite: item.favorite, 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 { class Chapter {
@@ -440,9 +464,20 @@ class Audiobook extends MediaItem {
factory Audiobook.fromJson(Map<String, dynamic> json) { factory Audiobook.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json); final item = MediaItem.fromJson(json);
// Parse year // Parse year - check top level first, then metadata.release_date (ABS format)
int? year; int? year;
final yearValue = json['year']; final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic yearValue = json['year'];
if (yearValue == null && metadata != null) {
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
final releaseDate = metadata['release_date'];
if (releaseDate is String && releaseDate.isNotEmpty) {
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
yearValue = releaseDate.split('-').first;
} else if (releaseDate is int) {
yearValue = releaseDate;
}
}
if (yearValue is int) { if (yearValue is int) {
year = yearValue; year = yearValue;
} else if (yearValue is String) { } else if (yearValue is String) {
@@ -472,8 +507,6 @@ class Audiobook extends MediaItem {
// Parse chapters - check top level first, then metadata // Parse chapters - check top level first, then metadata
List<Chapter>? chapters; List<Chapter>? chapters;
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic chaptersData = json['chapters']; dynamic chaptersData = json['chapters'];
if (chaptersData == null && metadata != null) { if (chaptersData == null && metadata != null) {
chaptersData = metadata['chapters']; chaptersData = metadata['chapters'];
@@ -519,6 +552,12 @@ class Audiobook extends MediaItem {
String get narratorsString => String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator'; 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) /// Get progress as a percentage (0.0 to 1.0)
double get progress { double get progress {
if (fullyPlayed == true) return 1.0; 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 String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active 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? 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({ Player({
required this.playerId, required this.playerId,
@@ -35,6 +36,7 @@ class Player {
this.activeSource, this.activeSource,
this.isExternalSource = false, this.isExternalSource = false,
this.appId, this.appId,
this.activeQueue,
}); });
/// Create a copy of this Player with some fields replaced /// Create a copy of this Player with some fields replaced
@@ -55,6 +57,7 @@ class Player {
String? activeSource, String? activeSource,
bool? isExternalSource, bool? isExternalSource,
String? appId, String? appId,
String? activeQueue,
}) { }) {
return Player( return Player(
playerId: playerId ?? this.playerId, playerId: playerId ?? this.playerId,
@@ -73,6 +76,7 @@ class Player {
activeSource: activeSource ?? this.activeSource, activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource, isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId, appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
); );
} }
@@ -218,50 +222,61 @@ class Player {
// Parse active_source - the currently active source for this player // Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?; final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.) // Parse active_queue - this is the key indicator for MA-controlled playback
// Primary indicator: app_id - if not 'music_assistant', it's an external source // When MA is playing: active_queue = 'uuid:...' or similar queue ID
// Secondary indicators: URI patterns and media_type // 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; bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players // If MA explicitly says it's playing, trust that
// When MA is playing: app_id = 'music_assistant' if (appId == 'music_assistant') {
// When external source: app_id = 'http', 'spotify', etc. isExternalSource = false;
final appId = json['app_id'] as String?; }
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') { // If there's an active queue, MA is controlling playback (even with spotify:// URIs)
// app_id is set but not 'music_assistant' - external source is active 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; isExternalSource = true;
} }
// Fallback: check current_media for simple external source indicators
// Also check current_media for additional external source indicators // Only check these when we have no app_id and no active_queue
if (!isExternalSource && json.containsKey('current_media')) { else if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?; final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) { if (currentMedia != null) {
final uri = currentMedia['uri'] as String?; final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?; final mediaType = currentMedia['media_type'] as String?;
// External source indicators: // Only check for simple external source identifiers (physical inputs)
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi' // Do NOT flag spotify://, airplay://, etc. as external here -
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc. // those should only be external if app_id indicates external control
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) { if (uri != null) {
final uriLower = uri.toLowerCase(); final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /) // Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') && final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' || (uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' || uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink'); 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 (!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?; final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) { if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true; isExternalSource = true;
@@ -287,6 +302,7 @@ class Player {
activeSource: activeSource, activeSource: activeSource,
isExternalSource: isExternalSource, isExternalSource: isExternalSource,
appId: appId, appId: appId,
activeQueue: activeQueue,
); );
} }
@@ -308,6 +324,7 @@ class Player {
if (activeSource != null) 'active_source': activeSource, if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource, 'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId, if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
}; };
} }
} }
+256 -146
View File
@@ -26,6 +26,7 @@ import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart'; import '../services/offline_action_queue.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../services/library_status_service.dart';
import '../main.dart' show audioHandler; import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state. /// Main provider that coordinates connection, player, and library state.
@@ -64,8 +65,6 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer; Player? _selectedPlayer;
List<Player> _availablePlayers = []; List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer() bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack; Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters) Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -79,6 +78,10 @@ class MusicAssistantProvider with ChangeNotifier {
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
// Idle service timeout - stops foreground service after 30 min of no playback
Timer? _idleServiceTimer;
static const _idleServiceTimeout = Duration(minutes: 30);
// Local player state // Local player state
bool _isLocalPlayerPowered = true; bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100) int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -371,22 +374,40 @@ class MusicAssistantProvider with ChangeNotifier {
// Build list of providers that support this category, with their item counts // Build list of providers that support this category, with their item counts
// Only include providers that have at least 1 item (hides providers not synced to library) // Only include providers that have at least 1 item (hides providers not synced to library)
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
// but we dedupe if names match exactly (same account re-added with new instance ID)
final result = <(ProviderInstance, int)>[]; final result = <(ProviderInstance, int)>[];
final addedInstanceIds = <String>{}; final addedInstanceIds = <String>{};
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
for (final provider in _availableMusicProviders) { for (final provider in _availableMusicProviders) {
// Only include providers that support this content type AND have items // Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) { if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0; final count = counts[provider.instanceId] ?? 0;
if (count > 0) { if (count > 0) {
result.add((provider, count)); final existingCount = addedNames[provider.name];
addedInstanceIds.add(provider.instanceId); if (existingCount == null) {
// First provider with this name
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
} else if (count > existingCount) {
// Same name but more items - likely same account re-added, keep better one
result.removeWhere((r) => r.$1.name == provider.name);
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
}
} }
} }
} }
// Also include providers that have items but aren't in _availableMusicProviders // Also include providers that have items but aren't in _availableMusicProviders
// (e.g., search-only providers like iTunes) // (e.g., search-only providers like iTunes, or orphaned items from removed providers)
// For these synthetic providers, dedupe by domain since we don't have account-specific names
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
final addedSyntheticDomains = <String>{};
for (final entry in counts.entries) { for (final entry in counts.entries) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) { if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID // Create a synthetic ProviderInstance from the instance ID
@@ -395,6 +416,11 @@ class MusicAssistantProvider with ChangeNotifier {
final domain = instanceId.contains('--') final domain = instanceId.contains('--')
? instanceId.split('--').first ? instanceId.split('--').first
: instanceId; : instanceId;
// Skip if this domain is already represented by a real provider or synthetic
if (addedRealDomains.contains(domain)) continue;
if (addedSyntheticDomains.contains(domain)) continue;
// Only add if this domain supports the category // Only add if this domain supports the category
final capabilities = ProviderInstance.providerCapabilities[domain]; final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) { if (capabilities != null && capabilities.contains(category)) {
@@ -407,6 +433,7 @@ class MusicAssistantProvider with ChangeNotifier {
), ),
entry.value, entry.value,
)); ));
addedSyntheticDomains.add(domain);
} }
} }
} }
@@ -894,6 +921,7 @@ class MusicAssistantProvider with ChangeNotifier {
_artists = syncService.cachedArtists; _artists = syncService.cachedArtists;
// Note: tracks are loaded separately via API, not cached in SyncService // Note: tracks are loaded separately via API, not cached in SyncService
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists'); _logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} }
} catch (e) { } catch (e) {
@@ -989,11 +1017,11 @@ class MusicAssistantProvider with ChangeNotifier {
} }
Future<void> _restoreAuthCredentials() async { Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials(); final storedToken = await SettingsService.getMaAuthToken();
if (savedCredentials != null) { if (storedToken != null) {
_logger.log('🔐 Restoring saved auth credentials...'); _logger.log('🔐 Restoring saved MA token...');
_authManager.deserializeCredentials(savedCredentials); _authManager.setToken(storedToken);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}'); _logger.log('🔐 Auth token restored');
} }
} }
@@ -1252,11 +1280,23 @@ class MusicAssistantProvider with ChangeNotifier {
return false; return false;
} }
await SettingsService.toggleMusicProvider(instanceId, enabled, allIds); // Update local state directly for immediate UI response
// If currently "all enabled" (empty list) and disabling one, need to switch to explicit mode
if (_enabledProviderIds.isEmpty && !enabled) {
// Initialize with all providers except the one being disabled
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
} else if (enabled) {
// Add to list if not present
if (!_enabledProviderIds.contains(instanceId)) {
_enabledProviderIds = [..._enabledProviderIds, instanceId];
}
} else {
// Remove from list
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
}
// Reload the enabled providers // Persist to settings (async, but don't wait for UI update)
final savedEnabled = await SettingsService.getEnabledMusicProviders(); SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
_enabledProviderIds = savedEnabled ?? [];
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}'); _logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
@@ -1583,6 +1623,41 @@ class MusicAssistantProvider with ChangeNotifier {
// LIBRARY MANAGEMENT // LIBRARY MANAGEMENT
// ============================================================================ // ============================================================================
/// Sync library status to centralized service for reactive UI updates
void _syncLibraryStatusToService() {
final service = LibraryStatusService.instance;
// Sync albums
service.syncFromLibrary(
mediaType: 'album',
items: _albums,
getInLibrary: (a) => (a as Album).inLibrary,
getFavorite: (a) => (a as Album).favorite ?? false,
getProvider: (a) => (a as Album).provider,
getItemId: (a) => (a as Album).itemId,
);
// Sync artists
service.syncFromLibrary(
mediaType: 'artist',
items: _artists,
getInLibrary: (a) => (a as Artist).inLibrary,
getFavorite: (a) => (a as Artist).favorite ?? false,
getProvider: (a) => (a as Artist).provider,
getItemId: (a) => (a as Artist).itemId,
);
// Sync tracks
service.syncFromLibrary(
mediaType: 'track',
items: _tracks,
getInLibrary: (t) => (t as Track).inLibrary,
getFavorite: (t) => (t as Track).favorite ?? false,
getProvider: (t) => (t as Track).provider,
getItemId: (t) => (t as Track).itemId,
);
}
/// Add item to library /// Add item to library
/// Returns true if action was executed successfully /// Returns true if action was executed successfully
Future<bool> addToLibrary({ Future<bool> addToLibrary({
@@ -1628,8 +1703,11 @@ class MusicAssistantProvider with ChangeNotifier {
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback // OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
// This ensures library screens show the change even before API completes // This ensures library screens show the change even before API completes
_removeFromLocalLibrary(mediaType, libraryItemId); _removeFromLocalLibrary(mediaType, libraryItemId);
// Also remove from SyncService's in-memory cache
SyncService.instance.removeFromCacheByLibraryId(mediaType, libraryItemId);
// Also mark as deleted in database cache so it doesn't reappear on next load // Also mark as deleted in database cache so it doesn't reappear on next load
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString()); // Use the library ID aware method that handles cases where itemId differs from libraryId
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status // Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache(); _cacheService.invalidateSearchCache();
if (mediaType == 'album') { if (mediaType == 'album') {
@@ -1750,6 +1828,7 @@ class MusicAssistantProvider with ChangeNotifier {
} else if (mediaType == 'podcast') { } else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100); _podcasts = await _api!.getPodcasts(limit: 100);
} }
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
_logger.log('⚠️ Background library refresh failed: $e'); _logger.log('⚠️ Background library refresh failed: $e');
@@ -1962,24 +2041,21 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20; return beta >= 20;
} }
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint) /// Check if server has built-in Sendspin proxy (added in MA 2.7.1)
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
/// or manually configure reverse proxy routing to port 8927
bool _serverHasSendspinProxy() { bool _serverHasSendspinProxy() {
final version = _parseServerVersion(); final version = _parseServerVersion();
if (version == null) return false; if (version == null) return false;
final (:major, :minor, :patch, :beta) = version; final (:major, :minor, :patch, :beta) = version;
// Compare with 2.8.0 // Compare with 2.7.1
if (major > 2) return true; if (major > 2) return true;
if (major < 2) return false; if (major < 2) return false;
// major == 2 // major == 2
if (minor > 8) return true; if (minor > 7) return true;
if (minor < 8) return false; if (minor < 7) return false;
// minor == 8, any 2.8.x version has the proxy // minor == 7, need patch >= 1
return true; return patch >= 1;
} }
/// Get the server version string for logging /// Get the server version string for logging
@@ -2092,7 +2168,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// This is the replacement for builtin_player when that API is not available. /// This is the replacement for builtin_player when that API is not available.
/// ///
/// Connection strategy depends on MA version and network: /// Connection strategy depends on MA version and network:
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup /// - MA 2.7.1+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.x: NO proxy, must either: /// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR /// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927 /// - Manually configure reverse proxy to route /sendspin to port 8927
@@ -2108,7 +2184,7 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService?.dispose(); _sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); _sendspinService = SendspinService(_serverUrl!);
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy) // Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken(); final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) { if (authToken != null) {
_sendspinService!.setAuthToken(authToken); _sendspinService!.setAuthToken(authToken);
@@ -2159,10 +2235,10 @@ class MusicAssistantProvider with ChangeNotifier {
} }
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed'); _logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path in case user has a local reverse proxy // For local IPs, also try the proxy path (MA 2.7.1+ has built-in /sendspin proxy)
_logger.log('Sendspin: Trying local proxy fallback...'); _logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin'; final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl); final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl, useProxyAuth: true);
if (proxyConnected) { if (proxyConnected) {
_sendspinConnected = true; _sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy'); _logger.log('✅ Sendspin: Connected via local proxy');
@@ -2173,7 +2249,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin // Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) { if (isHttps || !isLocalIp) {
if (hasProxy) { if (hasProxy) {
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy'); _logger.log('Sendspin: MA 2.7.1+ detected, using built-in /sendspin proxy');
} else { } else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)'); _logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
} }
@@ -2214,7 +2290,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927'); _logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }'); _logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else { } else {
// MA 2.8.0+ but still failing // MA 2.7.1+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy'); _logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections'); _logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through'); _logger.log(' Ensure Upgrade and Connection headers are passed through');
@@ -2363,6 +2439,7 @@ class MusicAssistantProvider with ChangeNotifier {
); );
// Initialize notification with position 0 // Initialize notification with position 0
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: true, playing: true,
@@ -2403,6 +2480,7 @@ class MusicAssistantProvider with ChangeNotifier {
); );
// Show paused state with preserved position // Show paused state with preserved position
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: false, playing: false,
@@ -2658,21 +2736,31 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🎙️ Podcast currentMedia: $currentMedia'); _logger.log('🎙️ Podcast currentMedia: $currentMedia');
} }
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata // Check for external source (optical, Spotify Connect, etc.) - skip caching stale metadata
// Use same logic as Player.fromJson:
// 1. If app_id == 'music_assistant' -> NOT external
// 2. If active_queue has value -> NOT external (MA controls playback)
// 3. If app_id is set and != 'music_assistant' -> external
// 4. Fallback: check for simple external IDs (optical, etc.)
final appId = event['app_id'] as String?;
final activeQueue = event['active_queue'] as String?;
bool isExternalSource = false; bool isExternalSource = false;
if (uri != null) { if (appId == 'music_assistant') {
isExternalSource = false;
} else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
} else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
} else if (uri != null) {
final uriLower = uri.toLowerCase(); final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /) // Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, etc. - those need app_id/active_queue context
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') && final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' || (uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' || uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink'); uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols isExternalSource = isSimpleExternalId;
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
// Also treat unknown media type with non-MA URIs as external // Also treat unknown media type with non-MA URIs as external
if (!isExternalSource && mediaType == 'unknown' && if (!isExternalSource && mediaType == 'unknown' &&
@@ -2682,7 +2770,7 @@ class MusicAssistantProvider with ChangeNotifier {
} }
if (isExternalSource) { if (isExternalSource) {
_logger.log('📡 External source detected for $playerName (uri: $uri) - skipping metadata cache'); _logger.log('📡 External source detected for $playerName (app_id: $appId, uri: $uri) - skipping metadata cache');
// Clear cached track for this player to avoid showing stale data // Clear cached track for this player to avoid showing stale data
_cacheService.clearCachedTrackForPlayer(playerId); _cacheService.clearCachedTrackForPlayer(playerId);
return; // Skip further processing for external sources return; // Skip further processing for external sources
@@ -3126,6 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('⚠️ Failed to refresh tracks: $e'); _logger.log('⚠️ Failed to refresh tracks: $e');
} }
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
_logger.log('✅ Force sync complete'); _logger.log('✅ Force sync complete');
} }
@@ -3744,33 +3833,20 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) { if (_availablePlayers.isNotEmpty) {
Player? playerToSelect; Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Log settings for debugging player selection issues _logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
// If "Prefer Local Player" is ON, always try to select local player first // SIMPLIFIED PLAYER SELECTION LOGIC:
// This takes priority even over the currently selected player // 1. User's manual selection is always respected
if (preferLocalPlayer && builtinPlayerId != null) { // 2. On app reopen: restore last selection
try { // 3. If selected player unavailable: switch to local player
playerToSelect = _availablePlayers.firstWhere( // 4. First launch (no history): select local player
(p) => p.playerId == builtinPlayerId && p.available, // 5. Local player unavailable: select first available
);
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) {
// Local player not available yet, will fall through to other options
_logger.log('⚠️ Prefer local player ON but local player not available');
}
}
// Keep currently selected player if still available (and not overridden by prefer local) // Keep currently selected player if still available
// On coldStart, skip this block and apply full priority logic if (_selectedPlayer != null && !coldStart) {
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs // Check if selected player is still available - also check translated Cast/Sendspin IDs
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
final selectedId = _selectedPlayer!.playerId; final selectedId = _selectedPlayer!.playerId;
final translatedId = _castToSendspinIdMap[selectedId]; final translatedId = _castToSendspinIdMap[selectedId];
String? reverseTranslatedId; String? reverseTranslatedId;
@@ -3787,103 +3863,55 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)), (reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
); );
if (stillAvailable) { if (stillAvailable) {
// Find the actual player in the list (might be Cast or Sendspin version)
final currentPlayer = _availablePlayers.firstWhere( final currentPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == selectedId || (p) => p.playerId == selectedId ||
(translatedId != null && p.playerId == translatedId) || (translatedId != null && p.playerId == translatedId) ||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId), (reverseTranslatedId != null && p.playerId == reverseTranslatedId),
); );
// Keep current selection - no auto-switching during non-coldStart refreshes
// Auto-switch to playing player is now ONLY done on coldStart (see below)
playerToSelect = currentPlayer; playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) { if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}'); _logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else { } else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}'); _logger.log('✓ Keeping current selection: ${currentPlayer.name}');
} }
} else {
// Selected player went offline - will fall through to local player
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
} }
} }
if (coldStart) { // Priority 1: Restore last selected player (on coldStart)
_logger.log('🚀 Cold start: applying full priority logic'); if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
}
} }
if (playerToSelect == null) { // Priority 2: Local player (default fallback)
// Smart auto-selection priority (coldStart only for auto-switch to playing): if (playerToSelect == null && builtinPlayerId != null) {
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF) try {
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
// Priority 3: Last manually selected player
// Priority 4: First available player
// Priority 1: Auto-switch to single playing player
// ONLY on coldStart AND only if disableAutoSwitch is OFF
// Also apply cooldown to prevent rapid successive auto-switches
final cooldownActive = _lastPlayerSwitchTime != null &&
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
} else if (playingPlayers.length > 1) {
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
}
} else if (cooldownActive) {
_logger.log('⏳ Auto-switch cooldown active, skipping');
} else if (!coldStart) {
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
} else if (disableAutoSwitch) {
_logger.log('⚠️ Auto-switch disabled by user setting');
}
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
// Priority 3: Last manually selected player
if (playerToSelect == null && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
}
}
// Priority 4: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere( playerToSelect = _availablePlayers.firstWhere(
(p) => p.available, (p) => p.playerId == builtinPlayerId && p.available,
orElse: () => _availablePlayers.first,
); );
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}'); _logger.log('📱 Selected local player: ${playerToSelect.name}');
} catch (e) {
_logger.log('⚠️ Local player not available');
} }
} }
// Priority 3: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
selectPlayer(playerToSelect); selectPlayer(playerToSelect);
} }
@@ -3973,6 +4001,7 @@ class MusicAssistantProvider with ChangeNotifier {
} else { } else {
// Builtin player is idle - clear notification (fixes issue #42) // Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState(); audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
} }
} else { } else {
// For remote players, immediately show notification if we have cached track info // For remote players, immediately show notification if we have cached track info
@@ -3994,6 +4023,7 @@ class MusicAssistantProvider with ChangeNotifier {
); );
// Use position tracker for consistent position // Use position tracker for consistent position
final position = _positionTracker.currentPosition; final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: player.state == 'playing', playing: player.state == 'playing',
@@ -4009,6 +4039,7 @@ class MusicAssistantProvider with ChangeNotifier {
artist: 'Loading...', artist: 'Loading...',
); );
final position = _positionTracker.currentPosition; final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: player.state == 'playing', playing: player.state == 'playing',
@@ -4019,6 +4050,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Player is idle - clear the notification to prevent stale metadata // Player is idle - clear the notification to prevent stale metadata
// from previous player showing (fixes issue #42) // from previous player showing (fixes issue #42)
audioHandler.clearRemotePlaybackState(); audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
} }
} }
@@ -4172,6 +4204,7 @@ class MusicAssistantProvider with ChangeNotifier {
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
); );
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: isPlaying, playing: isPlaying,
@@ -4218,16 +4251,16 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return; if (_api == null) return;
try { try {
// Log app_id for playing players to help diagnose external source detection // Log app_id and active_queue for playing players to help diagnose external source detection
if (player.state == 'playing') { if (player.state == 'playing') {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, isExternalSource=${player.isExternalSource}'); _logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"}, isExternalSource=${player.isExternalSource}');
} else { } else {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}'); _logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
} }
// Skip external sources - they're not playing MA content // Skip external sources - they're not playing MA content
if (player.isExternalSource) { if (player.isExternalSource) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId})'); _logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"})');
_cacheService.setCachedTrackForPlayer(player.playerId, null); _cacheService.setCachedTrackForPlayer(player.playerId, null);
return; return;
} }
@@ -4356,6 +4389,26 @@ class MusicAssistantProvider with ChangeNotifier {
_updatePlayerState(); _updatePlayerState();
} }
/// Start the idle service timer - stops foreground service after 30 min of no playback
/// Called when playback stops (player state becomes idle)
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_idleServiceTimer = Timer(_idleServiceTimeout, () {
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
audioHandler.stopService();
});
}
/// Cancel the idle service timer - called when playback starts
void _cancelIdleServiceTimer() {
if (_idleServiceTimer?.isActive == true) {
_logger.log('⏱️ Cancelling idle service timer (playback resumed)');
_idleServiceTimer?.cancel();
_idleServiceTimer = null;
}
}
Future<void> _updatePlayerState() async { Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return; if (_selectedPlayer == null || _api == null) return;
@@ -4418,6 +4471,7 @@ class MusicAssistantProvider with ChangeNotifier {
currentItemId: rawPlayer.currentItemId, currentItemId: rawPlayer.currentItemId,
isExternalSource: rawPlayer.isExternalSource, isExternalSource: rawPlayer.isExternalSource,
appId: rawPlayer.appId, appId: rawPlayer.appId,
activeQueue: rawPlayer.activeQueue,
); );
_selectedPlayer = updatedPlayer; _selectedPlayer = updatedPlayer;
@@ -4444,6 +4498,7 @@ class MusicAssistantProvider with ChangeNotifier {
} }
// Clear notification for external source // Clear notification for external source
audioHandler.clearRemotePlaybackState(); audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
if (stateChanged) { if (stateChanged) {
notifyListeners(); notifyListeners();
} }
@@ -4630,6 +4685,7 @@ class MusicAssistantProvider with ChangeNotifier {
); );
// Use position tracker for consistent position (single source of truth) // Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition; final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: _selectedPlayer!.state == 'playing', playing: _selectedPlayer!.state == 'playing',
@@ -4638,19 +4694,32 @@ class MusicAssistantProvider with ChangeNotifier {
); );
} }
} else { } else {
// No track data available - show player name placeholder for active players // No queue data available from API
final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId; final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
if (_currentTrack != null) { // For builtin player, try to preserve cached track data since queue API doesn't return it
// The cached track was set in selectPlayer() and contains valid data
if (isBuiltinPlayer && _currentTrack == null) {
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null) {
_currentTrack = cachedTrack;
stateChanged = true;
_logger.log('🎵 Restored cached track for builtin player: ${cachedTrack.name}');
}
}
// Only clear track if we have no cached data to fall back on
// Don't clear for builtin player - it maintains its own playback state
if (_currentTrack != null && !isBuiltinPlayer) {
_currentTrack = null; _currentTrack = null;
stateChanged = true; stateChanged = true;
// Persist cleared track state // Persist cleared track state
_persistPlaybackState(); _persistPlaybackState();
} }
// Show player name placeholder so notification shows correct player // Show player name placeholder only if we have no track data
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') { if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
final mediaItem = audio_service.MediaItem( final mediaItem = audio_service.MediaItem(
id: 'player_${_selectedPlayer!.playerId}', id: 'player_${_selectedPlayer!.playerId}',
title: _selectedPlayer!.name, title: _selectedPlayer!.name,
@@ -4664,6 +4733,7 @@ class MusicAssistantProvider with ChangeNotifier {
); );
} else { } else {
final position = _positionTracker.currentPosition; final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: _selectedPlayer!.state == 'playing', playing: _selectedPlayer!.state == 'playing',
@@ -4671,8 +4741,24 @@ class MusicAssistantProvider with ChangeNotifier {
duration: Duration.zero, duration: Duration.zero,
); );
} }
} else { } else if (_currentTrack != null && isBuiltinPlayer) {
// Builtin player has track data - update notification with it
final track = _currentTrack!;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
);
} else if (_selectedPlayer!.state != 'playing' && _selectedPlayer!.state != 'paused') {
audioHandler.clearRemotePlaybackState(); audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear(); _positionTracker.clear();
} }
} }
@@ -4764,6 +4850,7 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums; _albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists; _artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache'); _logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} }
@@ -4781,6 +4868,7 @@ class MusicAssistantProvider with ChangeNotifier {
} }
_isLoading = false; _isLoading = false;
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
// Trigger background sync (non-blocking) // Trigger background sync (non-blocking)
@@ -4807,6 +4895,7 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums; _albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists; _artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists'); _logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} }
syncService.removeListener(onSyncComplete); syncService.removeListener(onSyncComplete);
@@ -4900,7 +4989,11 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy, orderBy: orderBy,
); );
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false; _isLoading = false;
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists'); final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
@@ -4932,7 +5025,11 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy, orderBy: orderBy,
); );
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false; _isLoading = false;
_syncLibraryStatusToService();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums'); final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
@@ -5158,6 +5255,18 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
await _api?.addTracksToQueue(playerId, tracks, startIndex: startIndex);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Add tracks to queue');
_error = errorInfo.userMessage;
ErrorHandler.logError('Add tracks to queue', e);
notifyListeners();
rethrow;
}
}
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async { Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
try { try {
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue); await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
@@ -5877,6 +5986,7 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerStateReportTimer?.cancel(); _localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel(); _sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel(); _sleepTimerDisplayTimer?.cancel();
_idleServiceTimer?.cancel();
_connectionStateSubscription?.cancel(); _connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel(); _localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel(); _playerUpdatedEventSubscription?.cancel();
+384 -360
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
@@ -9,8 +11,12 @@ import '../theme/theme_provider.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../services/library_status_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/library_status_builder.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
@@ -32,15 +38,12 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState(); State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
} }
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin { class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
final _logger = DebugLogger(); final _logger = DebugLogger();
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
String? _albumDescription; String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata Album? _freshAlbum; // Full album data with image metadata
@@ -50,24 +53,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.album.favorite ?? false; // Initialize status in centralized service from widget data
_isInLibrary = widget.album.inLibrary; final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_loadTracks(); _loadTracks();
_loadAlbumDescription(); _loadAlbumDescription();
// CRITICAL FIX: Defer both fresh data loading AND color extraction until // Mark that we're on a detail screen and extract colors immediately
// after the hero animation completes. This prevents: WidgetsBinding.instance.addPostFrameCallback((_) {
// 1. setState with new image URL during animation → grey icon flash if (mounted) {
// 2. Expensive palette extraction blocking animation frames markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer fresh data loading until after hero animation completes
// This prevents setState with new image URL during animation → grey icon flash
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
_loadFreshAlbumData(); _loadFreshAlbumData();
_extractColors();
} }
}); });
}); });
@@ -90,8 +111,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (freshAlbum != null && mounted) { if (freshAlbum != null && mounted) {
setState(() { setState(() {
_freshAlbum = freshAlbum; _freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
}); });
// Sync fresh data to centralized service
final service = LibraryStatusService.instance;
service.syncSingleItem(
key: libraryItemKey,
inLibrary: freshAlbum.inLibrary,
favorite: freshAlbum.favorite ?? false,
);
// Re-extract colors now that we have fresh album with images // Re-extract colors now that we have fresh album with images
_extractColors(); _extractColors();
} }
@@ -107,15 +134,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
final colorSchemes = await PaletteHelper.extractColorSchemes( // Use isolate-based extraction to avoid blocking the main thread
CachedNetworkImageProvider(imageUrl), final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _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) { } catch (e) {
_logger.log('Failed to extract colors for album: $e'); _logger.log('Failed to extract colors for album: $e');
@@ -124,9 +154,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async { Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try { try {
final newState = !_isFavorite;
bool success; bool success;
if (newState) { if (newState) {
@@ -172,6 +206,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (libraryItemId == null) { if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal'); _logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this album'); throw Exception('Could not determine library ID for this album');
} }
@@ -182,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
if (success) { if (success) {
// Optimistically update UI regardless of online/offline state completeFavoriteOperation();
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status // Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache(); maProvider.invalidateHomeCache();
@@ -197,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text( content: Text(
isOffline isOffline
? S.of(context)!.actionQueuedForSync ? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites), : (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
} else {
rollbackFavoriteOperation();
} }
} catch (e) { } catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling favorite: $e'); _logger.log('Error toggling favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -220,10 +254,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
/// Toggle library status /// Toggle library status
Future<void> _toggleLibrary() async { Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try { try {
final newState = !_isInLibrary;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // Add to library - MUST use non-library provider
String? actualProvider; String? actualProvider;
@@ -253,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
// OPTIMISTIC UPDATE: Update UI immediately // OPTIMISTIC UPDATE via centralized service
setState(() { setLibraryStatus(newState);
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -268,20 +300,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId'); _logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background final success = await maProvider.addToLibrary(
maProvider.addToLibrary(
mediaType: 'album', mediaType: 'album',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
).catchError((e) { );
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure if (success) {
if (mounted) { completeLibraryOperation();
setState(() { } else {
_isInLibrary = !newState; rollbackLibraryOperation();
}); }
}
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -302,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return; return;
} }
// OPTIMISTIC UPDATE: Update UI immediately // OPTIMISTIC UPDATE via centralized service
setState(() { setLibraryStatus(newState);
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -316,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
); );
} }
// Fire and forget - API call happens in background final success = await maProvider.removeFromLibrary(
maProvider.removeFromLibrary(
mediaType: 'album', mediaType: 'album',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
).catchError((e) { );
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure if (success) {
if (mounted) { completeLibraryOperation();
setState(() { } else {
_isInLibrary = !newState; rollbackLibraryOperation();
}); }
}
});
} }
} catch (e) { } catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e'); _logger.log('Error toggling album library: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -444,6 +469,89 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
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> _loadTracks() async { Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final cacheKey = '${widget.album.provider}_${widget.album.itemId}'; final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
@@ -542,161 +650,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _addAlbumToQueue() { void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way GlobalPlayerOverlay.showPlayerSelectorForAction(
GlobalPlayerOverlay.hidePlayer(); contextHint: S.of(context)!.addAlbumToQueueOn,
onPlayerSelected: (player) async {
showModalBottomSheet( try {
context: context, _logger.log('Adding album to queue on ${player.name}');
backgroundColor: Colors.transparent, await maProvider.addTracksToQueue(
builder: (context) => Container( player.playerId,
decoration: BoxDecoration( _tracks,
color: Theme.of(context).colorScheme.surface, );
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), _logger.log('Album added to queue on ${player.name}');
), if (mounted) {
child: Column( ScaffoldMessenger.of(context).showSnackBar(
mainAxisSize: MainAxisSize.min, SnackBar(
children: [ content: Text(S.of(context)!.albumAddedToQueue),
Spacing.vGap16, duration: const Duration(seconds: 1),
Text(
S.of(context)!.addAlbumToQueueOn,
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 album to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
},
),
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16), );
], }
), } catch (e) {
), _logger.log('Error adding album to queue: $e');
).whenComplete(() { _showError('Failed to add album to queue: $e');
// Show mini player again when sheet closes }
GlobalPlayerOverlay.showPlayer(); },
}); );
} }
void _addTrackToQueue(BuildContext context, int index) { void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way GlobalPlayerOverlay.showPlayerSelectorForAction(
GlobalPlayerOverlay.hidePlayer(); contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
showModalBottomSheet( try {
context: context, // Add tracks from this index onwards to queue
backgroundColor: Colors.transparent, await maProvider.addTracksToQueue(
builder: (context) => Container( player.playerId,
decoration: BoxDecoration( _tracks,
color: Theme.of(context).colorScheme.surface, startIndex: index,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), );
), if (mounted) {
child: Column( ScaffoldMessenger.of(context).showSnackBar(
mainAxisSize: MainAxisSize.min, SnackBar(
children: [ content: Text(S.of(context)!.tracksAddedToQueue),
Spacing.vGap16, duration: const Duration(seconds: 1),
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 {
// Add tracks from this index onwards to queue
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');
}
},
);
},
),
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16), );
], }
), } catch (e) {
), _logger.log('Error adding to queue: $e');
).whenComplete(() { _showError('Failed to add to queue: $e');
// Slide mini player back up when sheet is dismissed }
GlobalPlayerOverlay.showPlayer(); },
}); );
} }
void _navigateToArtist() { void _navigateToArtist() {
@@ -993,7 +1000,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [ children: [
// Main Play Button // Main Play Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -1013,37 +1019,26 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// "Play on..." Button (Square) // Library Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context), onPressed: _toggleLibrary,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Icon(Icons.speaker_group_outlined), child: Icon(
), Symbols.book_2,
), size: 25,
fill: isInLibrary ? 1 : 0,
const SizedBox(width: 12), color: isInLibrary
? colorScheme.primary
// "Add Album to Queue" Button (Square) : Colors.white70,
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: const Icon(Icons.playlist_add),
), ),
), ),
@@ -1062,33 +1057,53 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
child: Icon( child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border, Icons.favorite,
color: _isFavorite size: 25,
color: isFavorite
? colorScheme.error ? colorScheme.error
: colorScheme.onSurfaceVariant, : Colors.white70,
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Library Button // Three-dot Menu Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: Builder(
onPressed: _toggleLibrary, builder: (buttonContext) => FilledButton.tonal(
style: FilledButton.styleFrom( onPressed: () {
padding: EdgeInsets.zero, if (_isLoading || _tracks.isEmpty) return;
shape: RoundedRectangleBorder( final RenderBox box = buttonContext.findRenderObject() as RenderBox;
borderRadius: BorderRadius.circular(12), final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false, // Only show list items since buttons are already visible
);
},
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,
), ),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
), ),
), ),
), ),
@@ -1122,138 +1137,78 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = _tracks[index]; final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
return Column( return GestureDetector(
mainAxisSize: MainAxisSize.min, onLongPressStart: (details) {
children: [ MediaContextMenu.show(
ListTile( context: context,
leading: Container( position: details.globalPosition,
width: 40, mediaType: ContextMenuMediaType.track,
height: 40, item: track,
decoration: BoxDecoration( isFavorite: track.favorite ?? false,
color: colorScheme.surfaceVariant, isInLibrary: track.inLibrary,
borderRadius: BorderRadius.circular(8), onToggleFavorite: () => _toggleTrackFavorite(index),
), onToggleLibrary: () => _toggleTrackLibrary(index),
child: Center( adaptiveColorScheme: adaptiveScheme,
child: Text( );
'${track.position ?? index + 1}', },
style: TextStyle( child: ListTile(
color: colorScheme.onSurfaceVariant, leading: Container(
fontWeight: FontWeight.bold, width: 40,
), height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
), ),
), ),
), ),
title: Text( ),
track.name, title: Text(
style: textTheme.bodyLarge?.copyWith( track.name,
color: colorScheme.onSurface, style: textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, color: colorScheme.onSurface,
), fontWeight: FontWeight.w500,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( maxLines: 1,
track.artistsString, overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith( ),
color: colorScheme.onSurface.withOpacity(0.6), subtitle: Text(
), track.artistsString,
maxLines: 1, style: textTheme.bodyMedium?.copyWith(
overflow: TextOverflow.ellipsis, color: colorScheme.onSurface.withOpacity(0.6),
), ),
trailing: track.duration != null maxLines: 1,
? Text( overflow: TextOverflow.ellipsis,
),
trailing: 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!), _formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
) textAlign: TextAlign.right,
: null, ),
onTap: () { ),
if (isExpanded) { ],
// Single tap to collapse when expanded
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
), ),
AnimatedSize( onTap: () => _playTrack(index),
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(),
),
],
); );
}, },
childCount: _tracks.length, childCount: _tracks.length,
@@ -1309,6 +1264,75 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
); );
} }
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
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)!.albumAddedToQueue),
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);
_addAlbumToQueue();
},
),
const SizedBox(height: 8),
],
),
),
);
}
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {
final minutes = duration.inMinutes; final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60; final seconds = duration.inSeconds % 60;
File diff suppressed because it is too large Load Diff
+251 -56
View File
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/media_context_menu.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -33,6 +36,7 @@ class AudiobookAuthorScreen extends StatefulWidget {
} }
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> { class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks; late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
@@ -116,6 +120,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>( final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -168,6 +315,49 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}, },
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
actions: [
// Three-dot menu with sort/view options
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
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: 20,
color: Colors.white70,
),
),
),
),
),
],
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Column( background: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -242,55 +432,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
), ),
), ),
// Audiobooks Section Header with controls // Audiobooks Section Header
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0), padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
child: Row( child: Text(
children: [ S.of(context)!.audiobooks,
Text( style: textTheme.titleLarge?.copyWith(
S.of(context)!.audiobooks, color: colorScheme.onSurface,
style: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold,
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),
),
],
), ),
), ),
), ),
@@ -344,10 +495,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
return InkWell( // Get adaptive scheme for context menu
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl), final themeProvider = context.read<ThemeProvider>();
child: Column( final isDark = Theme.of(context).brightness == Brightness.dark;
crossAxisAlignment: CrossAxisAlignment.start, 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: [ children: [
AspectRatio( AspectRatio(
aspectRatio: 1.0, aspectRatio: 1.0,
@@ -438,7 +610,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
),
), ),
); );
} }
@@ -450,9 +623,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
return ListTile( // Get adaptive scheme for context menu
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), final themeProvider = context.read<ThemeProvider>();
leading: Hero( 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', tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -533,7 +727,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
) )
: null, : null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl), onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
); );
} }
+157 -15
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.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 '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -35,6 +38,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isFavorite = false; bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedChapterIndex; int? _expandedChapterIndex;
@@ -46,17 +50,24 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook // Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.audiobook.favorite ?? false; _isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { if (mounted) {
if (mounted) { markDetailScreenEntered(context);
_extractColors(); _extractColors(); // Extract colors immediately - async so won't block
} }
});
// Load full audiobook details with chapters // Load full audiobook details with chapters
_loadAudiobookDetails(); _loadAudiobookDetails();
}); });
@@ -107,15 +118,18 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
final colorSchemes = await PaletteHelper.extractColorSchemes( // Use isolate-based extraction to avoid blocking the main thread
CachedNetworkImageProvider(imageUrl), final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _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) { } catch (e) {
_logger.log('Failed to extract colors for audiobook: $e'); _logger.log('Failed to extract colors for audiobook: $e');
@@ -209,6 +223,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) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -652,7 +748,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [ children: [
// Play/Resume Button // Play/Resume Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -686,26 +781,33 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Icon(Icons.replay), child: const Icon(Icons.replay, size: 25),
), ),
), ),
], ],
const SizedBox(width: 12), const SizedBox(width: 12),
// Play On Button // Library Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context), onPressed: _toggleLibrary,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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 +826,50 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border, Icons.favorite,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? 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,
),
), ),
), ),
), ),
+2 -1
View File
@@ -271,7 +271,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; 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)!; final l10n = S.of(context)!;
return Scaffold( return Scaffold(
+4 -3
View File
@@ -43,7 +43,6 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -66,11 +65,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
), ),
centerTitle: true, 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; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
+16 -11
View File
@@ -11,7 +11,6 @@ class LibraryTracksScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -34,35 +33,41 @@ class LibraryTracksScreen extends StatelessWidget {
), ),
centerTitle: true, centerTitle: true,
), ),
body: _buildTracksList(context, provider), body: _buildTracksList(context),
); );
} }
Widget _buildTracksList( Widget _buildTracksList(BuildContext context) {
BuildContext context, MusicAssistantProvider provider) { // 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; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're 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( return Center(
child: CircularProgressIndicator(color: colorScheme.primary), child: CircularProgressIndicator(color: colorScheme.primary),
); );
} }
if (provider.tracks.isEmpty) { if (tracks.isEmpty) {
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary); return EmptyState.tracks(
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
} }
return ListView.builder( return ListView.builder(
itemCount: provider.tracks.length, itemCount: tracks.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = provider.tracks[index]; final track = tracks[index] as Track;
return _buildTrackTile(context, track, provider, index); return _buildTrackTile(context, track, index);
}, },
); );
} }
@@ -70,9 +75,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile( Widget _buildTrackTile(
BuildContext context, BuildContext context,
Track track, Track track,
MusicAssistantProvider maProvider,
int index, int index,
) { ) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128) ? maProvider.getImageUrl(track.album!, size: 128)
: null; : null;
+149 -691
View File
@@ -4,7 +4,6 @@ import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../services/profile_service.dart'; import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart'; import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -19,260 +18,91 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _serverUrlController = TextEditingController(); final TextEditingController _serverUrlController = TextEditingController();
final TextEditingController _ownerNameController = TextEditingController();
final TextEditingController _portController = TextEditingController();
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final FocusNode _usernameFocusNode = FocusNode(); final FocusNode _usernameFocusNode = FocusNode();
final _logger = DebugLogger();
bool _isConnecting = false; bool _isConnecting = false;
bool _isDetectingAuth = false;
AuthStrategy? _detectedAuthStrategy;
String? _detectedAuthType;
String? _error; String? _error;
bool _showDebug = false; bool _showDebug = false;
List<String> _debugLogs = [];
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSavedSettings();
}
Future<void> _loadSavedSettings() async {
final savedPort = await SettingsService.getWebSocketPort();
final savedAuthServerUrl = await SettingsService.getAuthServerUrl();
final savedOwnerName = await SettingsService.getOwnerName();
if (savedPort != null) {
setState(() {
_portController.text = savedPort.toString();
});
}
if (savedAuthServerUrl != null) {
setState(() {
_authServerUrlController.text = savedAuthServerUrl;
});
}
if (savedOwnerName != null) {
setState(() {
_ownerNameController.text = savedOwnerName;
});
}
} }
@override @override
void dispose() { void dispose() {
_serverUrlController.dispose(); _serverUrlController.dispose();
_ownerNameController.dispose();
_portController.dispose();
_authServerUrlController.dispose();
_usernameController.dispose(); _usernameController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_usernameFocusNode.dispose(); _usernameFocusNode.dispose();
super.dispose(); super.dispose();
} }
String _normalizeServerUrl(String url) { /// Normalize server URL and add default port for local IPs
// Remove any trailing slashes String _buildServerUrl(String input) {
url = url.trim(); var url = input.trim();
// Remove trailing slashes
while (url.endsWith('/')) { while (url.endsWith('/')) {
url = url.substring(0, url.length - 1); url = url.substring(0, url.length - 1);
} }
// If URL already has a protocol, return it // Add protocol if missing
if (url.startsWith('http://') || url.startsWith('https://')) { if (!url.startsWith('http://') && !url.startsWith('https://')) {
return url; if (_isLocalAddress(url)) {
} url = 'http://$url';
} else if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
// If it's an IP address or localhost, default to http:// // Tailscale - use http since VPN is encrypted
if (url.startsWith('192.') || url = 'http://$url';
url.startsWith('10.') ||
url.startsWith('172.') ||
url == 'localhost' ||
url.startsWith('127.')) {
return 'http://$url';
}
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
return 'http://$url';
}
// For domain names, default to https://
return 'https://$url';
}
/// Check if the URL is using HTTP (for security warning display)
bool _isHttpConnection(String url) {
final normalized = _normalizeServerUrl(url);
return normalized.startsWith('http://') && !normalized.startsWith('https://');
}
/// Check if the URL is a Tailscale URL (safe for HTTP)
bool _isTailscaleUrl(String url) {
final normalized = url.trim().toLowerCase();
return normalized.contains('.ts.net');
}
String _buildServerUrl() {
// Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim());
// Parse to check if port is already included
final uri = Uri.parse(url);
// Get port from field
final portText = _portController.text.trim();
if (portText.isEmpty) {
return url; // No port specified, use URL as-is
}
final port = int.tryParse(portText);
if (port == null) {
return url; // Invalid port, use URL as-is
}
// Skip adding port if it's the default for the scheme
final isDefaultPort = (uri.scheme == 'https' && port == 443) ||
(uri.scheme == 'http' && port == 80);
if (isDefaultPort) {
return url; // Don't add default ports
}
// Build URL with custom port
return '${uri.scheme}://${uri.host}:$port';
}
void _addDebugLog(String message) {
setState(() {
_debugLogs.add('[${DateTime.now().toString().substring(11, 19)}] $message');
});
}
Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
setState(() {
_isDetectingAuth = true;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear();
});
try {
final serverUrl = _buildServerUrl();
_addDebugLog('Built server URL: $serverUrl');
final provider = context.read<MusicAssistantProvider>();
_addDebugLog('Starting auth detection...');
// Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
// Check if auth manager used a different URL (e.g., HTTP fallback)
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
if (resolvedUrl != null && resolvedUrl != serverUrl) {
_addDebugLog('URL resolved via fallback: $resolvedUrl');
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
}
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) {
setState(() {
_error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false;
});
return;
}
setState(() {
_detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false;
});
// If no auth required, connect immediately
if (strategy.name == 'none') {
await _connect();
} else { } else {
// Auth required - focus username field after a brief delay url = 'https://$url';
// to allow the new fields to build
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_usernameFocusNode.requestFocus();
}
});
} }
} catch (e) {
setState(() {
_error = 'Auth detection failed: ${e.toString()}';
_isDetectingAuth = false;
});
} }
// Add default port 8095 for local IPs without a port
final uri = Uri.parse(url);
if (!uri.hasPort && _isLocalAddress(uri.host)) {
return '${uri.scheme}://${uri.host}:8095';
}
return url;
} }
String _getAuthTypeName(String strategyName) { /// Check if address is local/private
switch (strategyName) { bool _isLocalAddress(String host) {
case 'none': return host.startsWith('192.168.') ||
return S.of(context)!.noAuthentication; host.startsWith('10.') ||
case 'basic': host.startsWith('172.16.') ||
return S.of(context)!.httpBasicAuth; host.startsWith('172.17.') ||
case 'authelia': host.startsWith('172.18.') ||
return S.of(context)!.authelia; host.startsWith('172.19.') ||
case 'music_assistant': host.startsWith('172.2') ||
return S.of(context)!.musicAssistantLogin; host.startsWith('172.30.') ||
default: host.startsWith('172.31.') ||
return 'Unknown'; host == 'localhost' ||
} host.startsWith('127.');
}
IconData _getAuthIcon(String strategyName) {
switch (strategyName) {
case 'none':
return Icons.lock_open_rounded;
case 'basic':
return Icons.vpn_key_rounded;
case 'authelia':
return Icons.shield_rounded;
case 'music_assistant':
return Icons.music_note_rounded;
default:
return Icons.security_rounded;
}
} }
Future<void> _connect() async { Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) { final serverInput = _serverUrlController.text.trim();
setState(() { final username = _usernameController.text.trim();
_error = S.of(context)!.pleaseEnterServerAddress; final password = _passwordController.text.trim();
});
// Validate inputs
if (serverInput.isEmpty) {
setState(() => _error = S.of(context)!.pleaseEnterServerAddress);
return; return;
} }
if (username.isEmpty) {
// Validate owner name - only required if NOT using MA auth setState(() => _error = S.of(context)!.pleaseEnterCredentials);
// (MA auth will get display_name from user profile after login) _usernameFocusNode.requestFocus();
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant'; return;
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) { }
setState(() { if (password.isEmpty) {
_error = S.of(context)!.pleaseEnterName; setState(() => _error = S.of(context)!.pleaseEnterCredentials);
});
return; return;
} }
@@ -282,173 +112,73 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { try {
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it final serverUrl = _buildServerUrl(serverInput);
final serverUrl = _resolvedServerUrl ?? _buildServerUrl(); _logger.log('Connecting to: $serverUrl');
_addDebugLog('Using server URL for connection: $serverUrl');
final port = _portController.text.trim();
// Validate port (if provided)
int? portNum;
if (port.isNotEmpty) {
portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() {
_error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false;
});
return;
}
}
// Save owner name (if provided) and port to settings
// For MA auth, the owner name will be set from user profile after login
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
final ownerName = _ownerNameController.text.trim();
await SettingsService.setOwnerName(ownerName);
// Create/activate profile in local database for manual name entry
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(ownerName);
}
}
await SettingsService.setWebSocketPort(portNum);
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
// Handle authentication based on detected strategy
// Note: isMaAuth is already defined earlier in this method
if (_detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none' && !isMaAuth) {
// Pre-connect auth (Authelia, Basic Auth)
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
}
// Get auth server URL (if provided for Authelia)
String? authServerUrl;
if (_detectedAuthStrategy!.name == 'authelia') {
final authUrl = _authServerUrlController.text.trim();
if (authUrl.isNotEmpty) {
authServerUrl = _normalizeServerUrl(authUrl);
}
}
// Attempt login with detected strategy
final success = await provider.authManager.login(
serverUrl,
username,
password,
_detectedAuthStrategy!,
authServerUrl: authServerUrl,
);
if (!success) {
setState(() {
_error = S.of(context)!.authFailed;
_isConnecting = false;
});
return;
}
// Save credentials for future auto-login
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Save auth server URL if provided
if (authServerUrl != null && authServerUrl.isNotEmpty) {
await SettingsService.setAuthServerUrl(authServerUrl);
} else {
await SettingsService.setAuthServerUrl(null);
}
// Save auth credentials to settings
final serialized = provider.authManager.serializeCredentials();
if (serialized != null) {
await SettingsService.setAuthCredentials(serialized);
}
}
// Connect to server // Connect to server
await provider.connectToServer(serverUrl); await provider.connectToServer(serverUrl);
// Post-connect auth (Music Assistant native auth) // Authenticate with MA
if (isMaAuth) { _logger.log('Authenticating with Music Assistant...');
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) { // Check if we have a stored MA token first
final storedToken = await SettingsService.getMaAuthToken();
bool authSuccess = false;
if (storedToken != null && provider.api != null) {
_logger.log('Trying stored MA token...');
authSuccess = await provider.api!.authenticateWithToken(storedToken);
}
if (!authSuccess) {
_logger.log('Logging in with credentials...');
final accessToken = await provider.api?.loginWithCredentials(username, password);
if (accessToken == null) {
setState(() { setState(() {
_error = S.of(context)!.pleaseEnterCredentials; _error = S.of(context)!.maLoginFailed;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
} }
_addDebugLog('Authenticating with Music Assistant...'); // Try to create a long-lived token for future use
final longLivedToken = await provider.api?.createLongLivedToken();
final tokenToStore = longLivedToken ?? accessToken;
// Check if we have a stored MA token first // Save the token for future auto-login
final storedToken = await SettingsService.getMaAuthToken(); await SettingsService.setMaAuthToken(tokenToStore);
bool authSuccess = false; _logger.log('MA token saved for future logins');
if (storedToken != null && provider.api != null) {
_addDebugLog('Trying stored MA token...');
authSuccess = await provider.api!.authenticateWithToken(storedToken);
}
if (!authSuccess) {
_addDebugLog('Logging in with credentials...');
// Login with credentials over WebSocket
final accessToken = await provider.api?.loginWithCredentials(username, password);
if (accessToken == null) {
setState(() {
_error = S.of(context)!.maLoginFailed;
_isConnecting = false;
});
return;
}
// Try to create a long-lived token for future use
final longLivedToken = await provider.api?.createLongLivedToken();
final tokenToStore = longLivedToken ?? accessToken;
// Save the token for future auto-login
await SettingsService.setMaAuthToken(tokenToStore);
_addDebugLog('MA token saved for future logins');
}
// Save credentials for future auto-login
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Save auth strategy info
await SettingsService.setAuthCredentials({
'strategy': 'music_assistant',
'data': {'username': username},
});
} }
// Wait for connection and authentication to complete // Save credentials
// MA auth happens asynchronously after connect, so we need to wait longer await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
await SettingsService.setServerUrl(serverUrl);
// Save auth credentials to settings
await SettingsService.setAuthCredentials({
'strategy': 'music_assistant',
'data': {'username': username},
});
// Set owner name from username for profile
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(username);
}
// Wait for connection to complete
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (provider.isConnected) break; if (provider.isConnected) break;
} }
if (provider.isConnected) { if (provider.isConnected) {
// For first-time users, wait for player selection so the welcome // First-time user: wait for player selection
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding(); final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) { if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) { for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break; if (provider.selectedPlayer != null) break;
@@ -457,7 +187,6 @@ class _LoginScreenState extends State<LoginScreen> {
// Navigate to home screen // Navigate to home screen
if (mounted) { if (mounted) {
// Ensure keyboard is closed before navigating
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()), MaterialPageRoute(builder: (context) => const HomeScreen()),
@@ -470,6 +199,7 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
} }
} catch (e) { } catch (e) {
_logger.log('Connection error: $e');
setState(() { setState(() {
_error = 'Connection failed: ${e.toString()}'; _error = 'Connection failed: ${e.toString()}';
_isConnecting = false; _isConnecting = false;
@@ -482,9 +212,6 @@ class _LoginScreenState extends State<LoginScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Determine if we need auth fields
final needsAuth = _detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none';
return Scaffold( return Scaffold(
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
body: SafeArea( body: SafeArea(
@@ -538,76 +265,29 @@ class _LoginScreenState extends State<LoginScreen> {
color: colorScheme.onSurface.withOpacity(0.54), color: colorScheme.onSurface.withOpacity(0.54),
), ),
), ),
enabled: !_isConnecting && !_isDetectingAuth, enabled: !_isConnecting,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Your Name - only show AFTER auth detection AND only for non-MA auth // Username
// (MA auth gets display_name from user profile automatically)
// Show for: 'none' (no auth), 'authelia', 'basic_auth', etc.
// Hide for: 'music_assistant' (gets name from profile)
if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[
Text(
S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting && !_isDetectingAuth,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
// Port
Text( Text(
S.of(context)!.portOptional, S.of(context)!.username,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 8),
Text(
S.of(context)!.portDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: _portController, controller: _usernameController,
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: S.of(context)!.portHint, hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -616,248 +296,50 @@ class _LoginScreenState extends State<LoginScreen> {
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
prefixIcon: Icon( prefixIcon: Icon(
Icons.settings_ethernet_rounded, Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54), color: colorScheme.onSurface.withOpacity(0.54),
), ),
), ),
enabled: !_isConnecting && !_isDetectingAuth, enabled: !_isConnecting,
keyboardType: TextInputType.number, textInputAction: TextInputAction.next,
textInputAction: needsAuth ? TextInputAction.next : TextInputAction.done,
onSubmitted: (_) => needsAuth ? null : _detectedAuthStrategy == null ? _detectAuthRequirements() : _connect(),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Detected auth method indicator // Password
if (_detectedAuthType != null) Text(
Container( S.of(context)!.password,
padding: const EdgeInsets.all(16), style: textTheme.titleMedium?.copyWith(
margin: const EdgeInsets.only(bottom: 24), color: colorScheme.onBackground,
decoration: BoxDecoration( fontWeight: FontWeight.w500,
color: needsAuth ),
? colorScheme.primaryContainer.withOpacity(0.3) ),
: colorScheme.tertiaryContainer.withOpacity(0.3), const SizedBox(height: 12),
TextField(
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
), ),
child: Row( prefixIcon: Icon(
children: [ Icons.lock_rounded,
Icon( color: colorScheme.onSurface.withOpacity(0.54),
_getAuthIcon(_detectedAuthStrategy!.name),
color: needsAuth ? colorScheme.primary : colorScheme.tertiary,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle(
color: needsAuth
? colorScheme.onPrimaryContainer
: colorScheme.onTertiaryContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (needsAuth)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
S.of(context)!.serverRequiresAuth,
style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
), ),
), ),
obscureText: true,
enabled: !_isConnecting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connect(),
),
// HTTP connection info banner const SizedBox(height: 32),
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondaryContainer.withOpacity(0.3)
: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: _isTailscaleUrl(_serverUrlController.text)
? null
: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
_isTailscaleUrl(_serverUrlController.text)
? Icons.vpn_lock_rounded
: Icons.warning_amber_rounded,
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondary
: colorScheme.error,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.tailscaleVpnConnection
: S.of(context)!.unencryptedConnection,
style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback
? S.of(context)!.httpsFailedUsingHttp
: S.of(context)!.httpNotEncrypted,
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer).withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Authentication fields (shown only if auth required)
if (needsAuth) ...[
// Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[
Text(
S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.authServerUrlDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
TextField(
controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.shield_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
Text(
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _usernameController,
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
Text(
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.lock_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
obscureText: true,
enabled: !_isConnecting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 16),
],
const SizedBox(height: 16),
// Error message // Error message
if (_error != null) if (_error != null)
@@ -893,16 +375,14 @@ class _LoginScreenState extends State<LoginScreen> {
if (_showDebug) ...[ if (_showDebug) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
DebugConsole( DebugConsole(
onClear: () => setState(() => _debugLogs.clear()), onClear: () {},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Connect button (changes based on state) // Connect button
ElevatedButton( ElevatedButton(
onPressed: (_isConnecting || _isDetectingAuth) onPressed: _isConnecting ? null : _connect,
? null
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
@@ -912,44 +392,22 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
elevation: 0, elevation: 0,
), ),
child: _isDetectingAuth child: _isConnecting
? Row( ? SizedBox(
mainAxisAlignment: MainAxisAlignment.center, height: 20,
children: [ width: 20,
SizedBox( child: CircularProgressIndicator(
height: 20, strokeWidth: 2,
width: 20, valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
child: CircularProgressIndicator( ),
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
),
const SizedBox(width: 12),
Text(
S.of(context)!.detectingAuth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
) )
: _isConnecting : Text(
? SizedBox( S.of(context)!.connect,
height: 20, style: const TextStyle(
width: 20, fontSize: 16,
child: CircularProgressIndicator( fontWeight: FontWeight.bold,
strokeWidth: 2, ),
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary), ),
),
)
: Text(
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
), ),
], ],
), ),
File diff suppressed because it is too large Load Diff
+423 -336
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -10,6 +11,8 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
@@ -41,9 +44,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false; bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -55,15 +58,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.playlist.favorite ?? false; _isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
_loadTracks(); _loadTracks();
// Defer color extraction until after hero animation completes // Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { if (mounted) {
if (mounted) { markDetailScreenEntered(context);
_extractColors(); _extractColors(); // Extract colors immediately - async so won't block
} }
});
}); });
} }
@@ -74,15 +77,18 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
final colorSchemes = await PaletteHelper.extractColorSchemes( // Use isolate-based extraction to avoid blocking the main thread
CachedNetworkImageProvider(imageUrl), final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _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) { } catch (e) {
_logger.log('Failed to extract colors for playlist: $e'); _logger.log('Failed to extract colors for playlist: $e');
@@ -227,6 +233,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 { Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return; if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -324,6 +410,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 { Future<void> _playPlaylist() async {
if (_tracks.isEmpty) return; if (_tracks.isEmpty) return;
@@ -387,158 +556,130 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
); );
} }
void _addPlaylistToQueue() { void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: colorScheme.surface,
builder: (context) => Container( shape: const RoundedRectangleBorder(
decoration: BoxDecoration( borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
color: Theme.of(context).colorScheme.surface, ),
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), builder: (context) => SafeArea(
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Spacing.vGap16, const SizedBox(height: 8),
Text( Container(
S.of(context)!.addToQueueOn, width: 32,
style: Theme.of(context).textTheme.titleLarge, height: 4,
), decoration: BoxDecoration(
Spacing.vGap16, color: colorScheme.onSurfaceVariant.withOpacity(0.4),
if (players.isEmpty) borderRadius: BorderRadius.circular(2),
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');
}
},
);
},
),
), ),
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) { void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
showModalBottomSheet( onPlayerSelected: (player) async {
context: context, try {
backgroundColor: Colors.transparent, await maProvider.addTracksToQueue(
builder: (context) => Container( player.playerId,
decoration: BoxDecoration( _tracks,
color: Theme.of(context).colorScheme.surface, startIndex: index,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), );
), if (mounted) {
child: Column( ScaffoldMessenger.of(context).showSnackBar(
mainAxisSize: MainAxisSize.min, SnackBar(
children: [ content: Text(S.of(context)!.tracksAddedToQueue),
Spacing.vGap16, duration: const Duration(seconds: 1),
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');
}
},
);
},
),
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16), );
], }
), } catch (e) {
), _logger.log('Error adding to queue: $e');
).whenComplete(() { _showError('Failed to add to queue: $e');
GlobalPlayerOverlay.showPlayer(); }
}); },
);
} }
void _showPlayRadioMenu(BuildContext context, int trackIndex) { void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -782,7 +923,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [ children: [
// Main Play Button // Main Play Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -802,42 +942,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
const SizedBox(width: 12), 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 // Favorite Button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -851,10 +955,53 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
), ),
child: Icon( child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border, Icons.favorite,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? 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 +1035,108 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = _tracks[index]; final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80); final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return Column( return GestureDetector(
mainAxisSize: MainAxisSize.min, onLongPressStart: (details) {
children: [ MediaContextMenu.show(
ListTile( context: context,
leading: Row( position: details.globalPosition,
mainAxisSize: MainAxisSize.min, mediaType: ContextMenuMediaType.track,
children: [ item: track,
// Track number isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: 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,
),
),
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(
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: 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( SizedBox(
width: 28, width: 40,
child: Text( child: Text(
'${index + 1}', _formatDuration(track.duration!),
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
textAlign: TextAlign.right, 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(
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: 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 onTap: () => _playTrack(index),
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(),
),
],
); );
}, },
childCount: _tracks.length, childCount: _tracks.length,
+374 -192
View File
@@ -1,16 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.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 '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget { class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast; final MediaItem podcast;
@@ -36,7 +43,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false; bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isInLibrary = 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}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,16 +52,109 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast); _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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { if (mounted) {
if (mounted) { markDetailScreenEntered(context);
_extractColors(); _extractColors(); // Extract colors immediately - async so won't block
} }
});
_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 /// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) { bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true; if (podcast.provider == 'library') return true;
@@ -186,6 +287,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 { Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation // Use initialImageUrl first for consistency with hero animation
@@ -193,14 +359,17 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) { if (imageUrl != null) {
try { try {
final colorSchemes = await PaletteHelper.extractColorSchemes( // Use isolate-based extraction to avoid blocking the main thread
CachedNetworkImageProvider(imageUrl), final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
);
if (mounted && colorSchemes != null) { if (mounted && colorSchemes != null) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _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) { } catch (e) {
_logger.log('🎙️ Error extracting colors: $e'); _logger.log('🎙️ Error extracting colors: $e');
@@ -261,6 +430,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
setState(() { setState(() {
_episodes = episodes; _episodes = episodes;
_sortEpisodes();
}); });
} }
} }
@@ -406,6 +576,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation // CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -569,7 +768,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [ children: [
// Main Play Latest Episode Button // Main Play Latest Episode Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -591,24 +789,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
const SizedBox(width: 12), 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 // Library button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -622,10 +802,76 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add, Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary color: _isInLibrary
? colorScheme.primary ? 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 +978,126 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index]; final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?; final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration; final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256); final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata // Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata); final releaseDate = _formatReleaseDate(episode.metadata);
return Column( return GestureDetector(
children: [ onTap: () => _playEpisode(episode),
InkWell( onLongPressStart: (details) => _showEpisodeContextMenu(
onTap: () => _playEpisode(episode), context,
onLongPress: () { details.globalPosition,
setState(() { episode,
_expandedEpisodeId = isExpanded ? null : episodeId; episodeImageUrl,
}); ),
}, child: Padding(
child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(
child: Row( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ // Episode cover
// Episode cover (bigger than before) Hero(
ClipRRect( tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
borderRadius: BorderRadius.circular(8), child: ClipRRect(
child: Container( borderRadius: BorderRadius.circular(8),
width: 72, child: Container(
height: 72, width: 72,
color: colorScheme.surfaceContainerHighest, height: 72,
child: episodeImageUrl != null color: colorScheme.surfaceContainerHighest,
? CachedNetworkImage( child: episodeImageUrl != null
imageUrl: episodeImageUrl, ? CachedNetworkImage(
fit: BoxFit.cover, imageUrl: episodeImageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, fadeInDuration: Duration.zero,
placeholder: (_, __) => Icon( fadeOutDuration: Duration.zero,
MdiIcons.podcast, placeholder: (_, __) => Icon(
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast, MdiIcons.podcast,
size: 32, size: 32,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), errorWidget: (_, __, ___) => Icon(
), MdiIcons.podcast,
const SizedBox(width: 12), size: 32,
// Episode info color: colorScheme.onSurfaceVariant,
Expanded( ),
child: Column( )
crossAxisAlignment: CrossAxisAlignment.start, : Icon(
children: [ MdiIcons.podcast,
Text( size: 32,
episode.name, color: colorScheme.onSurfaceVariant,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
), ),
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( Text(
episodeDescription, _formatDuration(duration),
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.5),
), ),
), ),
], ],
const SizedBox(height: 6), if (duration != null && duration > Duration.zero && releaseDate != null)
// Duration and release date row Padding(
Row( padding: const EdgeInsets.symmetric(horizontal: 8),
children: [ child: Text(
if (duration != null && duration > Duration.zero) ...[ '•',
Icon( style: textTheme.bodySmall?.copyWith(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
const SizedBox(width: 4), ),
Text( ),
_formatDuration(duration), if (releaseDate != null)
style: textTheme.bodySmall?.copyWith( Text(
color: colorScheme.onSurface.withOpacity(0.5), releaseDate,
), style: textTheme.bodySmall?.copyWith(
), color: colorScheme.onSurface.withOpacity(0.5),
], ),
if (duration != null && duration > Duration.zero && releaseDate != null) ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
], ],
), ),
), ],
], ),
), ),
), ],
), ),
// Expanded action buttons (matching album track style) ),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
); );
}, },
childCount: _episodes.length, childCount: _episodes.length,
@@ -0,0 +1,504 @@
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 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.all(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),
),
],
);
},
),
),
);
}
}
+6 -5
View File
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // Use select to only rebuild when selectedPlayer changes
final player = maProvider.selectedPlayer; final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF1a1a1a), backgroundColor: const Color(0xFF1a1a1a),
@@ -153,7 +153,7 @@ class _QueueScreenState extends State<QueueScreen> {
IconButton( IconButton(
icon: const Icon(Icons.swap_horiz_rounded), icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue, tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider), onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()),
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh), 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) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
File diff suppressed because it is too large Load Diff
+11 -69
View File
@@ -7,6 +7,7 @@ import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart'; import 'debug_log_screen.dart';
import 'login_screen.dart'; import 'login_screen.dart';
@@ -39,9 +40,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order // Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder); List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings // Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false; bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true; bool _volumePrecisionMode = true;
// Hint settings // Hint settings
bool _showHints = true; bool _showHints = true;
@@ -85,9 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder(); final rowOrder = await SettingsService.getHomeRowOrder();
// Load player settings // Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers(); final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode(); final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings // Load hint settings
@@ -111,9 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder; _homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort; _smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision; _volumePrecisionMode = volumePrecision;
_showHints = showHints; _showHints = showHints;
_showProviderIcons = showProviderIcons; _showProviderIcons = showProviderIcons;
@@ -267,7 +262,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { 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 colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -295,9 +291,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( FadeSlidePageRoute(child: const DebugLogScreen()),
builder: (context) => const DebugLogScreen(),
),
); );
}, },
color: colorScheme.onBackground, color: colorScheme.onBackground,
@@ -345,15 +339,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
_getStatusIcon(provider.connectionState), _getStatusIcon(connectionState),
color: _getStatusColor(provider.connectionState, colorScheme), color: _getStatusColor(connectionState, colorScheme),
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
_getStatusText(provider.connectionState), _getStatusText(connectionState),
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(provider.connectionState, colorScheme), color: _getStatusColor(connectionState, colorScheme),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -703,33 +697,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -754,7 +721,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -763,36 +730,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
child: SwitchListTile( child: SwitchListTile(
title: Text( title: Text(
S.of(context)!.disableAutoSwitch, S.of(context)!.volumePrecisionMode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
S.of(context)!.disableAutoSwitchDescription, S.of(context)!.volumePrecisionModeDescription,
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',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
value: _volumePrecisionMode, value: _volumePrecisionMode,
@@ -157,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Note: We don't actually stop playback - this button is repurposed for player switching // Note: We don't actually stop playback - this button is repurposed for player switching
} }
/// Fully stop the foreground service and release resources
/// Called after idle timeout to save battery
Future<void> stopService() async {
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
_isRemoteMode = false;
_currentMediaItem = null;
await _player.stop();
// Call the base stop() to properly stop the foreground service
await super.stop();
}
@override @override
Future<void> seek(Duration position) => _player.seek(position); Future<void> seek(Duration position) => _player.seek(position);
+126 -408
View File
@@ -1,468 +1,186 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; 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 '../debug_logger.dart';
/// Central authentication manager /// Simple authentication manager for Music Assistant
/// Manages auth strategy selection, auto-detection, and credential lifecycle /// MA now uses its own built-in authentication system
class AuthManager { class AuthManager {
final _logger = DebugLogger(); final _logger = DebugLogger();
// Available auth strategies String? _accessToken;
final List<AuthStrategy> _strategies = [ String? _longLivedToken;
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
AuthStrategy? _currentStrategy; /// Get the best available token (prefer long-lived)
AuthCredentials? _currentCredentials; String? get token => _longLivedToken ?? _accessToken;
/// Get current auth strategy (null if none selected) /// Check if we have valid credentials
AuthStrategy? get currentStrategy => _currentStrategy; bool get hasCredentials => token != null;
/// Get current credentials (null if none stored) /// Login to Music Assistant and get an access token
AuthCredentials? get currentCredentials => _currentCredentials; Future<String?> login(String serverUrl, String username, String password) async {
/// 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
String _normalizeUrl(String url) {
var baseUrl = url;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
baseUrl = 'https://$baseUrl';
}
}
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
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)
try { try {
_logger.log('Probing server for auth headers...'); final apiUrl = _buildApiUrl(serverUrl);
// Test root endpoint _logger.log('🔐 Logging in to Music Assistant at $apiUrl');
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final response = await http.get( final response = await http.post(
Uri.parse(probeUrl), apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10)); ).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}'); _logger.log('Auth response 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';
}
if (response.statusCode == 200) { if (response.statusCode == 200) {
// Try to parse as JSON final data = jsonDecode(response.body) as Map<String, dynamic>;
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check if we got server info successfully (no auth required) if (data.containsKey('error_code')) {
if (data.containsKey('result')) { _logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
final result = data['result'] as Map<String, dynamic>?; return null;
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');
} }
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; return null;
} catch (e) { } catch (e) {
_logger.log('MA auth check error: $e'); _logger.log('✗ Login error: $e');
return null; return null;
} }
} }
/// Test if server is accessible without authentication /// Create a long-lived token for persistent authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async { Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try { try {
final uri = Uri.parse(serverUrl); _logger.log('Creating long-lived token...');
_logger.log('No-auth test URL: $serverUrl');
final isLocalIp = _isLocalIpAddress(uri.host); final response = await http.post(
int statusCode; 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) { if (response.statusCode == 200) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues final data = jsonDecode(response.body) as Map<String, dynamic>;
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS if (!data.containsKey('error_code')) {
if (statusCode == 308 || statusCode == 301 || statusCode == 302) { final result = data['result'] as Map<String, dynamic>?;
final location = response.headers.value('location'); final token = result?['token'] as String?;
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location'); if (token != null) {
client.close(); _logger.log('✓ Created long-lived token');
return false; // Can't connect - would need HTTPS which has cert issues 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 /// Validate a stored token
// 302/307/308 = redirect (probably to auth or HTTPS) Future<bool> validateToken(String serverUrl, String storedToken) async {
// 401 = auth required try {
if (statusCode == 200) { final apiUrl = _buildApiUrl(serverUrl);
return true;
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; return false;
} catch (e) { } catch (e) {
_logger.log('No-auth error: $e'); _logger.log('Token validation failed: $e');
return false; return false;
} }
} }
/// Attempt login with specified strategy /// Build headers for WebSocket connection
/// 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
Map<String, dynamic> getWebSocketHeaders() { Map<String, dynamic> getWebSocketHeaders() {
if (_currentStrategy == null || _currentCredentials == null) { if (token != null) {
return {}; return {'Authorization': 'Bearer $token'};
} }
return {};
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
} }
/// Get HTTP streaming headers for current credentials /// Build headers for HTTP streaming requests
Map<String, String> getStreamingHeaders() { Map<String, String> getStreamingHeaders() {
if (_currentStrategy == null || _currentCredentials == null) { if (token != null) {
return {}; return {'Authorization': 'Bearer $token'};
} }
return {};
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
} }
/// Serialize current credentials for persistent storage /// Set token from external source (e.g., restored from settings)
Map<String, dynamic>? serializeCredentials() { void setToken(String? storedToken) {
if (_currentStrategy == null || _currentCredentials == null) { _longLivedToken = storedToken;
return null;
}
return {
'strategy': _currentStrategy!.name,
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
};
} }
/// Deserialize and restore credentials from persistent storage /// Clear authentication state
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
void logout() { void logout() {
_currentStrategy = null; _accessToken = null;
_currentCredentials = null; _longLivedToken = null;
_logger.log('Logged out - cleared auth state'); _logger.log('Logged out - cleared auth state');
} }
/// Get strategy by name (for manual selection) /// Build API URL from server URL
AuthStrategy? getStrategyByName(String name) { Uri _buildApiUrl(String serverUrl) {
try { var baseUrl = serverUrl;
return _strategies.firstWhere((s) => s.name == name); if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
} catch (e) { baseUrl = 'http://$baseUrl';
return null;
} }
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', {});
}
}
+27
View File
@@ -193,6 +193,33 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]); return db.markItemsDeleted(itemType, [itemId]);
} }
/// Mark a cached item as deleted by searching for library ID in the data
/// This handles cases where itemId is different from library ID
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
// First try direct match (for provider=library items)
await db.markItemsDeleted(itemType, [libraryId]);
// Also search through cached items to find any with matching library mapping
final items = await db.getCachedItems(itemType);
for (final item in items) {
try {
// Check if this item's data contains a library mapping with this ID
if (item.data.contains('"provider_instance":"library"') &&
item.data.contains('"item_id":"$libraryId"')) {
// Found a match - extract the actual cache key's itemId
final cacheKey = item.cacheKey;
final parts = cacheKey.split('_');
if (parts.length >= 2) {
final actualItemId = parts.sublist(1).join('_');
await db.markItemsDeleted(itemType, [actualItemId]);
}
}
} catch (_) {
// Ignore parse errors, continue checking other items
}
}
}
/// Batch cache multiple items efficiently /// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets. /// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500. /// Items are automatically chunked into batches of 500.
+1 -1
View File
@@ -12,7 +12,7 @@ class HardwareVolumeService {
factory HardwareVolumeService() => _instance; factory HardwareVolumeService() => _instance;
HardwareVolumeService._internal(); HardwareVolumeService._internal();
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons'); static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
final _logger = DebugLogger(); final _logger = DebugLogger();
final _volumeUpController = StreamController<void>.broadcast(); final _volumeUpController = StreamController<void>.broadcast();
+177
View File
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Centralized service for tracking library and favorite status of media items.
///
/// This service implements the "single source of truth" pattern:
/// - All widgets query this service instead of maintaining local state
/// - Status changes notify all listeners immediately (optimistic updates)
/// - API failures trigger rollback notifications
///
/// Usage:
/// ```dart
/// // Check status
/// final isInLibrary = LibraryStatusService.instance.isInLibrary(itemKey);
///
/// // Listen to changes
/// LibraryStatusService.instance.addListener(() {
/// // Rebuild UI
/// });
///
/// // Update status (optimistically)
/// LibraryStatusService.instance.setLibraryStatus(itemKey, true);
/// ```
class LibraryStatusService extends ChangeNotifier {
LibraryStatusService._();
static final LibraryStatusService instance = LibraryStatusService._();
// Status maps: itemKey -> status
// itemKey format: "mediaType:provider:itemId" for uniqueness
final Map<String, bool> _libraryStatus = {};
final Map<String, bool> _favoriteStatus = {};
// Pending operations for rollback on failure
final Map<String, bool> _pendingLibraryOps = {};
final Map<String, bool> _pendingFavoriteOps = {};
/// Generate a unique key for an item
static String makeKey(String mediaType, String provider, String itemId) {
return '$mediaType:$provider:$itemId';
}
/// Check if item is in library
bool isInLibrary(String key) => _libraryStatus[key] ?? false;
/// Check if item is favorite
bool isFavorite(String key) => _favoriteStatus[key] ?? false;
/// Set library status (optimistic update)
/// Call this immediately when user taps the button
void setLibraryStatus(String key, bool inLibrary) {
if (_libraryStatus[key] == inLibrary) return;
_libraryStatus[key] = inLibrary;
notifyListeners();
}
/// Set favorite status (optimistic update)
void setFavoriteStatus(String key, bool favorite) {
if (_favoriteStatus[key] == favorite) return;
_favoriteStatus[key] = favorite;
notifyListeners();
}
/// Mark operation as pending (for rollback tracking)
void markLibraryPending(String key, bool previousValue) {
_pendingLibraryOps[key] = previousValue;
}
void markFavoritePending(String key, bool previousValue) {
_pendingFavoriteOps[key] = previousValue;
}
/// Complete operation successfully (clear pending)
void completeLibraryOperation(String key) {
_pendingLibraryOps.remove(key);
}
void completeFavoriteOperation(String key) {
_pendingFavoriteOps.remove(key);
}
/// Rollback operation on failure
void rollbackLibraryOperation(String key) {
final previousValue = _pendingLibraryOps.remove(key);
if (previousValue != null) {
_libraryStatus[key] = previousValue;
notifyListeners();
}
}
void rollbackFavoriteOperation(String key) {
final previousValue = _pendingFavoriteOps.remove(key);
if (previousValue != null) {
_favoriteStatus[key] = previousValue;
notifyListeners();
}
}
/// Bulk update from provider's library data
/// Called when library is loaded/refreshed
void syncFromLibrary({
required String mediaType,
required List<dynamic> items,
required bool Function(dynamic item) getInLibrary,
required bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId,
}) {
bool changed = false;
for (final item in items) {
final key = makeKey(mediaType, getProvider(item), getItemId(item));
final inLibrary = getInLibrary(item);
final favorite = getFavorite(item);
// Only update if not pending (don't overwrite optimistic updates)
if (!_pendingLibraryOps.containsKey(key)) {
if (_libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
}
if (!_pendingFavoriteOps.containsKey(key)) {
if (_favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
}
}
if (changed) {
notifyListeners();
}
}
/// Update a single item's status from fresh data
void syncSingleItem({
required String key,
required bool inLibrary,
required bool favorite,
}) {
bool changed = false;
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
if (changed) {
notifyListeners();
}
}
/// Clear all status (on logout/disconnect)
void clear() {
_libraryStatus.clear();
_favoriteStatus.clear();
_pendingLibraryOps.clear();
_pendingFavoriteOps.clear();
notifyListeners();
}
/// Get debug info
Map<String, dynamic> get debugInfo => {
'libraryCount': _libraryStatus.length,
'favoriteCount': _favoriteStatus.length,
'pendingLibrary': _pendingLibraryOps.length,
'pendingFavorite': _pendingFavoriteOps.length,
};
}
+43 -1
View File
@@ -809,6 +809,15 @@ class MusicAssistantAPI {
} }
} }
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API'); _logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
// If browse returned nothing, the saved library paths may be stale
// (e.g., after provider was removed and re-added with new instance ID)
// Clear the stale settings so we fall back to library_items API
if (browseBooks.isEmpty) {
_logger.log('📚 Browse returned 0 books - clearing stale library settings');
await SettingsService.clearEnabledAbsLibraries();
await SettingsService.setDiscoveredAbsLibraries([]);
}
} }
} }
@@ -826,11 +835,13 @@ class MusicAssistantAPI {
// Only call library_items API if: // Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR // 1. No ABS libraries configured (traditional mode), OR
// 2. We have specific non-ABS providers to query // 2. We have specific non-ABS providers to query, OR
// 3. ABS browse returned 0 items (stale paths were cleared above)
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist, // NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
// as library_items API would return all audiobooks bypassing the library filter // as library_items API would return all audiobooks bypassing the library filter
final shouldFetchFromApi = enabledLibraries == null || final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty || enabledLibraries.isEmpty ||
allAudiobooks.isEmpty || // Fall back if browse got nothing
(nonAbsProviders != null && nonAbsProviders.isNotEmpty); (nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) { if (shouldFetchFromApi) {
@@ -2065,6 +2076,37 @@ class MusicAssistantAPI {
} }
} }
/// Add multiple tracks to queue without interrupting playback
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
final tracksToAdd = startIndex > 0 ? tracks.sublist(startIndex) : tracks;
// Filter to only tracks with available provider mappings
final playableTracks = tracksToAdd.where((track) {
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
return false;
}
return track.providerMappings!.any((m) => m.available);
}).toList();
if (playableTracks.isEmpty) return;
final uris = playableTracks.map((track) => _buildTrackUri(track)).toList();
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': uris,
'option': 'add', // Add to queue without starting playback
},
);
} catch (e) {
_logger.log('Error adding tracks to queue: $e');
rethrow;
}
}
/// Play album on player /// Play album on player
Future<void> playAlbum(String playerId, Album album) async { Future<void> playAlbum(String playerId, Album album) async {
try { try {
+3 -2
View File
@@ -154,7 +154,8 @@ class SendspinService {
} }
/// Connect with a specific WebSocket URL (called by provider with local_ws_url) /// Connect with a specific WebSocket URL (called by provider with local_ws_url)
Future<bool> connectWithUrl(String wsUrl) async { /// If useProxyAuth is true, will authenticate with MA token (needed for proxy connections)
Future<bool> connectWithUrl(String wsUrl, {bool useProxyAuth = false}) async {
if (_isDisposed) return false; if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true; if (_state == SendspinConnectionState.connected) return true;
@@ -173,7 +174,7 @@ class SendspinService {
_logger.log('Sendspin: Connecting with URL: $wsUrl'); _logger.log('Sendspin: Connecting with URL: $wsUrl');
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5)); final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5), useProxyAuth: useProxyAuth);
if (!connected) { if (!connected) {
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
+12 -10
View File
@@ -90,9 +90,7 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name'; static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name'; static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id'; static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players'; static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
static const String _keyShowRecentAlbums = 'show_recent_albums'; static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists'; static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums'; static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -152,6 +150,7 @@ class SettingsService {
static const String _keyLibrarySeriesSortOrder = 'library_series_sort_order'; // 'alpha', 'alpha_desc', 'books' static const String _keyLibrarySeriesSortOrder = 'library_series_sort_order'; // 'alpha', 'alpha_desc', 'books'
static const String _keyLibraryRadioSortOrder = 'library_radio_sort_order'; // 'alpha', 'alpha_desc' static const String _keyLibraryRadioSortOrder = 'library_radio_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyLibraryPodcastsSortOrder = 'library_podcasts_sort_order'; // 'alpha', 'alpha_desc' static const String _keyLibraryPodcastsSortOrder = 'library_podcasts_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyPodcastEpisodesSortOrder = 'podcast_episodes_sort_order'; // 'newest', 'oldest', 'alpha', 'duration'
// Audiobookshelf Direct Integration Settings // Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url'; static const String _keyAbsServerUrl = 'abs_server_url';
@@ -354,18 +353,10 @@ class SettingsService {
static Future<void> setLastSelectedPlayerId(String? playerId) => static Future<void> setLastSelectedPlayerId(String? playerId) =>
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true); _setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically // Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false); static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort); static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
// Disable Auto-Switch - prevents automatic switching to playing players
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
// Helper to create player name with possessive apostrophe // Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size // Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) { static String _makePlayerName(String ownerName) {
@@ -692,6 +683,17 @@ class SettingsService {
await prefs.setString(_keyLibraryPodcastsSortOrder, order); await prefs.setString(_keyLibraryPodcastsSortOrder, order);
} }
// Podcast Episode Sort Order (within podcast detail screen)
static Future<String> getPodcastEpisodesSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyPodcastEpisodesSortOrder) ?? 'newest';
}
static Future<void> setPodcastEpisodesSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyPodcastEpisodesSortOrder, order);
}
// Audiobookshelf Settings // Audiobookshelf Settings
static Future<String?> getAbsServerUrl() async { static Future<String?> getAbsServerUrl() async {
final prefs = await _getPrefs(); final prefs = await _getPrefs();
+95 -3
View File
@@ -505,13 +505,11 @@ class SyncService with ChangeNotifier {
/// Empty enabledProviderIds = all providers enabled, show everything /// Empty enabledProviderIds = all providers enabled, show everything
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering /// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) { List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
// Empty set = all providers enabled, show everything
if (enabledProviderIds.isEmpty) { if (enabledProviderIds.isEmpty) {
return _cachedAlbums; return _cachedAlbums;
} }
return _cachedAlbums.where((album) { return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId]; final sources = _albumSourceProviders[album.itemId];
// STRICT MODE: Hide items without tracking to ensure accurate filtering
if (sources == null || sources.isEmpty) return false; if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s)); return sources.any((s) => enabledProviderIds.contains(s));
}).toList(); }).toList();
@@ -524,7 +522,6 @@ class SyncService with ChangeNotifier {
} }
return _cachedArtists.where((artist) { return _cachedArtists.where((artist) {
final sources = _artistSourceProviders[artist.itemId]; final sources = _artistSourceProviders[artist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false; if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s)); return sources.any((s) => enabledProviderIds.contains(s));
}).toList(); }).toList();
@@ -578,4 +575,99 @@ class SyncService with ChangeNotifier {
/// Check if we have data available (from cache or sync) /// Check if we have data available (from cache or sync)
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty || bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty; _cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
/// Update cached albums with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedAlbums(List<Album> albums) {
_cachedAlbums = albums;
notifyListeners();
}
/// Update cached artists with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedArtists(List<Artist> artists) {
_cachedArtists = artists;
notifyListeners();
}
/// Update cached playlists with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
void updateCachedPlaylists(List<Playlist> playlists) {
_cachedPlaylists = playlists;
notifyListeners();
}
/// Remove an item from in-memory cache by library ID
/// Called when an item is removed from library to keep SyncService in sync
void removeFromCacheByLibraryId(String mediaType, int libraryItemId) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
// Helper to check if item matches the library ID being removed
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
if (provider == 'library' && itemId == libraryIdStr) return true;
if (mappings != null) {
for (final m in mappings) {
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
m.itemId == libraryIdStr) {
return true;
}
}
}
return false;
}
switch (mediaType) {
case 'album':
final before = _cachedAlbums.length;
_cachedAlbums = _cachedAlbums.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAlbums.length != before;
// Also remove from source provider tracking
if (updated) {
_albumSourceProviders.removeWhere((key, _) =>
_cachedAlbums.every((a) => a.itemId != key));
}
break;
case 'artist':
final before = _cachedArtists.length;
_cachedArtists = _cachedArtists.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedArtists.length != before;
if (updated) {
_artistSourceProviders.removeWhere((key, _) =>
_cachedArtists.every((a) => a.itemId != key));
}
break;
case 'audiobook':
final before = _cachedAudiobooks.length;
_cachedAudiobooks = _cachedAudiobooks.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAudiobooks.length != before;
if (updated) {
_audiobookSourceProviders.removeWhere((key, _) =>
_cachedAudiobooks.every((a) => a.itemId != key));
}
break;
case 'playlist':
final before = _cachedPlaylists.length;
_cachedPlaylists = _cachedPlaylists.where((p) =>
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _cachedPlaylists.length != before;
if (updated) {
_playlistSourceProviders.removeWhere((key, _) =>
_cachedPlaylists.every((p) => p.itemId != key));
}
break;
}
if (updated) {
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from SyncService cache');
notifyListeners();
}
}
} }
+2 -2
View File
@@ -68,7 +68,7 @@ class AppTheme {
foregroundColor: scheme.onBackground, foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground), iconTheme: IconThemeData(color: scheme.onBackground),
), ),
cardTheme: CardTheme( cardTheme: CardThemeData(
color: scheme.surface, color: scheme.surface,
elevation: 2, elevation: 2,
), ),
@@ -91,7 +91,7 @@ class AppTheme {
foregroundColor: scheme.onBackground, foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground), iconTheme: IconThemeData(color: scheme.onBackground),
), ),
cardTheme: CardTheme( cardTheme: CardThemeData(
color: scheme.surface, color: scheme.surface,
elevation: 2, elevation: 2,
), ),
+470 -49
View File
@@ -1,9 +1,25 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:image/image.dart' as img;
import 'package:http/http.dart' as http;
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
final _logger = DebugLogger(); final _logger = DebugLogger();
/// Minimum contrast ratio for UI components (WCAG 2.1 Level AA)
const double _minContrastRatio = 3.0;
/// Minimum chroma for primary colors to ensure they're visually distinct
/// Material Design 3 uses 48 for vibrant, but we use 36 as a balance
const double _minPrimaryChroma = 36.0;
/// Minimum chroma for surface/container colors (lower is fine for backgrounds)
const double _minSurfaceChroma = 6.0;
/// Extracted adaptive colors from album art /// Extracted adaptive colors from album art
class AdaptiveColors { class AdaptiveColors {
final Color primary; // Main accent color (vibrant) final Color primary; // Main accent color (vibrant)
@@ -26,15 +42,398 @@ class AdaptiveColors {
); );
} }
/// Helper to get contrasting text color based on background luminance /// Data class to pass color extraction results from isolate
/// (Color objects can't be passed directly between isolates)
class _ExtractedColors {
final int vibrantColor;
final int lightVibrantColor;
final int dominantColor;
final int darkMutedColor;
final int mutedColor;
final int lightMutedColor;
const _ExtractedColors({
required this.vibrantColor,
required this.lightVibrantColor,
required this.dominantColor,
required this.darkMutedColor,
required this.mutedColor,
required this.lightMutedColor,
});
}
/// Calculate WCAG 2.1 contrast ratio between two colors
double _contrastRatio(Color c1, Color c2) {
final l1 = c1.computeLuminance();
final l2 = c2.computeLuminance();
final lighter = math.max(l1, l2);
final darker = math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/// Helper to get contrasting text color based on WCAG contrast ratio
Color getContrastingTextColor(Color backgroundColor) { Color getContrastingTextColor(Color backgroundColor) {
// Use relative luminance to determine if we need light or dark text // Calculate contrast with both black and white, pick the one with better contrast
// Standard threshold is 0.179 based on WCAG guidelines final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white; final contrastWithBlack = _contrastRatio(backgroundColor, Colors.black);
return contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
}
/// Adjust a color's tone using HCT to ensure minimum contrast against a background
/// Uses binary search to find the MINIMUM tone adjustment needed (preserves color identity)
Color _ensureContrastHct(Color foreground, Color background, {double minRatio = _minContrastRatio}) {
final fgHct = Hct.fromInt(foreground.value);
final bgHct = Hct.fromInt(background.value);
// Ensure foreground has enough chroma to be visually distinct
final chroma = math.max(fgHct.chroma, _minPrimaryChroma);
final currentRatio = _contrastRatio(foreground, background);
if (currentRatio >= minRatio) {
// Already has sufficient contrast, just ensure chroma
if (fgHct.chroma < _minPrimaryChroma) {
return Color(Hct.from(fgHct.hue, chroma, fgHct.tone).toInt());
}
return foreground;
}
// For dark backgrounds (tone < 50), we need lighter foreground
// For light backgrounds (tone >= 50), we need darker foreground
final bgIsDark = bgHct.tone < 50;
// Binary search for the minimum tone that achieves target contrast
double lo, hi;
if (bgIsDark) {
// Search upward from current tone toward white
lo = fgHct.tone;
hi = 100.0;
} else {
// Search downward from current tone toward black
lo = 0.0;
hi = fgHct.tone;
}
double bestTone = bgIsDark ? hi : lo; // Fallback to extreme if needed
for (int i = 0; i < 20; i++) {
final mid = (lo + hi) / 2;
final testColor = Hct.from(fgHct.hue, chroma, mid);
final testRatio = _contrastRatio(Color(testColor.toInt()), background);
if (bgIsDark) {
// We want lighter - if mid works, try lower (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
hi = mid; // Try to find a lower tone that still works
} else {
lo = mid; // Need to go lighter
}
} else {
// We want darker - if mid works, try higher (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
lo = mid; // Try to find a higher tone that still works
} else {
hi = mid; // Need to go darker
}
}
if (hi - lo < 0.5) break; // Precision reached
}
return Color(Hct.from(fgHct.hue, chroma, bestTone).toInt());
}
/// Ensure a color has minimum chroma (colorfulness) for primary use
Color _ensureMinChroma(Color color) {
final hct = Hct.fromInt(color.value);
if (hct.chroma >= _minPrimaryChroma) {
return color;
}
final adjusted = Hct.from(hct.hue, _minPrimaryChroma, hct.tone);
return Color(adjusted.toInt());
}
/// Top-level function for isolate-based color extraction
/// Must be top-level or static to work with compute()
_ExtractedColors? _extractColorsInIsolate(Uint8List imageBytes) {
try {
// Decode image using pure Dart image package
final image = img.decodeImage(imageBytes);
if (image == null) return null;
// Resize for faster processing (max 100x100)
final resized = img.copyResize(
image,
width: image.width > 100 ? 100 : image.width,
height: image.height > 100 ? 100 : image.height,
interpolation: img.Interpolation.average,
);
// Extract color histogram
final colorCounts = <int, int>{};
for (int y = 0; y < resized.height; y++) {
for (int x = 0; x < resized.width; x++) {
final pixel = resized.getPixel(x, y);
// Quantize to reduce color space (shift off lower 4 bits)
final r = (pixel.r.toInt() >> 4) << 4;
final g = (pixel.g.toInt() >> 4) << 4;
final b = (pixel.b.toInt() >> 4) << 4;
final quantized = (0xFF << 24) | (r << 16) | (g << 8) | b;
colorCounts[quantized] = (colorCounts[quantized] ?? 0) + 1;
}
}
// Sort colors by frequency
final sortedColors = colorCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (sortedColors.isEmpty) return null;
// Categorize colors by HSL properties
int? vibrant, lightVibrant, dominant, darkMuted, muted, lightMuted;
for (final entry in sortedColors.take(32)) {
final color = entry.key;
final r = (color >> 16) & 0xFF;
final g = (color >> 8) & 0xFF;
final b = color & 0xFF;
// Calculate HSL manually
final rf = r / 255.0;
final gf = g / 255.0;
final bf = b / 255.0;
final maxC = [rf, gf, bf].reduce((a, b) => a > b ? a : b);
final minC = [rf, gf, bf].reduce((a, b) => a < b ? a : b);
final lightness = (maxC + minC) / 2;
final saturation = maxC == minC ? 0.0 :
(lightness > 0.5
? (maxC - minC) / (2 - maxC - minC)
: (maxC - minC) / (maxC + minC));
// Categorize based on saturation and lightness
if (saturation > 0.5 && lightness > 0.3 && lightness < 0.7) {
vibrant ??= color;
}
if (saturation > 0.4 && lightness > 0.6) {
lightVibrant ??= color;
}
if (saturation < 0.4 && lightness < 0.3) {
darkMuted ??= color;
}
if (saturation < 0.4 && lightness > 0.3 && lightness < 0.7) {
muted ??= color;
}
if (saturation < 0.4 && lightness > 0.7) {
lightMuted ??= color;
}
// First color is dominant
dominant ??= color;
}
// Fallback to dominant for any missing colors
final defaultColor = dominant ?? sortedColors.first.key;
return _ExtractedColors(
vibrantColor: vibrant ?? defaultColor,
lightVibrantColor: lightVibrant ?? vibrant ?? defaultColor,
dominantColor: dominant ?? defaultColor,
darkMutedColor: darkMuted ?? defaultColor,
mutedColor: muted ?? defaultColor,
lightMutedColor: lightMuted ?? muted ?? defaultColor,
);
} catch (e) {
return null;
}
} }
class PaletteHelper { class PaletteHelper {
/// Cache for downloaded image bytes to avoid re-downloading
static final Map<String, Uint8List> _imageCache = {};
static const int _maxCacheSize = 20;
/// Cache for extracted color schemes to avoid re-processing
static final Map<String, (ColorScheme, ColorScheme)> _colorSchemeCache = {};
static const int _maxColorSchemeCacheSize = 30;
/// Extract color schemes from a URL using isolate-based processing
/// This is the preferred method as it doesn't block the main thread
/// Results are cached for instant retrieval on subsequent calls
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
try {
// Check color scheme cache first for instant return
final cachedSchemes = _colorSchemeCache[imageUrl];
if (cachedSchemes != null) {
return cachedSchemes;
}
// Check image bytes cache
Uint8List? imageBytes = _imageCache[imageUrl];
if (imageBytes == null) {
// Download image bytes
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode != 200) {
_logger.warning('Failed to download image: ${response.statusCode}', context: 'Palette');
return null;
}
imageBytes = response.bodyBytes;
// Cache the bytes
if (_imageCache.length >= _maxCacheSize) {
_imageCache.remove(_imageCache.keys.first);
}
_imageCache[imageUrl] = imageBytes;
}
// Extract colors in isolate
final extractedColors = await compute(_extractColorsInIsolate, imageBytes);
if (extractedColors == null) return null;
// Convert to ColorSchemes on main thread
final colorSchemes = _buildColorSchemes(extractedColors);
// Cache the color schemes for instant access next time
if (_colorSchemeCache.length >= _maxColorSchemeCacheSize) {
_colorSchemeCache.remove(_colorSchemeCache.keys.first);
}
_colorSchemeCache[imageUrl] = colorSchemes;
return colorSchemes;
} catch (e) {
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
return null;
}
}
/// Build ColorSchemes from extracted color data using HCT color space
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
// Convert int colors to Color objects
final vibrant = Color(colors.vibrantColor);
final lightVibrant = Color(colors.lightVibrantColor);
final dominant = Color(colors.dominantColor);
final darkMuted = Color(colors.darkMutedColor);
final muted = Color(colors.mutedColor);
final lightMuted = Color(colors.lightMutedColor);
// Get primary color - prefer vibrant, then lightVibrant, then dominant
Color primary = vibrant;
final vibrantHct = Hct.fromInt(vibrant.value);
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
primary = lightVibrant;
}
final primaryHct = Hct.fromInt(primary.value);
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
primary = dominant;
}
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
// Build dark mode colors using HCT
final darkMutedHct = Hct.fromInt(darkMuted.value);
final darkSurface = Color(Hct.from(
darkMutedHct.hue,
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
).toInt());
final darkMiniPlayer = Color(Hct.from(
darkMutedHct.hue,
(darkMutedHct.chroma * 1.2).clamp(4, 16),
30, // Fixed tone for consistency
).toInt());
// Build light mode colors using HCT
final lightMutedHct = Hct.fromInt(lightMuted.value);
final lightSurface = Color(Hct.from(
lightMutedHct.hue,
math.min(lightMutedHct.chroma, _minSurfaceChroma),
95, // Very light
).toInt());
final lightMiniPlayer = Color(Hct.from(
lightMutedHct.hue,
(lightMutedHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
// Generate secondary container colors using HCT
// Dark secondary container at tone 25 for tonal buttons
final primaryHctFinal = Hct.fromInt(adjustedPrimary.value);
final darkSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
final lightSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
// This guarantees highlighted icons are visible on tonal buttons
final darkAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
final lightAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary,
secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
error: Colors.red,
onError: Colors.white,
surface: lightSurface,
onSurface: Colors.black87,
primaryContainer: lightMiniPlayer,
onPrimaryContainer: lightOnMiniPlayer,
);
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary,
secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
error: Colors.redAccent,
onError: Colors.black,
surface: darkSurface,
onSurface: Colors.white,
primaryContainer: darkMiniPlayer,
onPrimaryContainer: darkOnMiniPlayer,
);
return (lightScheme, darkScheme);
}
/// Clear all caches (image bytes and color schemes)
static void clearCache() {
_imageCache.clear();
_colorSchemeCache.clear();
}
/// Extract a color palette from an image with higher color count for better variety /// Extract a color palette from an image with higher color count for better variety
/// NOTE: This runs on the main thread. Prefer extractColorSchemesFromUrl for better performance.
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async { static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
try { try {
final palette = await PaletteGenerator.fromImageProvider( final palette = await PaletteGenerator.fromImageProvider(
@@ -48,7 +447,7 @@ class PaletteHelper {
} }
} }
/// Extract actual colors from the palette (not seed-based) /// Extract actual colors from the palette using HCT color space
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) { static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
if (palette == null) return null; if (palette == null) return null;
@@ -58,22 +457,9 @@ class PaletteHelper {
palette.dominantColor?.color ?? palette.dominantColor?.color ??
const Color(0xFF604CEC); const Color(0xFF604CEC);
// Ensure primary has enough saturation to be visually distinct // Use HCT for perceptually accurate color adjustment
// AND enough lightness for white text to be readable on it // Ensure minimum chroma (colorfulness) so colors aren't muddy
final HSLColor hslPrimary = HSLColor.fromColor(primary); Color adjustedPrimary = _ensureMinChroma(primary);
Color adjustedPrimary = primary;
// Adjust saturation if too low
if (hslPrimary.saturation < 0.3) {
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
}
// Ensure minimum lightness for contrast with white text
// If primary is too dark (luminance < 0.15), lighten it
if (adjustedPrimary.computeLuminance() < 0.15) {
final hsl = HSLColor.fromColor(adjustedPrimary);
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
}
if (isDark) { if (isDark) {
// Dark mode: use dark muted colors for background // Dark mode: use dark muted colors for background
@@ -81,18 +467,19 @@ class PaletteHelper {
palette.mutedColor?.color ?? palette.mutedColor?.color ??
const Color(0xFF121212); const Color(0xFF121212);
// Darken the surface color significantly for the expanded background // Build surface colors using HCT
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase); final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = hslSurface final Color surface = Color(Hct.from(
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15)) surfaceHct.hue,
.toColor(); math.min(surfaceHct.chroma, _minSurfaceChroma),
math.max(surfaceHct.tone * 0.3, 5.0).clamp(5.0, 15.0),
).toInt());
// Mini player should be medium brightness - noticeably tinted but readable final Color miniPlayer = Color(Hct.from(
// Use the muted color but at medium lightness (0.25-0.35 range) surfaceHct.hue,
final Color miniPlayer = hslSurface (surfaceHct.chroma * 1.2).clamp(4, 16),
.withLightness(0.3.clamp(0.25, 0.38)) 30,
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5)) ).toInt());
.toColor();
return AdaptiveColors( return AdaptiveColors(
primary: adjustedPrimary, primary: adjustedPrimary,
@@ -106,16 +493,18 @@ class PaletteHelper {
palette.mutedColor?.color ?? palette.mutedColor?.color ??
Colors.white; Colors.white;
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase); final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = hslSurface final Color surface = Color(Hct.from(
.withLightness((hslSurface.lightness).clamp(0.92, 0.98)) surfaceHct.hue,
.toColor(); math.min(surfaceHct.chroma, _minSurfaceChroma),
95,
).toInt());
// Mini player in light mode - medium tinted final Color miniPlayer = Color(Hct.from(
final Color miniPlayer = hslSurface surfaceHct.hue,
.withLightness(0.75.clamp(0.65, 0.8)) (surfaceHct.chroma * 1.2).clamp(4, 16),
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5)) 75,
.toColor(); ).toInt());
return AdaptiveColors( return AdaptiveColors(
primary: adjustedPrimary, primary: adjustedPrimary,
@@ -126,7 +515,7 @@ class PaletteHelper {
} }
} }
/// Generate color schemes from a palette (kept for backward compatibility) /// Generate color schemes from a palette using HCT color space
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) { static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
if (palette == null) return null; if (palette == null) return null;
@@ -135,18 +524,47 @@ class PaletteHelper {
if (lightColors == null || darkColors == null) return null; if (lightColors == null || darkColors == null) return null;
// Determine contrasting text colors based on primary color luminance // Generate secondary container colors using HCT
final lightOnPrimary = getContrastingTextColor(lightColors.primary); final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
final darkOnPrimary = getContrastingTextColor(darkColors.primary); final lightSecondaryContainer = Color(Hct.from(
lightPrimaryHct.hue,
(lightPrimaryHct.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
final darkPrimaryHct = Hct.fromInt(darkColors.primary.value);
final darkSecondaryContainer = Color(Hct.from(
darkPrimaryHct.hue,
(darkPrimaryHct.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
final lightAdjustedPrimary = _ensureContrastHct(
lightColors.primary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
final darkAdjustedPrimary = _ensureContrastHct(
darkColors.primary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer); final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer); final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
final lightScheme = ColorScheme( final lightScheme = ColorScheme(
brightness: Brightness.light, brightness: Brightness.light,
primary: lightColors.primary, primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary, onPrimary: lightOnPrimary,
secondary: lightColors.primary.withOpacity(0.8), secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary, onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
error: Colors.red, error: Colors.red,
onError: Colors.white, onError: Colors.white,
surface: lightColors.surface, surface: lightColors.surface,
@@ -157,10 +575,12 @@ class PaletteHelper {
final darkScheme = ColorScheme( final darkScheme = ColorScheme(
brightness: Brightness.dark, brightness: Brightness.dark,
primary: darkColors.primary, primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary, onPrimary: darkOnPrimary,
secondary: darkColors.primary.withOpacity(0.8), secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary, onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
error: Colors.redAccent, error: Colors.redAccent,
onError: Colors.black, onError: Colors.black,
surface: darkColors.surface, surface: darkColors.surface,
@@ -173,6 +593,7 @@ class PaletteHelper {
} }
/// Extract color schemes from an image provider in one call /// Extract color schemes from an image provider in one call
/// NOTE: This runs on the main thread. Prefer extractColorSchemesFromUrl for better performance.
static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async { static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async {
final palette = await extractPalette(imageProvider); final palette = await extractPalette(imageProvider);
return generateColorSchemes(palette); return generateColorSchemes(palette);
+18 -2
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import 'palette_helper.dart'; import 'palette_helper.dart';
@@ -10,11 +9,13 @@ final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL /// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist) /// This can be called from anywhere in the app (e.g., when tapping an album/artist)
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors /// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
/// Uses isolate-based extraction to avoid blocking the main thread
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async { Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return; if (imageUrl == null || imageUrl.isEmpty) return;
try { try {
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl)); // Use isolate-based extraction for better performance
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && context.mounted) { if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>(); final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true); themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
@@ -32,6 +33,13 @@ void clearAdaptiveColorsOnBack(BuildContext context) {
themeProvider.clearAdaptiveColors(); themeProvider.clearAdaptiveColors();
} }
/// Mark that we're entering a detail screen (call this synchronously on init)
/// This ensures the nav bar starts using adaptive colors immediately
void markDetailScreenEntered(BuildContext context) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.setOnDetailScreen(true);
}
class ThemeProvider extends ChangeNotifier { class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
bool _useMaterialTheme = false; bool _useMaterialTheme = false;
@@ -203,4 +211,12 @@ class ThemeProvider extends ChangeNotifier {
_isOnDetailScreen = false; _isOnDetailScreen = false;
notifyListeners(); notifyListeners();
} }
/// Set the detail screen flag (used for immediate synchronous updates)
void setOnDetailScreen(bool value) {
if (_isOnDetailScreen != value) {
_isOnDetailScreen = value;
notifyListeners();
}
}
} }
+211 -3
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -10,7 +11,11 @@ import '../constants/timings.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart'; import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AlbumCard extends StatefulWidget { class AlbumCard extends StatefulWidget {
final Album album; final Album album;
@@ -32,7 +37,7 @@ class AlbumCard extends StatefulWidget {
State<AlbumCard> createState() => _AlbumCardState(); State<AlbumCard> createState() => _AlbumCardState();
} }
class _AlbumCardState extends State<AlbumCard> { class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
String? _fallbackImageUrl; String? _fallbackImageUrl;
bool _triedFallback = false; bool _triedFallback = false;
bool _maImageFailed = false; bool _maImageFailed = false;
@@ -44,9 +49,25 @@ class _AlbumCardState extends State<AlbumCard> {
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll /// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400); static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_initFallbackImage(); _initFallbackImage();
} }
@@ -102,6 +123,176 @@ class _AlbumCardState extends State<AlbumCard> {
} }
} }
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.album.provider;
String actualItemId = widget.album.itemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
final mapping = widget.album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.album.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.album.provider != 'library') {
actualProvider = widget.album.provider;
actualItemId = widget.album.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -125,8 +316,13 @@ class _AlbumCardState extends State<AlbumCard> {
if (_isNavigating) return; if (_isNavigating) return;
_isNavigating = true; _isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState // Start color extraction immediately (fire-and-forget)
// to avoid competing with Hero animation for GPU resources // Runs in isolate so won't block Hero animation
// Results are cached, so detail screen gets them instantly
if (imageUrl != null) {
updateAdaptiveColorsFromImage(context, imageUrl);
}
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
@@ -143,6 +339,18 @@ class _AlbumCardState extends State<AlbumCard> {
}); });
}); });
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -105,7 +106,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (_albums.isEmpty) { if (_albums.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No albums found', S.of(context)!.noAlbumsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
+204 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -7,10 +8,13 @@ import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart'; import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class ArtistCard extends StatefulWidget { class ArtistCard extends StatefulWidget {
final Artist artist; final Artist artist;
@@ -32,7 +36,7 @@ class ArtistCard extends StatefulWidget {
State<ArtistCard> createState() => _ArtistCardState(); State<ArtistCard> createState() => _ArtistCardState();
} }
class _ArtistCardState extends State<ArtistCard> { class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
static final _logger = DebugLogger(); static final _logger = DebugLogger();
String? _fallbackImageUrl; String? _fallbackImageUrl;
bool _triedFallback = false; bool _triedFallback = false;
@@ -45,9 +49,25 @@ class _ArtistCardState extends State<ArtistCard> {
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll /// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400); static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Fetch fallback image once in initState, not during build // Fetch fallback image once in initState, not during build
_initFallbackImage(); _initFallbackImage();
} }
@@ -94,6 +114,176 @@ class _ArtistCardState extends State<ArtistCard> {
} }
} }
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
String actualItemId = widget.artist.itemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
final mapping = widget.artist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.artist.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.artist.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.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -135,6 +325,18 @@ class _ArtistCardState extends State<ArtistCard> {
}); });
}); });
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -105,7 +106,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
if (_artists.isEmpty) { if (_artists.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No artists found', S.of(context)!.noArtistsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
+226 -14
View File
@@ -1,14 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart'; import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart'; import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AudiobookRow extends StatefulWidget { class AudiobookRow extends StatefulWidget {
final String title; final String title;
@@ -94,7 +99,6 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
context, context,
).catchError((_) { ).catchError((_) {
// Silently ignore precache errors // Silently ignore precache errors
return false;
}); });
} }
} }
@@ -111,7 +115,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
if (_audiobooks.isEmpty) { if (_audiobooks.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No audiobooks found', S.of(context)!.noAudiobooks,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
@@ -189,7 +193,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
} }
} }
class _AudiobookCard extends StatelessWidget { class _AudiobookCard extends StatefulWidget {
final Audiobook audiobook; final Audiobook audiobook;
final String heroTagSuffix; final String heroTagSuffix;
final MusicAssistantProvider maProvider; final MusicAssistantProvider maProvider;
@@ -200,11 +204,207 @@ class _AudiobookCard extends StatelessWidget {
required this.maProvider, required this.maProvider,
}); });
@override
State<_AudiobookCard> createState() => _AudiobookCardState();
}
class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'audiobook',
widget.audiobook.provider,
widget.audiobook.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
String actualItemId = widget.audiobook.itemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final mapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.audiobook.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.audiobook.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} 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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
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;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final imageUrl = maProvider.getImageUrl(audiobook, size: 256); final imageUrl = widget.maProvider.getImageUrl(widget.audiobook, size: 256);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
@@ -212,13 +412,25 @@ class _AudiobookCard extends StatelessWidget {
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
child: AudiobookDetailScreen( child: AudiobookDetailScreen(
audiobook: audiobook, audiobook: widget.audiobook,
heroTagSuffix: heroTagSuffix, heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); );
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -226,7 +438,7 @@ class _AudiobookCard extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Hero( Hero(
tag: HeroTags.audiobookCover + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix', tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
@@ -268,7 +480,7 @@ class _AudiobookCard extends StatelessWidget {
), ),
), ),
// Progress indicator // Progress indicator
if (audiobook.progress > 0) if (widget.audiobook.progress > 0)
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
@@ -279,7 +491,7 @@ class _AudiobookCard extends StatelessWidget {
bottomRight: Radius.circular(8), bottomRight: Radius.circular(8),
), ),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: audiobook.progress, value: widget.audiobook.progress,
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary), valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 4, minHeight: 4,
@@ -287,20 +499,20 @@ class _AudiobookCard extends StatelessWidget {
), ),
), ),
// Provider icon overlay // Provider icon overlay
if (audiobook.providerMappings?.isNotEmpty == true) if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay( ProviderIconOverlay(
domain: audiobook.providerMappings!.first.providerDomain, domain: widget.audiobook.providerMappings!.first.providerDomain,
), ),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Hero( Hero(
tag: HeroTags.audiobookTitle + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix', tag: HeroTags.audiobookTitle + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Text( child: Text(
audiobook.name, widget.audiobook.name,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -310,7 +522,7 @@ class _AudiobookCard extends StatelessWidget {
), ),
), ),
Text( Text(
audiobook.authorsString, widget.audiobook.authorsString,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
+143 -27
View File
@@ -121,6 +121,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription; StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0); final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null); final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
bool _isSeeking = false; // Debounce flag to prevent rapid-fire seeks
// Pre-computed static colors to avoid object creation during animation frames // Pre-computed static colors to avoid object creation during animation frames
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3) static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
@@ -2860,33 +2861,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
children: [ children: [
SizedBox( SizedBox(
height: 48, // Increase touch target height height: 48, // Increase touch target height
// PERF Phase 1: Use cached SliderThemeData child: _buildProgressSlider(
child: SliderTheme( context,
data: _sliderTheme, currentProgress,
child: Slider( currentTrack.duration!.inSeconds.toDouble(),
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(), primaryColor,
max: currentTrack.duration!.inSeconds.toDouble(), primaryColor20,
onChanged: (value) => _seekPositionNotifier.value = value, maProvider,
onChangeStart: (value) => _seekPositionNotifier.value = value, selectedPlayer.playerId,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor20, // PERF: Use cached color
),
), ),
), ),
Padding( Padding(
@@ -4132,6 +4114,140 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
); );
} }
/// Custom progress slider that doesn't require Overlay widget
/// (Flutter 3.38's Material 3 Slider uses OverlayPortal which fails in our overlay context)
Widget _buildProgressSlider(
BuildContext context,
double currentProgress,
double maxProgress,
Color activeColor,
Color inactiveColor,
MusicAssistantProvider maProvider,
String playerId,
) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final progress = maxProgress > 0 ? (currentProgress / maxProgress).clamp(0.0, 1.0) : 0.0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragUpdate: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragCancel: () {
_seekPositionNotifier.value = null;
},
onHorizontalDragEnd: (details) async {
final seekPosition = _seekPositionNotifier.value;
if (seekPosition != null && !_isSeeking) {
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
}
},
onTapUp: (details) async {
if (_isSeeking) return; // Ignore if already seeking
// Calculate position from tap location
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
final seekPosition = newProgress * maxProgress;
_seekPositionNotifier.value = seekPosition;
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
child: SizedBox(
height: 48,
child: Center(
child: SizedBox(
height: 20,
child: Stack(
clipBehavior: Clip.none,
children: [
// Inactive track (full width, thinner)
Positioned(
left: 0,
right: 0,
top: 8.5,
child: Container(
height: 3,
decoration: BoxDecoration(
color: inactiveColor,
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: width * progress,
height: 6,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (width * progress) - 7,
top: 3,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
),
),
),
],
),
),
),
),
);
},
);
}
} }
/// Represents a single line of synced lyrics with timestamp /// Represents a single line of synced lyrics with timestamp
+8 -6
View File
@@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
@@ -530,10 +531,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
child: widget.child, child: widget.child,
), ),
// Global persistent bottom navigation bar - positioned at bottom // Global persistent bottom navigation bar - positioned at bottom
// Hide when: not connected, no player selected, OR showing welcome screen // Hide when: not connected OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time // Always show when connected so users can access Settings/Library even without a player
// as shouldShowWelcomeBackdrop becomes true, preventing any gap. if (isConnected && !shouldShowWelcomeBackdrop)
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -612,6 +612,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
GlobalPlayerOverlay.collapsePlayer(); GlobalPlayerOverlay.collapsePlayer();
} }
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst); navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) { if (index == 3) {
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) { } else if (navigationProvider.selectedIndex == 3) {
@@ -633,8 +635,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
label: S.of(context)!.home, label: S.of(context)!.home,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined), icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Icons.library_music_rounded), activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library, label: S.of(context)!.library,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import '../models/media_item.dart';
/// Badge widget that displays a hi-res audio indicator for tracks
/// with high quality audio (lossless codec + sample rate > 48kHz OR bit depth > 16)
class HiResBadge extends StatelessWidget {
final String? tooltip;
final Color? primaryColor;
const HiResBadge({super.key, this.tooltip, this.primaryColor});
/// Lossless audio codecs that qualify for hi-res badge
static const _losslessCodecs = [
'flac',
'wav',
'aiff',
'alac',
'dsf',
'wavpack',
'ape',
'tak',
];
/// Check if track qualifies for hi-res badge, returns tooltip text if so
static String? getTooltip(Track track) {
// Find first available provider mapping with audio format info
final mapping =
track.providerMappings?.cast<ProviderMapping?>().firstWhere(
(m) => m != null && m.available && m.audioFormat != null,
orElse: () => null,
);
final audioFormat = mapping?.audioFormat;
if (audioFormat == null) return null;
final contentType =
(audioFormat['content_type'] as String?)?.toLowerCase();
final sampleRate = audioFormat['sample_rate'] as int?;
final bitDepth = audioFormat['bit_depth'] as int?;
// Must be a lossless codec
if (contentType == null) return null;
final isLossless =
_losslessCodecs.any((codec) => contentType.contains(codec));
if (!isLossless) return null;
// Must be hi-res: sample_rate > 48000 OR bit_depth > 16
final effectiveSampleRate = sampleRate ?? 44100;
final effectiveBitDepth = bitDepth ?? 16;
if (effectiveSampleRate <= 48000 && effectiveBitDepth <= 16) {
return null;
}
return '${effectiveSampleRate ~/ 1000}kHz $effectiveBitDepth-bit';
}
/// Returns HiResBadge if track qualifies for hi-res, null otherwise
static Widget? fromTrack(Track track, {Color? primaryColor}) {
final tooltipText = getTooltip(track);
if (tooltipText == null) return null;
return HiResBadge(tooltip: tooltipText, primaryColor: primaryColor);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Use adaptive primary color muted with white, fallback to theme primary
final baseColor = primaryColor ?? colorScheme.primary;
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
return Tooltip(
message: tooltip ?? 'Hi-Res Audio',
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Hi-Res',
style: TextStyle(
color: color,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.1,
),
),
Container(
margin: const EdgeInsets.only(top: 1),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'AUDIO',
style: TextStyle(
color: colorScheme.surface,
fontSize: 8,
fontWeight: FontWeight.bold,
height: 1.1,
),
),
),
],
),
),
);
}
}
+101 -29
View File
@@ -1,10 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation /// Display mode for the scrollbar popup indicator
/// through alphabetically sorted lists. enum ScrollbarDisplayMode {
/// Show first letter of item name (A, B, C, #)
letter,
/// Show year (2024, 2023, etc.)
year,
/// Show count number
count,
/// Hide the popup (just show scrollbar thumb)
none,
}
/// A scrollbar that shows a contextual popup when dragging, for fast navigation
/// through sorted lists.
/// ///
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter /// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows contextual info
/// section you're currently in via a popup indicator. /// about the current position via a popup indicator. The popup content adapts
/// based on the display mode (letters for alphabetical, years for date sorts,
/// counts for play count sorts).
class LetterScrollbar extends StatefulWidget { class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.) /// The scrollable child widget (ListView, GridView, etc.)
final Widget child; final Widget child;
@@ -16,6 +30,14 @@ class LetterScrollbar extends StatefulWidget {
/// Each item's first character is used to determine the letter shown in the popup. /// Each item's first character is used to determine the letter shown in the popup.
final List<String> items; final List<String> items;
/// Display mode for the popup indicator
final ScrollbarDisplayMode displayMode;
/// Custom display labels for each item (optional).
/// When provided, these are shown in the popup instead of extracting letters.
/// Length must match [items] if provided.
final List<String>? displayLabels;
/// Callback when user taps/drags to a specific index. /// Callback when user taps/drags to a specific index.
final void Function(int index)? onScrollToIndex; final void Function(int index)? onScrollToIndex;
@@ -45,6 +67,8 @@ class LetterScrollbar extends StatefulWidget {
required this.child, required this.child,
required this.controller, required this.controller,
required this.items, required this.items,
this.displayMode = ScrollbarDisplayMode.letter,
this.displayLabels,
this.onScrollToIndex, this.onScrollToIndex,
this.onDragStateChanged, this.onDragStateChanged,
this.itemExtent, this.itemExtent,
@@ -85,7 +109,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
if ((newFraction - _scrollFraction).abs() > 0.01) { if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() { setState(() {
_scrollFraction = newFraction; _scrollFraction = newFraction;
_currentLetter = _getLetterAtFraction(newFraction); _currentLetter = _getLabelAtFraction(newFraction);
}); });
} }
} }
@@ -102,15 +126,32 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
return '#'; return '#';
} }
/// Get the letter at a given scroll fraction (0.0 to 1.0) /// Get the display label at a given scroll fraction (0.0 to 1.0)
String _getLetterAtFraction(double fraction) { String _getLabelAtFraction(double fraction) {
if (widget.items.isEmpty) return ''; if (widget.items.isEmpty) return '';
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1); final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
// Use custom display labels if provided
if (widget.displayLabels != null && index < widget.displayLabels!.length) {
return widget.displayLabels![index];
}
// Otherwise extract from items based on display mode
if (index < widget.items.length) { if (index < widget.items.length) {
final item = widget.items[index]; final item = widget.items[index];
if (item.isNotEmpty) { if (item.isNotEmpty) {
return _normalizeToLetter(item[0]); switch (widget.displayMode) {
case ScrollbarDisplayMode.letter:
return _normalizeToLetter(item[0]);
case ScrollbarDisplayMode.year:
case ScrollbarDisplayMode.count:
// For year/count modes, the item itself should be the display value
return item;
case ScrollbarDisplayMode.none:
// No popup shown, return empty
return '';
}
} }
} }
return ''; return '';
@@ -154,7 +195,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final fraction = clampedPosition / effectiveHeight; final fraction = clampedPosition / effectiveHeight;
// Get letter for display // Get letter for display
final letter = _getLetterAtFraction(fraction); final letter = _getLabelAtFraction(fraction);
// Scroll proportionally // Scroll proportionally
final maxScroll = widget.controller.position.maxScrollExtent; final maxScroll = widget.controller.position.maxScrollExtent;
@@ -170,6 +211,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
widget.controller.jumpTo(targetScroll); widget.controller.jumpTo(targetScroll);
} }
/// Build the popup bubble with appropriate size and styling based on display mode
Widget _buildPopupBubble(ColorScheme colorScheme) {
// Determine size and font based on content length and display mode
final double width;
final double height;
final double fontSize;
switch (widget.displayMode) {
case ScrollbarDisplayMode.letter:
case ScrollbarDisplayMode.none: // Fallback (none won't actually reach here)
width = 56;
height = 56;
fontSize = 28;
break;
case ScrollbarDisplayMode.year:
width = 72;
height = 48;
fontSize = 22;
break;
case ScrollbarDisplayMode.count:
// Dynamic width based on count length
final labelLength = _currentLetter.length;
width = labelLength <= 2 ? 56 : (labelLength <= 4 ? 72 : 88);
height = 48;
fontSize = labelLength <= 3 ? 22 : 18;
break;
}
return Material(
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: width,
height: height,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -236,32 +325,15 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
), ),
), ),
// The letter popup bubble // The popup bubble (letter, year, or count) - hidden when displayMode is none
if (_isDragging && _currentLetter.isNotEmpty) if (_isDragging && _currentLetter.isNotEmpty && widget.displayMode != ScrollbarDisplayMode.none)
Positioned( Positioned(
right: 48, right: 48,
top: (_dragPosition - 28).clamp( top: (_dragPosition - 28).clamp(
0.0, 0.0,
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding, (context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
), ),
child: Material( child: _buildPopupBubble(colorScheme),
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
), ),
], ],
); );
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../services/library_status_service.dart';
/// A widget that rebuilds when the library/favorite status of an item changes.
///
/// This widget listens to the centralized [LibraryStatusService] and only
/// rebuilds when the status of the specified item changes.
///
/// Example:
/// ```dart
/// LibraryStatusBuilder(
/// itemKey: LibraryStatusService.makeKey('album', album.provider, album.itemId),
/// builder: (context, isInLibrary, isFavorite) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.library_add_check : Icons.library_add),
/// onPressed: () => toggleLibrary(),
/// );
/// },
/// )
/// ```
class LibraryStatusBuilder extends StatefulWidget {
final String itemKey;
final Widget Function(BuildContext context, bool isInLibrary, bool isFavorite) builder;
const LibraryStatusBuilder({
super.key,
required this.itemKey,
required this.builder,
});
@override
State<LibraryStatusBuilder> createState() => _LibraryStatusBuilderState();
}
class _LibraryStatusBuilderState extends State<LibraryStatusBuilder> {
late bool _isInLibrary;
late bool _isFavorite;
@override
void initState() {
super.initState();
_updateStatus();
LibraryStatusService.instance.addListener(_onStatusChanged);
}
@override
void didUpdateWidget(LibraryStatusBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemKey != widget.itemKey) {
_updateStatus();
}
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onStatusChanged);
super.dispose();
}
void _updateStatus() {
_isInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
_isFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
}
void _onStatusChanged() {
final newInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
final newFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
if (newInLibrary != _isInLibrary || newFavorite != _isFavorite) {
setState(() {
_isInLibrary = newInLibrary;
_isFavorite = newFavorite;
});
}
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _isInLibrary, _isFavorite);
}
}
/// Mixin for StatefulWidgets that need to track library/favorite status.
///
/// This provides a simpler API for widgets that already maintain other state.
///
/// Example:
/// ```dart
/// class _MyWidgetState extends State<MyWidget> with LibraryStatusMixin {
/// @override
/// String get libraryItemKey => LibraryStatusService.makeKey('album', widget.album.provider, widget.album.itemId);
///
/// @override
/// Widget build(BuildContext context) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.check : Icons.add),
/// onPressed: toggleLibrary,
/// );
/// }
/// }
/// ```
mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
/// Override to provide the item key
String get libraryItemKey;
/// Whether the item is currently in the library
bool get isInLibrary => LibraryStatusService.instance.isInLibrary(libraryItemKey);
/// Whether the item is currently a favorite
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
@override
void initState() {
super.initState();
LibraryStatusService.instance.addListener(_onLibraryStatusChanged);
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onLibraryStatusChanged);
super.dispose();
}
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
if (mounted) {
setState(() {});
}
}
/// Set library status optimistically and return previous value for rollback
bool setLibraryStatus(bool value) {
final previous = isInLibrary;
LibraryStatusService.instance.markLibraryPending(libraryItemKey, previous);
LibraryStatusService.instance.setLibraryStatus(libraryItemKey, value);
return previous;
}
/// Set favorite status optimistically and return previous value for rollback
bool setFavoriteStatus(bool value) {
final previous = isFavorite;
LibraryStatusService.instance.markFavoritePending(libraryItemKey, previous);
LibraryStatusService.instance.setFavoriteStatus(libraryItemKey, value);
return previous;
}
/// Complete a successful library operation
void completeLibraryOperation() {
LibraryStatusService.instance.completeLibraryOperation(libraryItemKey);
}
/// Complete a successful favorite operation
void completeFavoriteOperation() {
LibraryStatusService.instance.completeFavoriteOperation(libraryItemKey);
}
/// Rollback a failed library operation
void rollbackLibraryOperation() {
LibraryStatusService.instance.rollbackLibraryOperation(libraryItemKey);
}
/// Rollback a failed favorite operation
void rollbackFavoriteOperation() {
LibraryStatusService.instance.rollbackFavoriteOperation(libraryItemKey);
}
}
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -14,9 +14,10 @@ class PlayerSelector extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // Use select to only rebuild when player data changes (not on every provider update)
final selectedPlayer = maProvider.selectedPlayer; final (selectedPlayer, availablePlayers) = context.select<MusicAssistantProvider, (dynamic, List)>(
final availablePlayers = maProvider.availablePlayers; (p) => (p.selectedPlayer, p.availablePlayers),
);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing MA content (excluding selected player) // Count players that are currently playing MA content (excluding selected player)
@@ -45,7 +46,7 @@ class PlayerSelector extends StatelessWidget {
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: InkWell( child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers), onTap: () => _showPlayerSelector(context),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
@@ -81,11 +82,7 @@ class PlayerSelector extends StatelessWidget {
); );
} }
void _showPlayerSelector( void _showPlayerSelector(BuildContext context) {
BuildContext context,
MusicAssistantProvider provider,
List players,
) {
// Use the new player reveal animation // Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal(); GlobalPlayerOverlay.showPlayerReveal();
} }
+209 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -7,9 +8,12 @@ import '../providers/music_assistant_provider.dart';
import '../screens/playlist_details_screen.dart'; import '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart'; import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PlaylistCard extends StatefulWidget { class PlaylistCard extends StatefulWidget {
final Playlist playlist; final Playlist playlist;
@@ -29,9 +33,200 @@ class PlaylistCard extends StatefulWidget {
State<PlaylistCard> createState() => _PlaylistCardState(); State<PlaylistCard> createState() => _PlaylistCardState();
} }
class _PlaylistCardState extends State<PlaylistCard> { class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
bool _isNavigating = false; bool _isNavigating = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'playlist',
widget.playlist.provider,
widget.playlist.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.playlist.provider;
String actualItemId = widget.playlist.itemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final mapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.playlist.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.playlist.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'playlist',
itemId: actualItemId,
provider: actualProvider,
);
} 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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'playlist',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
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;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -67,6 +262,18 @@ class _PlaylistCardState extends State<PlaylistCard> {
}); });
}); });
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -98,7 +99,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
if (_playlists.isEmpty) { if (_playlists.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No playlists found', S.of(context)!.noPlaylistsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
+209 -2
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@@ -8,9 +9,12 @@ import '../providers/music_assistant_provider.dart';
import '../screens/podcast_detail_screen.dart'; import '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart'; import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PodcastCard extends StatefulWidget { class PodcastCard extends StatefulWidget {
final MediaItem podcast; final MediaItem podcast;
@@ -32,9 +36,200 @@ class PodcastCard extends StatefulWidget {
State<PodcastCard> createState() => _PodcastCardState(); State<PodcastCard> createState() => _PodcastCardState();
} }
class _PodcastCardState extends State<PodcastCard> { class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
bool _isNavigating = false; bool _isNavigating = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'podcast',
widget.podcast.provider,
widget.podcast.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.podcast.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.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -73,6 +268,18 @@ class _PodcastCardState extends State<PodcastCard> {
}); });
}); });
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -106,7 +107,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
if (_podcasts.isEmpty) { if (_podcasts.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No podcasts found', S.of(context)!.noPodcasts,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
+208 -1
View File
@@ -7,6 +7,10 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class RadioStationCard extends StatefulWidget { class RadioStationCard extends StatefulWidget {
final MediaItem radioStation; final MediaItem radioStation;
@@ -26,9 +30,200 @@ class RadioStationCard extends StatefulWidget {
State<RadioStationCard> createState() => _RadioStationCardState(); State<RadioStationCard> createState() => _RadioStationCardState();
} }
class _RadioStationCardState extends State<RadioStationCard> { class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
bool _isTapping = false; bool _isTapping = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'radio',
widget.radioStation.provider,
widget.radioStation.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.radioStation.provider;
String actualItemId = widget.radioStation.itemId;
if (widget.radioStation.providerMappings != null && widget.radioStation.providerMappings!.isNotEmpty) {
final mapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.radioStation.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.radioStation.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'radio',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.radioStation.provider == 'library') {
libraryItemId = int.tryParse(widget.radioStation.itemId);
} else if (widget.radioStation.providerMappings != null) {
final libraryMapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.radioStation.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'radio',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.radioStation.providerMappings != null && widget.radioStation.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.radioStation.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.radioStation.provider != 'library') {
actualProvider = widget.radioStation.provider;
actualItemId = widget.radioStation.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'radio',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.radioStation.provider == 'library') {
libraryItemId = int.tryParse(widget.radioStation.itemId);
} else if (widget.radioStation.providerMappings != null) {
final libraryMapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.radioStation.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'radio',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -58,6 +253,18 @@ class _RadioStationCardState extends State<RadioStationCard> {
if (mounted) _isTapping = false; if (mounted) _isTapping = false;
}); });
}, },
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.radio,
item: widget.radioStation,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -98,7 +99,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
if (_radioStations.isEmpty) { if (_radioStations.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No radio stations found', S.of(context)!.noRadioStations,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
+4 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -172,7 +173,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
if (_series.isEmpty) { if (_series.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No series found', S.of(context)!.noSeriesFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
@@ -406,6 +407,8 @@ class _SeriesCard extends StatelessWidget {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: coverUrl, imageUrl: coverUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 192,
memCacheHeight: 192,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
+222 -9
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class TrackRow extends StatefulWidget { class TrackRow extends StatefulWidget {
final String title; final String title;
@@ -96,7 +101,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
if (_tracks.isEmpty) { if (_tracks.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No tracks found', S.of(context)!.noTracksFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
), ),
); );
@@ -176,7 +181,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
} }
} }
class _TrackCard extends StatelessWidget { class _TrackCard extends StatefulWidget {
final Track track; final Track track;
final List<Track> tracks; final List<Track> tracks;
final int index; final int index;
@@ -187,13 +192,209 @@ class _TrackCard extends StatelessWidget {
required this.index, required this.index,
}); });
@override
State<_TrackCard> createState() => _TrackCardState();
}
class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'track',
widget.track.provider,
widget.track.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.track.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.track.provider;
String actualItemId = widget.track.itemId;
if (widget.track.providerMappings != null && widget.track.providerMappings!.isNotEmpty) {
final mapping = widget.track.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.track.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.track.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.track.provider == 'library') {
libraryItemId = int.tryParse(widget.track.itemId);
} else if (widget.track.providerMappings != null) {
final libraryMapping = widget.track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.track.providerMappings != null && widget.track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.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 (widget.track.provider != 'library') {
actualProvider = widget.track.provider;
actualItemId = widget.track.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.track.provider == 'library') {
libraryItemId = int.tryParse(widget.track.itemId);
} else if (widget.track.providerMappings != null) {
final libraryMapping = widget.track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Try track image first, then album image // Try track image first, then album image
final trackImageUrl = maProvider.getImageUrl(track, size: 256); final trackImageUrl = maProvider.getImageUrl(widget.track, size: 256);
final albumImageUrl = track.album != null final albumImageUrl = widget.track.album != null
? maProvider.getImageUrl(track.album!, size: 256) ? maProvider.getImageUrl(widget.track.album!, size: 256)
: null; : null;
final imageUrl = trackImageUrl ?? albumImageUrl; final imageUrl = trackImageUrl ?? albumImageUrl;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -202,6 +403,18 @@ class _TrackCard extends StatelessWidget {
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: () => _playTrack(context, maProvider), onTap: () => _playTrack(context, maProvider),
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: widget.track,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -241,7 +454,7 @@ class _TrackCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// Track title // Track title
Text( Text(
track.name, widget.track.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith( style: textTheme.titleSmall?.copyWith(
@@ -251,7 +464,7 @@ class _TrackCard extends StatelessWidget {
), ),
// Artist name // Artist name
Text( Text(
track.artistsString, widget.track.artistsString,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
@@ -274,8 +487,8 @@ class _TrackCard extends StatelessWidget {
await maProvider.playTracks( await maProvider.playTracks(
maProvider.selectedPlayer!.playerId, maProvider.selectedPlayer!.playerId,
tracks, widget.tracks,
startIndex: index, startIndex: widget.index,
); );
} }
} }
+59 -27
View File
@@ -171,8 +171,8 @@ class _VolumeControlState extends State<VolumeControl> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // Use select to only rebuild when selectedPlayer changes (not on every provider update)
final player = maProvider.selectedPlayer; final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
if (player == null) { if (player == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -203,6 +203,7 @@ class _VolumeControlState extends State<VolumeControl> {
onPressed: () async { onPressed: () async {
// In compact mode, toggle between 0 and 50% // In compact mode, toggle between 0 and 50%
final newVolume = currentVolume < 0.01 ? 50 : 0; final newVolume = currentVolume < 0.01 ? 50 : 0;
final maProvider = context.read<MusicAssistantProvider>();
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0); await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
}, },
); );
@@ -216,7 +217,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_down, Icons.volume_down,
color: Colors.white70, color: Colors.white70,
), ),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1), onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, -1),
), ),
// Slider with floating teardrop indicator // Slider with floating teardrop indicator
Expanded( Expanded(
@@ -225,11 +226,8 @@ class _VolumeControlState extends State<VolumeControl> {
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final sliderWidth = constraints.maxWidth; final sliderWidth = constraints.maxWidth;
// Calculate thumb position to match Flutter Slider's internal positioning // Calculate thumb position for the custom slider track
// The Slider uses overlayRadius as padding on each side for the track final thumbPosition = sliderWidth * currentVolume.clamp(0.0, 1.0);
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
final thumbPosition = overlayRadius +
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -287,7 +285,7 @@ class _VolumeControlState extends State<VolumeControl> {
setState(() { setState(() {
_pendingVolume = newVolume; _pendingVolume = newVolume;
}); });
_sendVolumeUpdate(maProvider, player.playerId, newVolume); _sendVolumeUpdate(context.read<MusicAssistantProvider>(), player.playerId, newVolume);
} }
}, },
onHorizontalDragEnd: (details) { onHorizontalDragEnd: (details) {
@@ -301,7 +299,7 @@ class _VolumeControlState extends State<VolumeControl> {
FlutterVolumeController.setVolume(finalVolume); FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume; _systemVolume = finalVolume;
} else { } else {
maProvider.setVolume(player.playerId, volumeLevel); context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
} }
_exitPrecisionMode(); _exitPrecisionMode();
@@ -311,22 +309,56 @@ class _VolumeControlState extends State<VolumeControl> {
_pendingVolume = null; _pendingVolume = null;
}); });
}, },
child: SliderTheme( // Custom slider track (no Overlay needed - Flutter 3.38 Slider uses OverlayPortal)
data: SliderThemeData( child: SizedBox(
trackHeight: 2, height: 48,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), child: Center(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), child: SizedBox(
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white, height: 20,
inactiveTrackColor: Colors.white.withOpacity(0.3), child: Stack(
thumbColor: _inPrecisionMode ? accentColor : Colors.white, clipBehavior: Clip.none,
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2), children: [
), // Inactive track (full width, thinner)
child: AbsorbPointer( Positioned(
// AbsorbPointer prevents Slider from handling gestures left: 0,
// Our GestureDetector handles everything for live updates right: 0,
child: Slider( top: 8.5,
value: currentVolume.clamp(0.0, 1.0), child: Container(
onChanged: (_) {}, height: 3,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: sliderWidth * currentVolume.clamp(0.0, 1.0),
height: 6,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (sliderWidth * currentVolume.clamp(0.0, 1.0)) - 6,
top: 4,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
shape: BoxShape.circle,
),
),
),
],
),
), ),
), ),
), ),
@@ -356,7 +388,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_up, Icons.volume_up,
color: Colors.white70, color: Colors.white70,
), ),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1), onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, 1),
), ),
], ],
); );
+71 -68
View File
@@ -5,23 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "72.0.0" version: "91.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.7.0" version: "8.4.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -90,10 +85,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "3.0.1"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
@@ -114,26 +109,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build_resolvers name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "3.0.1"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.13" version: "2.6.1"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
name: build_runner_core name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.3.2" version: "9.2.1"
built_collection: built_collection:
dependency: transitive dependency: transitive
description: description:
@@ -174,14 +169,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
chalkdart:
dependency: transitive
description:
name: chalkdart
sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
characters: characters:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.0"
charcode: charcode:
dependency: transitive dependency: transitive
description: description:
@@ -210,10 +213,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@@ -226,10 +229,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.1"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@@ -258,10 +261,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.7" version: "3.1.3"
device_info_plus: device_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -282,18 +285,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621 sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.23.1" version: "2.30.1"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9 sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.23.1" version: "2.30.1"
drift_flutter: drift_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -314,10 +317,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -515,7 +518,7 @@ packages:
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
image: image:
dependency: transitive dependency: "direct main"
description: description:
name: image name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
@@ -526,10 +529,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -582,26 +585,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.5" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -618,22 +621,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16+1" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -650,14 +645,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.7296" version: "7.0.7296"
material_symbols_icons:
dependency: "direct main"
description:
name: material_symbols_icons
sha256: "02555a48e1ec02b16e532dfd4ef13c4f6bf7ec7c20230e58e56641a393433dc3"
url: "https://pub.dev"
source: hosted
version: "4.2892.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -718,10 +721,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
path_parsing: path_parsing:
dependency: transitive dependency: transitive
description: description:
@@ -950,15 +953,15 @@ packages:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.0"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
name: source_gen name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "4.2.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -1027,26 +1030,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlparser name: sqlparser
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41" sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.40.0" version: "0.43.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.12.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.4"
stream_transform: stream_transform:
dependency: transitive dependency: transitive
description: description:
@@ -1083,10 +1086,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.7"
timing: timing:
dependency: transitive dependency: transitive
description: description:
@@ -1171,10 +1174,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -1248,5 +1251,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.5.0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.24.0"
+4 -2
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.9.1-beta+49 version: 3.0.1+53
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -11,10 +11,11 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
intl: ^0.19.0 intl: ^0.20.0
# UI # UI
material_design_icons_flutter: ^7.0.7296 material_design_icons_flutter: ^7.0.7296
material_symbols_icons: ^4.2815.0
dynamic_color: 1.6.8 dynamic_color: 1.6.8
palette_generator: ^0.3.3+3 palette_generator: ^0.3.3+3
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
@@ -37,6 +38,7 @@ dependencies:
# Utilities # Utilities
uuid: ^4.2.1 uuid: ^4.2.1
image: ^4.3.0 # Pure Dart image decoding for isolate-based palette extraction
package_info_plus: ^8.0.0 package_info_plus: ^8.0.0
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
share_plus: ^10.1.4 share_plus: ^10.1.4