Compare commits

..
20 Commits
Author SHA1 Message Date
Chris 44a1674c28 WIP on feature/remote-access-v2: d7b7bbc Fix Sendspin proxy auth for local network connections 2026-01-22 10:16:43 +00:00
Chris 91494f7d25 index on feature/remote-access-v2: d7b7bbc Fix Sendspin proxy auth for local network connections 2026-01-22 10:16:43 +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
ChrisandClaude Opus 4.5 c00fb3dc78 chore: Bump version to 2.9.1-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:27:03 +00:00
ChrisandClaude Opus 4.5 ff34479169 feat: Provider icons and library performance optimizations
Provider Icons:
- Add provider icon overlays to series detail screen (grid/list)
- Add provider icon overlays to library playlists (grid/list)
- Add ProviderIconOverlay widget and provider manifest model

Performance Optimizations:
- Fix empty setState in SyncService listener - only rebuild on sync complete
- Add cached sorting for albums, artists, authors (avoid O(n log n) per rebuild)
- Add cached track sort keys for LetterScrollbar
- Fix image cache size mismatches (author 128px, audiobook list 128px, series 150px)
- Simplify provider toggle setState to avoid double rebuild
- Remove redundant text Hero animations from card widgets
- Defer color extraction from card tap to detail screen initState
- Increase fallback image debounce from 200ms to 400ms

Other Changes:
- Update screenshots
- Add new localizations
- Various detail screen improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:24:37 +00:00
ChrisandClaude Opus 4.5 d7127ea7da chore: Bump version to 2.9.0-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:16:35 +00:00
ChrisandClaude Opus 4.5 29b4887d0d docs: Add player selector implementation plan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:04:58 +00:00
ChrisandClaude Opus 4.5 18603c34e1 Merge audit-fixes: Memory leaks, performance, and bug fixes
Phase 1 - Critical Memory Leaks & Safety:
- Fix pending API requests memory leak (max 100, cancel on reconnect)
- Fix ImageStream listener leaks in expandable player
- Add database batch operations (10x faster sync)
- Fix connection guard race condition
- Mark undersized touch targets as deprecated (WCAG)
- Extract SleepTimerProvider and ConnectionProvider skeletons

Phase 2 - UI/Widget Fixes:
- Add LRU cache eviction to MetadataService (bounded caches)
- Add staleness detection for async player state updates
- Add explicit animation controller listener cleanup

Phase 3 - Medium Priority:
- Add MediaItem equality operators (itemId + provider)
- Extract duration formatting utility (remove 3 duplicates)

Bug Fixes:
- Fix volume precision toggle not affecting player selector overlay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 08:11:53 +00:00
ChrisandClaude Opus 4.5 5ed2bf7725 Fix volume precision toggle not affecting player selector overlay
The ExpandablePlayer was loading the volume precision setting only once
in initState(), so changes made in settings weren't reflected until app
restart. PlayerCards in the overlay were loading fresh values on each
creation.

Fix: Add didUpdateWidget to reload the precision mode setting when the
device reveal overlay becomes visible. This ensures the setting is fresh
after the user may have changed it in settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:44:13 +00:00
ChrisandClaude Opus 4.5 67a99c88a0 Phase 3: Medium priority fixes - MediaItem equality and duration formatting
MediaItem Equality (3.4):
- Add == operator based on itemId and provider
- Add hashCode using Object.hash for correct Set/Map behavior
- Enables proper comparison in collections and deduplication

Duration Formatter Utility (3.5):
- Create lib/utils/duration_formatter.dart with reusable functions
- Add DurationFormatter extension on Duration
- Add formatDurationSeconds() and formatDurationOrEmpty() functions
- Replace 3 duplicate _formatDuration implementations:
  - expandable_player.dart
  - chapters_panel.dart
  - queue_panel.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:21:29 +00:00
ChrisandClaude Opus 4.5 ffbb41580b Phase 2: UI/Widget fixes and memory management
MetadataService Cache Eviction (2.4):
- Create LruCache<K,V> utility class with O(1) LRU operations
- Create NullableLruCache for caches storing null (failed lookups)
- Replace unbounded static maps with bounded LRU caches:
  - Description cache: 100 entries
  - Artist/album image caches: 200 entries each
  - Author image cache: 100 entries
  - Lyrics cache: 500 entries with manual eviction

Async Cancellation on Player Switch (2.2):
- Add staleness detection to _updatePlayerState()
- Capture player ID at operation start
- Check for player changes after each await
- Abort stale operations to prevent UI inconsistency

Scroll Controller Cleanup (2.3):
- Verified existing cleanup is correct
- Only tracks scroll controller has listener, properly removed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:17:53 +00:00
ChrisandClaude Opus 4.5 6942b56cfb Phase 1: Critical memory leaks, safety & performance fixes
Memory Leak Fixes:
- Add max pending requests limit (100) to prevent unbounded growth
- Cancel pending requests in _reconnect() before reconnecting
- Track ImageStream listeners in expandable_player for proper cleanup
- Add cleanup in dispose() for active image stream listeners

Database Performance:
- Add batchCacheItems() method using drift batch API
- Chunk batches at 500 items to avoid SQLite limits
- Refactor sync_service to use batch inserts (~10x faster)
- Add BatchCacheItem data class for type-safe batch operations

Race Condition Fix:
- Make connection guard check-and-set atomic
- Create completer before check to close race window

WCAG Accessibility:
- Mark ButtonSizes.sm/md as @Deprecated (below 44x44 minimum)
- Add ButtonSizes.minAccessible constant (48x48)
- Add clear documentation on WCAG Level AA/AAA requirements

Provider Decomposition (partial):
- Extract SleepTimerProvider with callback pattern
- Extract ConnectionProvider skeleton for future integration
- Prepare facade pattern for backward compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:13:40 +00:00
ChrisandClaude Opus 4.5 14ba524ed0 refactor: Replace Play On popups with Player Selector overlay
Use the existing PlayerRevealOverlay for "Play On" actions instead of
the PlayerPickerSheet popup modal. This provides a more consistent UX
by reusing the same player selection interface throughout the app.

- Add onPlayerSelected callback and contextHint to PlayerRevealOverlay
- Add showPlayerSelectorForAction helper to GlobalPlayerOverlay
- Refactor album, artist, playlist, podcast, audiobook, and search screens
- Add localization strings for context-specific hints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 06:45:57 +00:00
ChrisandClaude Opus 4.5 49fc2a77e7 fix: Multiple notification and UI fixes
- Clear notification when switching to idle player (fixes issue #42)
- Fix notification progress bar desync after seek and with Sendspin
  - Always use position tracker instead of PCM elapsed time
  - PCM elapsed resets to 0 on stream start, causing wrong position
- Fix invisible nav bar due to stale playerExpansionNotifier
  - Reset notifier when player hidden (disconnect/no player)
  - Reset notifier after collapse() completes even if already at 0
- Fix welcome backdrop showing dark color in light theme
  - Now uses theme-appropriate surface color

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 05:26:33 +00:00
ChrisandClaude Opus 4.5 6632fa14c6 feat: Add sleep timer and fix progress bar freeze bug
Sleep Timer:
- Add sleep timer panel with preset durations (15/30/45/60 min)
- Support finish current track option
- Show countdown in queue panel header
- Auto-stop playback when timer expires

Progress Bar Fix:
- Fix anchor refresh logic when server sends stale position data
- Allow anchor refresh even with suspicious reset when anchor is stale
- Use interpolated position to prevent backward jumps from bad server data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:47:31 +00:00
71 changed files with 5805 additions and 1900 deletions
+16 -11
View File
@@ -36,7 +36,8 @@ This application was built with AI-assisted development using **Claude Code** an
### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue
- **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
- **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
- **Audiobooks** - Browse by title, series, or author with progress tracking
- **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
- **Provider Icons** - Provider icons on library covers
### Search
- **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
<div align="center">
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" 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>
## Download
+37
View File
@@ -0,0 +1,37 @@
# Release Notes Draft
## v2.9.2-beta (TBD)
### 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.
### Technical Changes
- 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
+5 -5
View File
@@ -30,16 +30,16 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "com.musicassistant.music_assistant"
compileSdk 35
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
sourceSets {
@@ -59,7 +59,7 @@ android {
defaultConfig {
applicationId "com.musicassistant.music_assistant"
minSdk 21
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
+2 -2
View File
@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.1.0'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
@@ -9,7 +9,7 @@ buildscript {
}
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"
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+2 -2
View File
@@ -22,8 +22,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

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.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 909 KiB

+221
View File
@@ -0,0 +1,221 @@
# Feature: Replace "Play On" Popups with Player Selector
## Overview
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
## Current State
### PlayerPickerSheet Popup
Used in 7+ places:
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
- `playlist_details_screen.dart` - Play Playlist, Play Radio
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
- `podcast_detail_screen.dart` - Play Episode
- `audiobook_detail_screen.dart` - Play Audiobook
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
### Player Selector (Reveal Overlay)
- Location: `lib/widgets/player/player_reveal_overlay.dart`
- Shows vertical list of all players with artwork, current track, sync status
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
- Triggered by pull-down gesture or device button tap
## Proposed Changes
### 1. Modify PlayerRevealOverlay
**File:** `lib/widgets/player/player_reveal_overlay.dart`
Add new optional parameters:
```dart
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom;
final double miniPlayerHeight;
final bool showOnboardingHints;
// NEW: Callback when player is selected for a pending action
final void Function(Player player)? onPlayerSelected;
// NEW: Context hint to display instead of default hints
// e.g., "Select player to play album"
final String? contextHint;
// NEW: Icon for context hint (default: speaker icon)
final IconData? contextHintIcon;
```
### 2. Modify Hint Display
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
```dart
// Current:
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
// New:
: widget.contextHint != null
? buildHintRow(
widget.contextHintIcon ?? Icons.play_circle_outline,
widget.contextHint!,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
```
### 3. Modify Player Card onTap
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
```dart
// Current:
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
dismiss();
},
// New:
onTap: () {
HapticFeedback.mediumImpact();
if (widget.onPlayerSelected != null) {
// Execute the pending action with selected player
widget.onPlayerSelected!(data.player);
} else {
// Default behavior: just select the player
maProvider.selectPlayer(data.player);
}
dismiss();
},
```
### 4. Add Helper Method to GlobalPlayerOverlay
**File:** `lib/widgets/global_player_overlay.dart`
Add a static method to show player selector with pending action:
```dart
/// Show player selector for a "Play on" action
///
/// [contextHint] - Message shown instead of default hints
/// [onPlayerSelected] - Called when user taps a player
///
/// Example:
/// ```dart
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
/// contextHint: "Select player to play album",
/// onPlayerSelected: (player) {
/// provider.playAlbum(albumId, playerId: player.playerId);
/// },
/// );
/// ```
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(Player player) onPlayerSelected,
IconData? hintIcon,
}) {
// Implementation: trigger the player reveal overlay with these parameters
// May need to store pending action in state and pass to overlay
}
```
### 5. Update Calling Sites
Replace `showPlayerPickerSheet()` calls with new method:
**Example - album_details_screen.dart:**
```dart
// Current (around line 1305-1316):
showPlayerPickerSheet(
context: context,
title: 'Play Album',
onPlayerSelected: (player) async {
GlobalPlayerOverlay.hidePlayer();
await maProvider.playAlbum(albumId, playerId: player.playerId);
GlobalPlayerOverlay.showPlayer();
},
);
// New:
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
onPlayerSelected: (player) async {
await maProvider.playAlbum(albumId, playerId: player.playerId);
},
);
```
### 6. Add Localizations
**File:** `lib/l10n/app_en.arb` (and other language files)
```json
"selectPlayerToPlay": "Select player to play",
"selectPlayerToPlayAlbum": "Select player to play album",
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"selectPlayerToPlayRadio": "Select player to start radio",
"selectPlayerToPlayEpisode": "Select player to play episode",
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
```
## Files to Modify
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
2. `lib/widgets/global_player_overlay.dart` - Add helper method
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
8. `lib/l10n/app_en.arb` - Add new strings
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
## Exception: Queue Transfer
**Keep the popup for `queue_screen.dart`** because:
- Needs to filter out current player (can't transfer to self)
- Needs to filter out certain player types
- Different UX intent (transfer vs play)
## Implementation Order
1. Modify `PlayerRevealOverlay` to accept new parameters
2. Add helper method to `GlobalPlayerOverlay`
3. Test with one screen (album_details_screen.dart)
4. Add localizations
5. Migrate remaining screens
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
## Testing Checklist
- [ ] Album details: Play Album button
- [ ] Album details: Play From Here (track context menu)
- [ ] Album details: Play Radio (track context menu)
- [ ] Playlist details: Play Playlist button
- [ ] Playlist details: Play Radio (track context menu)
- [ ] Search: Play album from results
- [ ] Search: Play playlist from results
- [ ] Search: Play radio from results
- [ ] Podcast detail: Play episode
- [ ] Audiobook detail: Play audiobook
- [ ] Queue transfer still works with popup
- [ ] Long-press to sync still works in player selector
- [ ] Dismiss by tapping backdrop works
- [ ] Dismiss by swiping down works
Binary file not shown.
Binary file not shown.
+3
View File
@@ -10,4 +10,7 @@ class NetworkConstants {
/// Default HTTP port
static const int defaultHttpPort = 80;
/// Maximum number of pending WebSocket requests to prevent memory leaks
static const int maxPendingRequests = 100;
}
+62
View File
@@ -695,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
// ============================================
// Batch Cache Operations
// ============================================
/// Batch size for chunked inserts to prevent SQLite limits
static const int _batchChunkSize = 500;
/// Batch cache multiple items efficiently using drift batch API
/// This is much faster than individual inserts for large datasets.
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
///
/// [items] - List of items to cache, each containing:
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
/// - itemId: the item's ID
/// - data: JSON string of the item
/// - sourceProviders: list of provider instance IDs
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
if (items.isEmpty) return;
// Process in chunks to avoid SQLite variable limits
for (var i = 0; i < items.length; i += _batchChunkSize) {
final chunk = items.skip(i).take(_batchChunkSize).toList();
await batch((b) {
for (final item in chunk) {
final cacheKey = '${item.itemType}_${item.itemId}';
final sourceProvidersJson = item.sourceProviders.isEmpty
? '[]'
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
b.insert(
libraryCache,
LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: item.itemType,
itemId: item.itemId,
data: item.data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
),
mode: InsertMode.insertOrReplace,
);
}
});
}
}
}
/// Data class for batch cache operations
class BatchCacheItem {
final String itemType;
final String itemId;
final String data;
final List<String> sourceProviders;
const BatchCacheItem({
required this.itemType,
required this.itemId,
required this.data,
this.sourceProviders = const [],
});
}
+60
View File
@@ -1292,6 +1292,16 @@
"description": "Description for smart sort players setting"
},
"disableAutoSwitch": "Disable Auto-Switch",
"@disableAutoSwitch": {
"description": "Setting to disable automatic player switching"
},
"disableAutoSwitchDescription": "Never automatically switch to a different player",
"@disableAutoSwitchDescription": {
"description": "Description for disable auto-switch setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
@@ -1347,6 +1357,21 @@
"description": "Description for show hints toggle"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
},
"showProviderIcons": "Show Provider Icons",
"@showProviderIcons": {
"description": "Toggle label for showing provider icons on album art"
},
"showProviderIconsDescription": "Display provider logos on album art",
"@showProviderIconsDescription": {
"description": "Description for show provider icons toggle"
},
"pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces"
@@ -1593,5 +1618,40 @@
"transferQueueToPlayer": "Transfer queue to another player",
"@transferQueueToPlayer": {
"description": "Tooltip for transfer queue button"
},
"selectPlayerToPlay": "Select a player",
"@selectPlayerToPlay": {
"description": "Generic hint for selecting a player in player selector"
},
"selectPlayerToPlayAlbum": "Select player to play album",
"@selectPlayerToPlayAlbum": {
"description": "Hint for selecting player to play album"
},
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"@selectPlayerToPlayPlaylist": {
"description": "Hint for selecting player to play playlist"
},
"selectPlayerForRadio": "Select player for radio",
"@selectPlayerForRadio": {
"description": "Hint for selecting player to start radio"
},
"selectPlayerForEpisode": "Select player for episode",
"@selectPlayerForEpisode": {
"description": "Hint for selecting player to play episode"
},
"selectPlayerForAudiobook": "Select player for audiobook",
"@selectPlayerForAudiobook": {
"description": "Hint for selecting player to play audiobook"
},
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
}
}
+90 -14
View File
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
/// be consistent with the languages listed in the S.supportedLocales
/// property.
abstract class S {
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
S(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
@@ -84,7 +85,8 @@ abstract class S {
/// 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
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -1761,6 +1763,18 @@ abstract class S {
/// **'Sort by status (playing, on, off) instead of alphabetically'**
String get smartSortPlayersDescription;
/// Setting to disable automatic player switching
///
/// In en, this message translates to:
/// **'Disable Auto-Switch'**
String get disableAutoSwitch;
/// Description for disable auto-switch setting
///
/// In en, this message translates to:
/// **'Never automatically switch to a different player'**
String get disableAutoSwitchDescription;
/// Player state when unavailable
///
/// In en, this message translates to:
@@ -1821,6 +1835,24 @@ abstract class S {
/// **'Display helpful tips for discovering features'**
String get showHintsDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
/// **'Display'**
String get display;
/// Toggle label for showing provider icons on album art
///
/// In en, this message translates to:
/// **'Show Provider Icons'**
String get showProviderIcons;
/// Description for show provider icons toggle
///
/// In en, this message translates to:
/// **'Display provider logos on album art'**
String get showProviderIconsDescription;
/// Hint shown when mini player bounces
///
/// In en, this message translates to:
@@ -2048,6 +2080,48 @@ abstract class S {
/// In en, this message translates to:
/// **'Transfer queue to another player'**
String get transferQueueToPlayer;
/// Generic hint for selecting a player in player selector
///
/// In en, this message translates to:
/// **'Select a player'**
String get selectPlayerToPlay;
/// Hint for selecting player to play album
///
/// In en, this message translates to:
/// **'Select player to play album'**
String get selectPlayerToPlayAlbum;
/// Hint for selecting player to play playlist
///
/// In en, this message translates to:
/// **'Select player to play playlist'**
String get selectPlayerToPlayPlaylist;
/// Hint for selecting player to start radio
///
/// In en, this message translates to:
/// **'Select player for radio'**
String get selectPlayerForRadio;
/// Hint for selecting player to play episode
///
/// In en, this message translates to:
/// **'Select player for episode'**
String get selectPlayerForEpisode;
/// Hint for selecting player to play audiobook
///
/// In en, this message translates to:
/// **'Select player for audiobook'**
String get selectPlayerForAudiobook;
/// Hint for selecting player to add to queue
///
/// In en, this message translates to:
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
}
class _SDelegate extends LocalizationsDelegate<S> {
@@ -2059,27 +2133,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
}
@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
bool shouldReload(_SDelegate old) => false;
}
S lookupS(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de': return SDe();
case 'en': return SEn();
case 'es': return SEs();
case 'fr': return SFr();
case 'de':
return SDe();
case 'en':
return SEn();
case 'es':
return SEs();
case 'fr':
return SFr();
}
throw FlutterError(
'S.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'
);
'S.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.');
}
+121 -42
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SDe extends S {
String get portHint => 'z.B. 8095 oder leer lassen';
@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
String get username => 'Benutzername';
@@ -60,10 +61,12 @@ class SDe extends S {
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@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
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
String get authServerUrlDescription =>
'Leer lassen, wenn Authentifizierung auf demselben Server';
@override
String detectedAuthType(String authType) {
@@ -80,40 +83,49 @@ class SDe extends S {
String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
String get usingHttpOverTailscale =>
'HTTP uber Tailscale (verschlusselter Tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
String get httpNotEncrypted =>
'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
String get pleaseEnterServerAddress =>
'Bitte gib deine Music Assistant Serveradresse ein';
@override
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
String get pleaseEnterValidPort =>
'Bitte gib eine gultige Portnummer ein (1-65535)';
@override
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
String get pleaseEnterCredentials =>
'Bitte gib Benutzername und Passwort ein';
@override
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
String get authFailed =>
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
String get maLoginFailed =>
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
String get connectionFailed =>
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override
String get detectingAuth => 'Erkenne Authentifizierung...';
@override
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
String get cannotDetermineAuth =>
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override
String get noAuthentication => 'Keine Authentifizierung';
@@ -221,10 +233,12 @@ class SDe extends S {
String get noRadioStations => 'Keine Radiosender';
@override
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
String get addRadioStationsHint =>
'Füge Radiosender in Music Assistant hinzu';
@override
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
String get searchFailed =>
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override
String get queue => 'Warteschlange';
@@ -471,37 +485,43 @@ class SDe extends S {
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
String get tapHeartAudiobook =>
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override
String get noAudiobooks => 'Keine Hörbücher';
@override
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
String get addAudiobooksHint =>
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override
String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
String get tapHeartArtist =>
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override
String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
String get tapHeartAlbum =>
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
String get tapHeartPlaylist =>
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override
String get noFavoriteTracks => 'Keine Lieblingssongs';
@override
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
String get longPressTrackHint =>
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override
String get loadSeries => 'Serien laden';
@@ -513,7 +533,8 @@ class SDe extends S {
String get notConnectedTitle => 'Nicht verbunden';
@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
String get configureServer => 'Server konfigurieren';
@@ -549,7 +570,8 @@ class SDe extends S {
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
String get noDebugLogsYet =>
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override
String get showDebug => 'Debug anzeigen';
@@ -649,10 +671,12 @@ class SDe extends S {
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
String get showRandomAudiobooks =>
'Zufällige Hörbücher zum Entdecken anzeigen';
@override
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
String get showRandomSeries =>
'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@@ -667,16 +691,19 @@ class SDe extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
String get extractColorsFromArtwork =>
'Farben aus Album- und Künstlercover extrahieren';
@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 addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -785,13 +812,15 @@ class SDe extends S {
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@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
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
String get chooseAudiobookLibraries =>
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override
String get unknownLibrary => 'Unbekannte Bibliothek';
@@ -800,7 +829,8 @@ class SDe extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +850,19 @@ class SDe extends S {
}
@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
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
String get search => 'Suche';
@override
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
String get metadataApisDescription =>
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override
String get lastFmApiKey => 'Last.fm API-Schlüssel';
@@ -901,13 +934,22 @@ class SDe extends S {
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@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
String get smartSortPlayers => 'Intelligente Sortierung';
@override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
String get smartSortPlayersDescription =>
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@@ -939,7 +981,18 @@ class SDe extends S {
String get showHints => 'Hinweise anzeigen';
@override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@@ -957,13 +1010,15 @@ class SDe extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -979,19 +1034,22 @@ class SDe extends S {
String get noTracks => 'No tracks';
@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
String get noFavoriteRadioStations => 'No favorite radio stations';
@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
String get noFavoritePodcasts => 'No favorite podcasts';
@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
String failedToPlayRadioStation(String error) {
@@ -1076,4 +1134,25 @@ class SDe extends S {
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
}
+105 -34
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank';
@override
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
String get portDescription =>
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
@override
String get username => 'Username';
@@ -60,10 +61,12 @@ class SEn extends S {
String get authServerUrlOptional => 'Auth Server URL (Optional)';
@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
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
String detectedAuthType(String authType) {
@@ -80,7 +83,8 @@ class SEn extends S {
String get unencryptedConnection => 'Unencrypted Connection';
@override
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
String get usingHttpOverTailscale =>
'Using HTTP over Tailscale (encrypted tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@@ -89,31 +93,37 @@ class SEn extends S {
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
String get pleaseEnterServerAddress =>
'Please enter your Music Assistant server address';
@override
String get pleaseEnterName => 'Please enter your name';
@override
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
String get pleaseEnterValidPort =>
'Please enter a valid port number (1-65535)';
@override
String get pleaseEnterCredentials => 'Please enter username and password';
@override
String get authFailed => 'Authentication failed. Please check your credentials.';
String get authFailed =>
'Authentication failed. Please check your credentials.';
@override
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
String get maLoginFailed =>
'Music Assistant login failed. Please check your credentials.';
@override
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
String get connectionFailed =>
'Could not connect to server. Please check the address and try again.';
@override
String get detectingAuth => 'Detecting authentication...';
@override
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
String get cannotDetermineAuth =>
'Cannot determine authentication requirements. Please check server URL.';
@override
String get noAuthentication => 'No Authentication';
@@ -471,37 +481,43 @@ class SEn extends S {
String get noFavoriteAudiobooks => 'No favorite audiobooks';
@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
String get noAudiobooks => 'No audiobooks';
@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
String get noFavoriteArtists => 'No favorite artists';
@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
String get noFavoriteAlbums => 'No favorite albums';
@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
String get noFavoritePlaylists => 'No favorite playlists';
@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
String get noFavoriteTracks => 'No favorite tracks';
@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
String get loadSeries => 'Load Series';
@@ -513,7 +529,8 @@ class SEn extends S {
String get notConnectedTitle => 'Not Connected';
@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
String get configureServer => 'Configure Server';
@@ -667,16 +684,19 @@ class SEn extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
String get extractColorsFromArtwork =>
'Extract colors from album and artist artwork';
@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 addedToFavorites => 'Added to favorites';
@@ -791,7 +811,8 @@ class SEn extends S {
String get audiobookLibraries => 'Audiobook Libraries';
@override
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
String get chooseAudiobookLibraries =>
'Choose which Audiobookshelf libraries to include';
@override
String get unknownLibrary => 'Unknown Library';
@@ -800,7 +821,8 @@ class SEn extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +842,19 @@ class SEn extends S {
}
@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
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
String get pullToLoadSeries =>
'Pull down to load series\nfrom Music Assistant';
@override
String get search => 'Search';
@override
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
String get metadataApisDescription =>
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override
String get lastFmApiKey => 'Last.fm API Key';
@@ -901,13 +926,22 @@ class SEn extends S {
String get preferLocalPlayer => 'Prefer Local Player';
@override
String get preferLocalPlayerDescription => 'Always select this device first when available';
String get preferLocalPlayerDescription =>
'Always select this device first when available';
@override
String get smartSortPlayers => 'Smart Sort';
@override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
String get smartSortPlayersDescription =>
'Sort by status (playing, on, off) instead of alphabetically';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Unavailable';
@@ -939,7 +973,18 @@ class SEn extends S {
String get showHints => 'Show Hints';
@override
String get showHintsDescription => 'Display helpful tips for discovering features';
String get showHintsDescription =>
'Display helpful tips for discovering features';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Pull to select players';
@@ -957,13 +1002,15 @@ class SEn extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -979,19 +1026,22 @@ class SEn extends S {
String get noTracks => 'No tracks';
@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
String get noFavoriteRadioStations => 'No favorite radio stations';
@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
String get noFavoritePodcasts => 'No favorite podcasts';
@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
String failedToPlayRadioStation(String error) {
@@ -1076,4 +1126,25 @@ class SEn extends S {
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
}
+137 -50
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SEs extends S {
String get portHint => 'ej., 8095 o dejar en blanco';
@override
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
String get portDescription =>
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override
String get username => 'Usuario';
@@ -57,13 +58,16 @@ class SEs extends S {
String get disconnect => 'Desconectar';
@override
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
String get authServerUrlOptional =>
'URL del servidor de autenticación (Opcional)';
@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
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
String detectedAuthType(String authType) {
@@ -80,40 +84,49 @@ class SEs extends S {
String get unencryptedConnection => 'Conexión sin cifrar';
@override
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
String get usingHttpOverTailscale =>
'Usando HTTP sobre Tailscale (túnel cifrado)';
@override
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
String get httpsFailedUsingHttp =>
'HTTPS falló, usando HTTP como alternativa';
@override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
String get pleaseEnterServerAddress =>
'Por favor, introduce la dirección de tu servidor Music Assistant';
@override
String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
String get pleaseEnterValidPort =>
'Por favor, introduce un número de puerto válido (1-65535)';
@override
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
String get pleaseEnterCredentials =>
'Por favor, introduce usuario y contraseña';
@override
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
String get authFailed =>
'Autenticación fallida. Por favor, verifica tus credenciales.';
@override
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
String get maLoginFailed =>
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
String get connectionFailed =>
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override
String get detectingAuth => 'Detectando autenticación...';
@override
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
String get cannotDetermineAuth =>
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override
String get noAuthentication => 'Sin autenticación';
@@ -221,10 +234,12 @@ class SEs extends S {
String get noRadioStations => 'No hay emisoras de radio';
@override
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
String get addRadioStationsHint =>
'Añade emisoras de radio en Music Assistant';
@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
String get queue => 'Cola';
@@ -264,7 +279,8 @@ class SEs extends S {
String get system => 'Sistema';
@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
String get viewDebugLogs => 'Ver registros de depuración';
@@ -301,7 +317,8 @@ class SEs extends S {
String get logsCleared => 'Registros borrados';
@override
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
String get playerListCopied =>
'Lista de reproductores copiada al portapapeles';
@override
String get noLogsYet => 'Aún no hay registros';
@@ -471,37 +488,43 @@ class SEs extends S {
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@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
String get noAudiobooks => 'No hay audiolibros';
@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
String get noFavoriteArtists => 'No hay artistas favoritos';
@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
String get noFavoriteAlbums => 'No hay álbumes favoritos';
@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
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
String get tapHeartPlaylist =>
'Toca el corazón en una lista para añadirla a favoritos';
@override
String get noFavoriteTracks => 'No hay canciones favoritas';
@override
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
String get longPressTrackHint =>
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override
String get loadSeries => 'Cargar series';
@@ -513,7 +536,8 @@ class SEs extends S {
String get notConnectedTitle => 'No conectado';
@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
String get configureServer => 'Configurar servidor';
@@ -549,7 +573,8 @@ class SEs extends S {
String get noLogsToCopy => 'No hay registros para copiar';
@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
String get showDebug => 'Mostrar depuración';
@@ -564,7 +589,8 @@ class SEs extends S {
String get noChapters => 'Sin capítulos';
@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
String errorSeeking(String error) {
@@ -637,7 +663,8 @@ class SEs extends S {
String get artist => 'Artista';
@override
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
String get showRecentlyPlayedAlbums =>
'Mostrar álbumes reproducidos recientemente';
@override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@@ -649,34 +676,41 @@ class SEs extends S {
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
String get showRandomAudiobooks =>
'Mostrar audiolibros aleatorios para descubrir';
@override
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
String get showRandomSeries =>
'Mostrar series de audiolibros aleatorias para descubrir';
@override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
String get showFavoriteArtists =>
'Mostrar una fila de tus artistas favoritos';
@override
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
String get showFavoriteTracks =>
'Mostrar una fila de tus canciones favoritas';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
String get extractColorsFromArtwork =>
'Extraer colores de las portadas de álbumes y artistas';
@override
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get addedToFavorites => 'Añadido a favoritos';
@@ -774,7 +808,8 @@ class SEs extends S {
String get loadingChapters => 'Cargando capítulos...';
@override
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
String get noChapterInfoAvailable =>
'No hay información de capítulos disponible';
@override
String percentComplete(int percent) {
@@ -785,13 +820,15 @@ class SEs extends S {
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
String get theAudioDbApiKeyHint =>
'Usa \"2\" para nivel gratuito o clave premium';
@override
String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
String get chooseAudiobookLibraries =>
'Elige qué bibliotecas de Audiobookshelf incluir';
@override
String get unknownLibrary => 'Biblioteca desconocida';
@@ -800,7 +837,8 @@ class SEs extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +858,19 @@ class SEs extends S {
}
@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
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
String get search => 'Buscar';
@override
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
String get metadataApisDescription =>
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override
String get lastFmApiKey => 'Clave API de Last.fm';
@@ -901,13 +942,22 @@ class SEs extends S {
String get preferLocalPlayer => 'Preferir dispositivo local';
@override
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
String get preferLocalPlayerDescription =>
'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override
String get smartSortPlayers => 'Orden inteligente';
@override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
String get smartSortPlayersDescription =>
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'No disponible';
@@ -939,7 +989,18 @@ class SEs extends S {
String get showHints => 'Mostrar Sugerencias';
@override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@@ -957,13 +1018,15 @@ class SEs extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -979,19 +1042,22 @@ class SEs extends S {
String get noTracks => 'No tracks';
@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
String get noFavoriteRadioStations => 'No favorite radio stations';
@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
String get noFavoritePodcasts => 'No favorite podcasts';
@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
String failedToPlayRadioStation(String error) {
@@ -1076,4 +1142,25 @@ class SEs extends S {
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
}
+155 -59
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SFr extends S {
String get portHint => 'ex., 8095 ou laisser vide';
@override
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
String get portDescription =>
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override
String get username => 'Nom d\'utilisateur';
@@ -60,10 +61,12 @@ class SFr extends S {
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
String get authServerUrlHint =>
'ex., auth.example.com (si different du serveur)';
@override
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
String get authServerUrlDescription =>
'Laisser vide si l\'authentification est sur le meme serveur';
@override
String detectedAuthType(String authType) {
@@ -80,40 +83,50 @@ class SFr extends S {
String get unencryptedConnection => 'Connexion non chiffrée';
@override
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
String get usingHttpOverTailscale =>
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
String get httpsFailedUsingHttp =>
'HTTPS a échoué, utilisation de HTTP en repli';
@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
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
String get pleaseEnterName => 'Veuillez entrer votre nom';
@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
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
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
String get authFailed =>
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
String get maLoginFailed =>
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
String get connectionFailed =>
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override
String get detectingAuth => 'Detection de l\'authentification...';
@override
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
String get cannotDetermineAuth =>
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override
String get noAuthentication => 'Pas d\'authentification';
@@ -128,7 +141,8 @@ class SFr extends S {
String get musicAssistantLogin => 'Connexion Music Assistant';
@override
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
String get pressBackToMinimize =>
'Appuyez a nouveau sur retour pour minimiser';
@override
String get recentlyPlayed => 'Ecoutes recemment';
@@ -221,10 +235,12 @@ class SFr extends S {
String get noRadioStations => 'Aucune station de radio';
@override
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
String get addRadioStationsHint =>
'Ajouter des stations de radio dans Music Assistant';
@override
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
String get searchFailed =>
'La recherche a échoué. Veuillez verifier votre connexion.';
@override
String get queue => 'File d\'attente';
@@ -264,7 +280,8 @@ class SFr extends S {
String get system => 'Système';
@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
String get viewDebugLogs => 'Voir les journaux de debogage';
@@ -301,7 +318,8 @@ class SFr extends S {
String get logsCleared => 'Journaux effaces';
@override
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
String get playerListCopied =>
'Liste des lecteurs copiee dans le presse-papiers !';
@override
String get noLogsYet => 'Pas encore de journaux';
@@ -471,37 +489,43 @@ class SFr extends S {
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
String get tapHeartAudiobook =>
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override
String get noAudiobooks => 'Aucun livre audio';
@override
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
String get addAudiobooksHint =>
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override
String get noFavoriteArtists => 'Aucun artiste favori';
@override
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
String get tapHeartArtist =>
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override
String get noFavoriteAlbums => 'Aucun album favori';
@override
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
String get tapHeartAlbum =>
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override
String get noFavoritePlaylists => 'Aucune playlist favorite';
@override
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
String get tapHeartPlaylist =>
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override
String get noFavoriteTracks => 'Aucune piste favorite';
@override
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
String get longPressTrackHint =>
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override
String get loadSeries => 'Charger les séries';
@@ -513,7 +537,8 @@ class SFr extends S {
String get notConnectedTitle => 'Not Connected';
@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
String get configureServer => 'Configurer le serveur';
@@ -549,7 +574,8 @@ class SFr extends S {
String get noLogsToCopy => 'Aucun journal a copier';
@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
String get showDebug => 'Afficher le debogage';
@@ -564,7 +590,8 @@ class SFr extends S {
String get noChapters => 'Aucun chapitre';
@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
String errorSeeking(String error) {
@@ -637,10 +664,12 @@ class SFr extends S {
String get artist => 'Artiste';
@override
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
String get showRecentlyPlayedAlbums =>
'Afficher les albums écoutes recemment';
@override
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
String get showRandomArtists =>
'Afficher des artistes aléatoires a découvrir';
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@@ -649,34 +678,43 @@ class SFr extends S {
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@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
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
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
String get showFavoriteArtists =>
'Afficher une rangee de vos artistes favoris';
@override
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
String get showFavoriteTracks =>
'Afficher une rangee de vos pistes favorites';
@override
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
String get showFavoritePlaylists =>
'Afficher une rangee de vos playlists favorites';
@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
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
String get showFavoritePodcasts =>
'Afficher une rangee de vos podcasts favoris';
@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
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 addedToFavorites => 'Ajoute aux favoris';
@@ -721,13 +759,15 @@ class SFr extends S {
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
String get podcastSupportComingSoon =>
'Prise en charge des podcasts bientot disponible';
@override
String get noPodcasts => 'Aucun podcast';
@override
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
String get addPodcastsHint =>
'Abonnez-vous a des podcasts dans Music Assistant';
@override
String get episodes => 'Episodes';
@@ -774,7 +814,8 @@ class SFr extends S {
String get loadingChapters => 'Chargement des chapitres...';
@override
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
String get noChapterInfoAvailable =>
'Aucune information de chapitre disponible';
@override
String percentComplete(int percent) {
@@ -785,13 +826,15 @@ class SFr extends S {
String get theAudioDbApiKey => 'Cle API TheAudioDB';
@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
String get audiobookLibraries => 'Bibliothèques de livres audio';
@override
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
String get chooseAudiobookLibraries =>
'Choisir quelles bibliothèques Audiobookshelf inclure';
@override
String get unknownLibrary => 'Bibliothèque inconnue';
@@ -800,7 +843,8 @@ class SFr extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +864,19 @@ class SFr extends S {
}
@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
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
String get search => 'Rechercher';
@override
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
String get metadataApisDescription =>
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override
String get lastFmApiKey => 'Cle API Last.fm';
@@ -852,7 +899,8 @@ class SFr extends S {
String get playOn => 'Lire sur...';
@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 addToQueueOn => 'Ajouter a la file d\'attente sur...';
@@ -889,7 +937,8 @@ class SFr extends S {
}
@override
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
String get materialYouDescription =>
'Utiliser les couleurs systeme (Android 12+)';
@override
String get accentColor => 'Couleur d\'accentuation';
@@ -901,13 +950,22 @@ class SFr extends S {
String get preferLocalPlayer => 'Préférer le lecteur local';
@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
String get smartSortPlayers => 'Tri intelligent';
@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
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Indisponible';
@@ -939,7 +997,18 @@ class SFr extends S {
String get showHints => 'Afficher les astuces';
@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 display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@@ -951,19 +1020,22 @@ class SFr extends S {
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@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
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
String get welcomeMessage =>
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override
String get skip => 'Passer';
@override
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
String get dismissPlayerHint =>
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override
String playingAlbum(String albumName) {
@@ -979,19 +1051,22 @@ class SFr extends S {
String get noTracks => 'No tracks';
@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
String get noFavoriteRadioStations => 'No favorite radio stations';
@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
String get noFavoritePodcasts => 'No favorite podcasts';
@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
String failedToPlayRadioStation(String error) {
@@ -1076,4 +1151,25 @@ class SFr extends S {
@override
String get transferQueueToPlayer => 'Transfer queue to another player';
@override
String get selectPlayerToPlay => 'Select a player';
@override
String get selectPlayerToPlayAlbum => 'Select player to play album';
@override
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
@override
String get selectPlayerForRadio => 'Select player for radio';
@override
String get selectPlayerForEpisode => 'Select player for episode';
@override
String get selectPlayerForAudiobook => 'Select player for audiobook';
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
}
+2 -1
View File
@@ -223,8 +223,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
// Then check expanded player
// No haptic - Android back gesture provides its own
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
+24 -4
View File
@@ -126,6 +126,17 @@ class MediaItem {
if (duration != null) 'duration': duration!.inSeconds,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaItem &&
runtimeType == other.runtimeType &&
itemId == other.itemId &&
provider == other.provider;
@override
int get hashCode => Object.hash(itemId, provider);
}
class Artist extends MediaItem {
@@ -429,9 +440,20 @@ class Audiobook extends MediaItem {
factory Audiobook.fromJson(Map<String, dynamic> json) {
final item = MediaItem.fromJson(json);
// Parse year
// Parse year - check top level first, then metadata.release_date (ABS format)
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) {
year = yearValue;
} else if (yearValue is String) {
@@ -461,8 +483,6 @@ class Audiobook extends MediaItem {
// Parse chapters - check top level first, then metadata
List<Chapter>? chapters;
final metadata = json['metadata'] as Map<String, dynamic>?;
dynamic chaptersData = json['chapters'];
if (chaptersData == null && metadata != null) {
chaptersData = metadata['chapters'];
+65
View File
@@ -0,0 +1,65 @@
/// Represents a provider manifest from Music Assistant.
///
/// Contains metadata about a provider type including its icons.
/// This is separate from ProviderInstance which represents a configured
/// instance of a provider.
class ProviderManifest {
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this provider type
final String name;
/// Description of the provider
final String? description;
/// SVG icon as XML string
final String? iconSvg;
/// SVG icon for dark theme as XML string
final String? iconSvgDark;
/// Monochrome SVG icon as XML string
final String? iconSvgMonochrome;
/// Material Design icon name (fallback)
final String? icon;
ProviderManifest({
required this.domain,
required this.name,
this.description,
this.iconSvg,
this.iconSvgDark,
this.iconSvgMonochrome,
this.icon,
});
/// Whether this manifest has any SVG icon available
bool get hasSvgIcon =>
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
/// Get the best available SVG icon for the given brightness
/// Prefers dark icon for dark themes, falls back to regular or monochrome
String? getSvgIcon({bool isDark = false}) {
if (isDark && iconSvgDark != null) {
return iconSvgDark;
}
return iconSvg ?? iconSvgMonochrome;
}
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
return ProviderManifest(
domain: json['domain'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
iconSvg: json['icon_svg'] as String?,
iconSvgDark: json['icon_svg_dark'] as String?,
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
icon: json['icon'] as String?,
);
}
@override
String toString() => 'ProviderManifest($domain: $name)';
}
+245
View File
@@ -0,0 +1,245 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
/// Callback types for connection events
typedef OnConnected = Future<void> Function();
typedef OnAuthenticated = Future<void> Function();
typedef OnDisconnected = void Function();
/// Provider for connection state management.
///
/// Extracted from MusicAssistantProvider to handle:
/// - WebSocket connection lifecycle
/// - Authentication flow (MA token, credentials)
/// - Reconnection logic
/// - Connection state broadcasting
///
/// Note: This is a partial extraction. Full integration requires updating
/// all consumers to use this provider directly.
class ConnectionProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final AuthManager _authManager = AuthManager();
MusicAssistantAPI? _api;
String? _serverUrl;
String? _error;
MAConnectionState _connectionState = MAConnectionState.disconnected;
StreamSubscription? _connectionStateSubscription;
// Callbacks for connection events - set by the facade provider
OnConnected? onConnected;
OnAuthenticated? onAuthenticated;
OnDisconnected? onDisconnected;
// User settings captured during authentication
List<String> _providerFilter = [];
List<String> _playerFilter = [];
// ============================================================================
// GETTERS
// ============================================================================
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
String? get serverUrl => _serverUrl;
String? get error => _error;
MAConnectionState get connectionState => _connectionState;
bool get isConnected =>
_connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
List<String> get providerFilter => _providerFilter;
List<String> get playerFilter => _playerFilter;
// ============================================================================
// CONNECTION
// ============================================================================
/// Connect to the Music Assistant server
Future<void> connect(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await _handleAuthentication();
if (!authenticated) {
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required, trigger callback
await onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
await onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_logger.log('📡 Disconnected - keeping cached data for instant resume');
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<bool> _handleAuthentication() async {
if (_api == null) return false;
try {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null &&
password != null &&
username.isNotEmpty &&
password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchUserSettings();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
final profileName =
(displayName != null && displayName.isNotEmpty) ? displayName : username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log(
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
}
} else {
_providerFilter = [];
}
// Capture player filter
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log(
'🔒 Player filter active: ${_playerFilter.length} players allowed');
}
} else {
_playerFilter = [];
}
} catch (e) {
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
/// Disconnect from the server
Future<void> disconnect() async {
_connectionStateSubscription?.cancel();
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
notifyListeners();
}
/// Check connection and reconnect if needed
Future<void> checkAndReconnect() async {
if (_serverUrl != null && !isConnected) {
_logger.log('🔄 Attempting reconnection...');
await connect(_serverUrl!);
}
}
@override
void dispose() {
_connectionStateSubscription?.cancel();
_api?.dispose();
super.dispose();
}
}
+393 -99
View File
@@ -8,6 +8,7 @@ import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
@@ -23,6 +24,7 @@ import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../services/remote/remote_bridge.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../main.dart' show audioHandler;
@@ -63,6 +65,8 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
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
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -70,6 +74,16 @@ class MusicAssistantProvider with ChangeNotifier {
Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
// Sleep timer state
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
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
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -92,6 +106,10 @@ class MusicAssistantProvider with ChangeNotifier {
SendspinService? _sendspinService;
bool _sendspinConnected = false;
// Remote connection (WebRTC-to-WS bridge for remote access)
RemoteBridge? _remoteBridge;
bool get isRemoteConnection => _remoteBridge != null && _remoteBridge!.isRunning;
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
@@ -221,6 +239,21 @@ class MusicAssistantProvider with ChangeNotifier {
/// List of available music providers from MA
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
/// Get all provider manifests (contains icons)
Map<String, ProviderManifest> get providerManifests =>
_api?.providerManifests ?? {};
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _api?.getProviderManifest(domain);
}
/// List of enabled provider instance IDs (empty = all enabled)
List<String> get enabledProviderIds => _enabledProviderIds;
@@ -347,22 +380,40 @@ class MusicAssistantProvider with ChangeNotifier {
// 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)
// 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 addedInstanceIds = <String>{};
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
if (count > 0) {
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
final existingCount = addedNames[provider.name];
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
// (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) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID
@@ -371,6 +422,11 @@ class MusicAssistantProvider with ChangeNotifier {
final domain = instanceId.contains('--')
? instanceId.split('--').first
: 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
final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) {
@@ -383,6 +439,7 @@ class MusicAssistantProvider with ChangeNotifier {
),
entry.value,
));
addedSyntheticDomains.add(domain);
}
}
}
@@ -510,6 +567,15 @@ class MusicAssistantProvider with ChangeNotifier {
Track? get currentTrack => _currentTrack;
// Sleep timer getters
bool get sleepTimerActive => _sleepTimerMinutes != null;
int? get sleepTimerMinutes => _sleepTimerMinutes;
Duration? get sleepTimerRemaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
@@ -1064,6 +1130,61 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Connect to a remote MA server via WebRTC using Remote Access ID.
/// Uses a WebRTC-to-WebSocket bridge so MusicAssistantAPI works unchanged.
Future<bool> connectRemote(String remoteId, String username, String password) async {
_logger.log('🌐 Attempting remote connection to: $remoteId');
try {
_error = null;
_connectionState = MAConnectionState.connecting;
notifyListeners();
// Start the WebRTC-to-WS bridge
_remoteBridge = RemoteBridge();
final port = await _remoteBridge!.start(remoteId, username, password);
if (port == null) {
_logger.log('❌ Remote bridge failed to start');
_remoteBridge = null;
_connectionState = MAConnectionState.error;
_error = 'Remote connection failed';
notifyListeners();
return false;
}
_logger.log('✅ Remote bridge started on port $port');
// Connect via normal API - it will hit the local bridge
// The bridge already authenticated, so we use the same credentials
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
await connectToServer('ws://localhost:$port');
// Wait a moment for connection to establish
await Future.delayed(const Duration(milliseconds: 500));
if (isConnected) {
_logger.log('✅ Remote connection via bridge successful!');
return true;
} else {
_logger.log('❌ Connection to bridge failed, stopping bridge');
await _remoteBridge?.stop();
_remoteBridge = null;
return false;
}
} catch (e) {
_logger.log('❌ Remote connection error: $e');
await _remoteBridge?.stop();
_remoteBridge = null;
_error = e.toString();
_connectionState = MAConnectionState.error;
notifyListeners();
return false;
}
}
Future<bool> _handleMaAuthentication() async {
if (_api == null) return false;
@@ -1219,11 +1340,23 @@ class MusicAssistantProvider with ChangeNotifier {
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
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
// Persist to settings (async, but don't wait for UI update)
SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
@@ -1743,6 +1876,11 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinConnected = false;
}
await _api?.disconnect();
// Stop remote bridge if running
if (_remoteBridge != null) {
await _remoteBridge!.stop();
_remoteBridge = null;
}
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches or player state - keep for instant reconnect
_logger.log('📡 Explicit disconnect - keeping cached data for instant resume');
@@ -1929,24 +2067,21 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20;
}
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
/// 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
/// Check if server has built-in Sendspin proxy (added in MA 2.7.1)
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
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 false;
// major == 2
if (minor > 8) return true;
if (minor < 8) return false;
// minor == 8, any 2.8.x version has the proxy
return true;
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7, need patch >= 1
return patch >= 1;
}
/// Get the server version string for logging
@@ -2059,7 +2194,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// This is the replacement for builtin_player when that API is not available.
///
/// 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:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
@@ -2075,7 +2210,7 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService?.dispose();
_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();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
@@ -2126,10 +2261,10 @@ class MusicAssistantProvider with ChangeNotifier {
}
_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...');
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) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
@@ -2140,7 +2275,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
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 {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
@@ -2181,7 +2316,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} 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('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
@@ -2330,6 +2465,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Initialize notification with position 0
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
@@ -2370,6 +2506,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Show paused state with preserved position
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: false,
@@ -3712,8 +3849,13 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Log settings for debugging player selection issues
_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
// This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) {
@@ -3724,12 +3866,12 @@ class MusicAssistantProvider with ChangeNotifier {
_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)
// But allow switching to a playing player when preferLocalPlayer is OFF
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
// On coldStart, skip this block and apply full priority logic
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// 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
@@ -3756,71 +3898,73 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
);
// If preferLocalPlayer is OFF, check if we should switch to a playing player
if (!preferLocalPlayer) {
final currentIsPlaying = currentPlayer.state == 'playing';
// Exclude external sources - they're not playing MA content
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
// Switch to playing player only if current isn't playing and exactly one other is
if (!currentIsPlaying && playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
}
}
// Keep current selection if no switch happened
if (playerToSelect == null) {
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
}
// 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;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
}
}
}
if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)');
_logger.log('🚀 Cold start: applying full priority logic');
}
if (playerToSelect == null) {
// Smart auto-selection priority:
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
// Smart auto-selection priority (coldStart only for auto-switch to playing):
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
// 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
if (!preferLocalPlayer) {
// Priority 1 (normal): Single playing player (skip if multiple are playing)
// Exclude external sources - they're not playing MA content
// 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
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}');
}
// 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');
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
// Priority 3: Last manually selected player
@@ -3840,6 +3984,7 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
}
@@ -3875,12 +4020,10 @@ class MusicAssistantProvider with ChangeNotifier {
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
// For grouped child players, this returns the leader's track
// IMPORTANT: Clear track for external sources - they're playing non-MA content
if (player.isExternalSource) {
_currentTrack = null;
} else {
_currentTrack = getCachedTrackForPlayer(player.playerId);
}
// NOTE: Always use cache even for external sources - the overlay shows cached
// track info, so we should match that. The async _updatePlayerState() will
// clear it if the player is truly playing external content.
_currentTrack = getCachedTrackForPlayer(player.playerId);
// Initialize position tracker for this player (skip for external sources)
_positionTracker.onPlayerSelected(player.playerId);
@@ -3931,6 +4074,10 @@ class MusicAssistantProvider with ChangeNotifier {
item: mediaItem,
playing: player.state == 'playing',
);
} else {
// Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
} else {
// For remote players, immediately show notification if we have cached track info
@@ -3952,6 +4099,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Use position tracker for consistent position
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
@@ -3967,12 +4115,18 @@ class MusicAssistantProvider with ChangeNotifier {
artist: 'Loading...',
);
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
position: position,
duration: Duration.zero,
);
} else {
// Player is idle - clear the notification to prevent stale metadata
// from previous player showing (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
}
@@ -4084,40 +4238,33 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Update just the notification position (called every 500ms for remote/Sendspin players)
void _updateNotificationPosition() {
/// Set [forceUpdate] to true to update even when paused (used after seek)
void _updateNotificationPosition({bool forceUpdate = false}) {
if (_selectedPlayer == null || _currentTrack == null) {
_notificationPositionTimer?.cancel();
return;
}
// Don't update if player is not playing
if (_selectedPlayer!.state != 'playing') {
final isPlaying = _selectedPlayer!.state == 'playing';
// Don't update if player is not playing (unless forced for seek)
if (!isPlaying && !forceUpdate) {
_notificationPositionTimer?.cancel();
return;
}
final track = _currentTrack!;
Duration position;
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
// For remote players, use the position tracker (server-based interpolation)
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
position = _pcmAudioPlayer!.elapsedTime;
// Always use position tracker for notification position - it's the single source of truth.
// Previously used PCM elapsed time for Sendspin, but this breaks when:
// 1. Stream starts mid-track (resume from pause) - PCM elapsed resets to 0
// 2. Switching between multiple Sendspin players - PCM elapsed doesn't match
final position = _positionTracker.currentPosition;
// Check if track has ended based on PCM elapsed time
if (track.duration != null && position >= track.duration!) {
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)');
return;
}
} else {
// Use position tracker for remote players
position = _positionTracker.currentPosition;
// Check if track has ended (position reached duration)
if (_positionTracker.hasReachedEnd) {
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
return;
}
// Check if track has ended (position reached duration)
if (_positionTracker.hasReachedEnd) {
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
return;
}
final artworkUrl = _api?.getImageUrl(track, size: 512);
@@ -4133,9 +4280,10 @@ class MusicAssistantProvider with ChangeNotifier {
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
playing: isPlaying,
position: position,
duration: track.duration,
);
@@ -4317,9 +4465,33 @@ class MusicAssistantProvider with ChangeNotifier {
_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 {
if (_selectedPlayer == null || _api == null) return;
// Capture player ID at start to detect stale operations
// If player changes during async operations, we should abort
final operationPlayerId = _selectedPlayer!.playerId;
try {
bool stateChanged = false;
@@ -4338,6 +4510,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Try to refresh from API first for latest state
final allPlayers = await getPlayers();
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during operation, aborting');
return;
}
Player? rawPlayer = allPlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
@@ -4394,6 +4573,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
if (stateChanged) {
notifyListeners();
}
@@ -4448,6 +4628,12 @@ class MusicAssistantProvider with ChangeNotifier {
final queue = await getQueue(_selectedPlayer!.playerId);
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during getQueue, aborting');
return;
}
if (queue != null && queue.currentItem != null) {
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null ||
@@ -4455,6 +4641,11 @@ class MusicAssistantProvider with ChangeNotifier {
_currentTrack!.name != queueTrack.name;
if (trackChanged) {
// Check "end of track" sleep timer - previous track just ended
if (_currentTrack != null) {
checkEndOfTrackSleepTimer();
}
// Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app
// For grouped child players, this gets the leader's cached track
@@ -4569,6 +4760,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
@@ -4603,6 +4795,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
} else {
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
@@ -4612,6 +4805,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
} else {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
}
}
@@ -4839,6 +5033,9 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
notifyListeners();
} catch (e) {
@@ -4871,6 +5068,9 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
notifyListeners();
} catch (e) {
@@ -5293,6 +5493,94 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// ============================================================================
// SLEEP TIMER
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setSleepTimer(int? minutes) {
// Cancel any existing timer
_cancelSleepTimerInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onSleepTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancelSleepTimer() {
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelSleepTimerInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onSleepTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrackSleepTimer() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
}
/// Sync a player to the currently selected player (temporary group)
/// The target player will play the same audio as the selected player
Future<void> syncPlayerToSelected(String targetPlayerId) async {
@@ -5563,6 +5851,9 @@ class MusicAssistantProvider with ChangeNotifier {
try {
// Immediately update position tracker for responsive UI
_positionTracker.onSeek(position.toDouble());
// Immediately update notification position to stay in sync with app
// Use forceUpdate since seek can happen while paused
_updateNotificationPosition(forceUpdate: true);
await _api?.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
@@ -5723,6 +6014,9 @@ class MusicAssistantProvider with ChangeNotifier {
_playerStateTimer?.cancel();
_notificationPositionTimer?.cancel();
_localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel();
_idleServiceTimer?.cancel();
_connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/debug_logger.dart';
/// Callback type for when sleep timer expires and should pause playback
typedef OnSleepTimerExpired = void Function();
/// Provider for sleep timer functionality.
///
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
/// This provider manages:
/// - Timed sleep (pause after X minutes)
/// - End of track sleep (pause when current track ends)
class SleepTimerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer;
/// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired;
// ============================================================================
// GETTERS
// ============================================================================
/// Whether a sleep timer is currently active
bool get isActive => _sleepTimerMinutes != null;
/// Current timer setting: null = off, -1 = end of track, positive = minutes
int? get minutes => _sleepTimerMinutes;
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
Duration? get remaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
// ============================================================================
// TIMER CONTROL
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setTimer(int? minutes) {
// Cancel any existing timer
_cancelInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancel() {
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrack() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
}
@override
void dispose() {
_cancelInternal();
super.dispose();
}
}
+46 -53
View File
@@ -10,7 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
@@ -878,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
@@ -1257,13 +1271,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(
@@ -1272,48 +1281,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
startIndex: startIndex,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex];
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
String _formatDuration(Duration duration) {
+100 -186
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
@@ -366,6 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isInLibrary = !newState;
});
}
return false;
});
} else {
// Remove from library
@@ -413,6 +415,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isInLibrary = !newState;
});
}
return false;
});
}
} catch (e) {
@@ -430,171 +433,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.startRadioOn(widget.artist.name),
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, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
}
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToAddToQueue,
onPlayerSelected: (player) async {
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
}
Future<void> _loadArtistImage() async {
@@ -1100,37 +1005,46 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
children: [
AspectRatio(
aspectRatio: 1.0, // Square album art
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
children: [
Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
// Provider icon overlay
if (album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: album.providerMappings!.first.providerDomain,
),
],
),
),
Spacing.vGap8,
+6 -3
View File
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -407,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
minHeight: 3,
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
+50 -43
View File
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -212,13 +212,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
@@ -227,9 +222,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
widget.audiobook,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
Future<void> _markAsFinished() async {
@@ -505,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+51 -35
View File
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../widgets/provider_icon.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
// Progress indicator
if (book.progress > 0)
Positioned(
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
child: SizedBox(
width: 56,
height: 56,
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
size: 16,
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
),
),
),
+228 -5
View File
@@ -5,11 +5,12 @@ import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
import 'home_screen.dart';
enum ConnectionMode { local, remote }
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@@ -24,8 +25,10 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
final FocusNode _usernameFocusNode = FocusNode();
ConnectionMode _connectionMode = ConnectionMode.local;
bool _isConnecting = false;
bool _isDetectingAuth = false;
AuthStrategy? _detectedAuthStrategy;
@@ -74,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
_authServerUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_remoteIdController.dispose();
_usernameFocusNode.dispose();
super.dispose();
}
@@ -477,6 +481,68 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
Future<void> _connectRemote() async {
final remoteId = _remoteIdController.text.trim();
if (remoteId.isEmpty) {
setState(() {
_error = 'Please enter a Remote Access ID';
});
return;
}
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterCredentials;
});
return;
}
setState(() {
_isConnecting = true;
_error = null;
});
_addDebugLog('Connecting to remote server: $remoteId');
try {
final provider = context.read<MusicAssistantProvider>();
// Connect via remote access
final success = await provider.connectRemote(remoteId, username, password);
if (success) {
_addDebugLog('Remote connection successful');
// Save credentials for future use
await SettingsService.setRemoteAccessId(remoteId);
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Navigate to home screen
if (mounted) {
FocusScope.of(context).unfocus();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
} else {
setState(() {
_error = 'Remote connection failed. Check your credentials and Remote Access ID.';
_isConnecting = false;
});
}
} catch (e) {
_addDebugLog('Remote connection error: $e');
setState(() {
_error = 'Connection failed: ${e.toString()}';
_isConnecting = false;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -509,8 +575,160 @@ class _LoginScreenState extends State<LoginScreen> {
},
),
const SizedBox(height: 48),
const SizedBox(height: 32),
// Connection mode selector
SegmentedButton<ConnectionMode>(
segments: [
ButtonSegment<ConnectionMode>(
value: ConnectionMode.local,
label: Text('Local'),
icon: Icon(Icons.home_rounded),
),
ButtonSegment<ConnectionMode>(
value: ConnectionMode.remote,
label: Text('Remote'),
icon: Icon(Icons.cloud_rounded),
),
],
selected: {_connectionMode},
onSelectionChanged: _isConnecting || _isDetectingAuth
? null
: (Set<ConnectionMode> selection) {
setState(() {
_connectionMode = selection.first;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
});
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primaryContainer;
}
return colorScheme.surfaceVariant.withOpacity(0.3);
}),
),
),
const SizedBox(height: 32),
// Remote Access UI
if (_connectionMode == ConnectionMode.remote) ...[
Text(
'Remote Access ID',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Find this in Music Assistant Settings → Remote Access',
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
TextField(
controller: _remoteIdController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'XXXX-XXXX-XXXX-XXXX',
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.vpn_key_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textCapitalization: TextCapitalization.characters,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
// Username
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),
// Password
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: (_) => _connectRemote(),
),
const SizedBox(height: 24),
],
// Local connection UI
if (_connectionMode == ConnectionMode.local) ...[
// Server URL
Text(
S.of(context)!.serverAddress,
@@ -856,6 +1074,7 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
],
], // End of local connection UI
const SizedBox(height: 16),
@@ -898,11 +1117,13 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
],
// Connect button (changes based on state)
// Connect button (changes based on state and mode)
ElevatedButton(
onPressed: (_isConnecting || _isDetectingAuth)
? null
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
: _connectionMode == ConnectionMode.remote
? _connectRemote
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -944,7 +1165,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: Text(
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
_connectionMode == ConnectionMode.remote
? S.of(context)!.connect
: (_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
+42 -42
View File
@@ -9,7 +9,7 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
@@ -378,20 +378,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _addPlaylistToQueue() {
@@ -552,20 +545,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex];
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
void _showError(String message) {
@@ -706,30 +692,44 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
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,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+50 -43
View File
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -308,22 +308,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
String _formatDuration(Duration? duration) {
@@ -486,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+18 -61
View File
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
@@ -2473,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioOnMenu(Track track) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -2500,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Add track to queue on current player (1 tap)
@@ -2943,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAlbumPlayOnMenu(Album album) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -2970,9 +2957,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Add album to queue
@@ -3122,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPlaylistPlayOnMenu(Playlist playlist) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -3149,9 +3129,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Play audiobook on current player
@@ -3189,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -3216,9 +3189,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Toggle audiobook favorite status
@@ -3306,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioPlayOnMenu(MediaItem radio) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
try {
maProvider.selectPlayer(player);
@@ -3333,9 +3299,7 @@ class SearchScreenState extends State<SearchScreen> {
}
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
/// Play podcast (navigate to detail screen where episodes can be played)
@@ -3363,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPodcastPlayOnMenu(MediaItem podcast) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlay,
onPlayerSelected: (player) async {
// Select the player, then navigate to podcast detail
maProvider.selectPlayer(player);
@@ -3389,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
);
}
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
);
}
String _formatDuration(Duration duration) {
+73
View File
@@ -41,9 +41,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
@override
void initState() {
@@ -84,11 +87,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -106,8 +113,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
});
}
}
@@ -746,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
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(
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -811,6 +845,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 24),
// Display section
Text(
S.of(context)!.display,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
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)!.showProviderIcons,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.showProviderIconsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
value: _showProviderIcons,
onChanged: (value) {
setState(() => _showProviderIcons = value);
SettingsService.setShowProviderIcons(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
@@ -157,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// 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
Future<void> seek(Duration position) => _player.seek(position);
+7
View File
@@ -193,6 +193,13 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]);
}
/// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500.
Future<void> batchCacheItems(List<BatchCacheItem> items) {
return db.batchCacheItems(items);
}
// ============================================
// Player Cache Convenience Methods
// ============================================
+101 -17
View File
@@ -2,14 +2,19 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'debug_logger.dart';
import 'settings_service.dart';
import '../utils/lru_cache.dart';
class MetadataService {
static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {};
// Cache for artist images
static final Map<String, String?> _artistImageCache = {};
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
// Using NullableLruCache for caches that store null values (failed lookups)
/// Cache for artist/album descriptions (100 entries)
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
/// Cache for artist images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
/// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in)
@@ -34,8 +39,8 @@ class MetadataService {
// Check cache
final cacheKey = 'artist:$artistName';
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey];
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API
@@ -43,7 +48,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc;
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
@@ -53,7 +58,7 @@ class MetadataService {
if (audioDbKey != null && audioDbKey.isNotEmpty) {
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
if (audioDbDesc != null) {
_cache[cacheKey] = audioDbDesc;
_descriptionCache[cacheKey] = audioDbDesc;
return audioDbDesc;
}
}
@@ -83,8 +88,8 @@ class MetadataService {
// Check cache
final cacheKey = 'album:$artistName:$albumName';
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey];
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API (TheAudioDB doesn't have good album info)
@@ -92,7 +97,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc;
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
@@ -251,11 +256,11 @@ class MetadataService {
return null;
}
// Cache for album images
static final Map<String, String?> _albumImageCache = {};
/// Cache for album images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
// Cache for author images (audiobooks)
static final Map<String, String?> _authorImageCache = {};
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
/// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
@@ -740,11 +745,90 @@ class MetadataService {
return null;
}
/// Clears the metadata cache
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
static final Map<String, (String?, String?)?> _lyricsCache = {};
static const int _lyricsCacheMaxSize = 500;
/// Fetches lyrics from LRCLIB (free, no API key required)
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
/// LRCLIB provides both synced (LRC format) and plain lyrics
static Future<(String?, String?)?> getTrackLyrics({
required String trackName,
required String artistName,
String? albumName,
int? durationSeconds,
}) async {
// Build cache key
final cacheKey = 'lyrics:$artistName:$trackName';
if (_lyricsCache.containsKey(cacheKey)) {
return _lyricsCache[cacheKey];
}
// Try LRCLIB API
try {
final params = <String, String>{
'track_name': trackName,
'artist_name': artistName,
};
if (albumName != null && albumName.isNotEmpty) {
params['album_name'] = albumName;
}
if (durationSeconds != null && durationSeconds > 0) {
params['duration'] = durationSeconds.toString();
}
final uri = Uri.https('lrclib.net', '/api/get', params);
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
final response = await http.get(
uri,
headers: {
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final plainLyrics = data['plainLyrics'] as String?;
final syncedLyrics = data['syncedLyrics'] as String?;
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
final result = (plainLyrics, syncedLyrics);
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = result;
return result;
}
} else if (response.statusCode == 404) {
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
} else {
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
}
} catch (e) {
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = null;
return null;
}
/// Evict oldest entries from lyrics cache if over max size
static void _evictLyricsCacheIfNeeded() {
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
_lyricsCache.remove(_lyricsCache.keys.first);
}
}
/// Clears all metadata caches
static void clearCache() {
_cache.clear();
_descriptionCache.clear();
_artistImageCache.clear();
_albumImageCache.clear();
_authorImageCache.clear();
_lyricsCache.clear();
}
}
+107 -8
View File
@@ -9,6 +9,7 @@ import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
@@ -60,6 +61,10 @@ class MusicAssistantAPI {
int? get schemaVersion => _schemaVersion;
Map<String, dynamic>? get serverInfo => _serverInfo;
// Provider manifests (contains icons)
final Map<String, ProviderManifest> _providerManifests = {};
Map<String, ProviderManifest> get providerManifests => _providerManifests;
MusicAssistantAPI(this.serverUrl, this.authManager);
// Guard to prevent multiple simultaneous connection attempts
@@ -80,13 +85,15 @@ class MusicAssistantAPI {
return;
}
// If connection is in progress, wait for it instead of starting another
// Atomic check-and-set: create new completer first, then check if one exists
// This ensures no async operations happen between check and set
final newCompleter = Completer<void>();
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<void>();
// Set immediately before any async operations to close the race window
_connectionInProgress = newCompleter;
try {
_updateConnectionState(MAConnectionState.connecting);
@@ -345,6 +352,14 @@ class MusicAssistantAPI {
}
}
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
'Too many pending requests (${_pendingRequests.length}). '
'Please wait for existing requests to complete.',
);
}
final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
@@ -794,6 +809,15 @@ class MusicAssistantAPI {
}
}
_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([]);
}
}
}
@@ -811,10 +835,13 @@ class MusicAssistantAPI {
// Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR
// 2. We have 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,
// as library_items API would return all audiobooks bypassing the library filter
final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty ||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
allAudiobooks.isEmpty || // Fall back if browse got nothing
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) {
@@ -1084,6 +1111,51 @@ class MusicAssistantAPI {
}
}
/// Get lyrics for a track
/// Returns the lyrics text if available, null otherwise
/// Uses the dedicated metadata/get_track_lyrics endpoint
Future<String?> getTrackLyrics(Map<String, dynamic> track) async {
try {
_logger.log('🎤 Getting lyrics for track: ${track['name']}');
// Use the dedicated lyrics endpoint
final response = await _sendCommand(
'metadata/get_track_lyrics',
args: {
'track': track,
},
);
if (response.containsKey('error_code')) {
_logger.log('🎤 Error getting lyrics: ${response['error_code']}');
return null;
}
// Result is a tuple: [plain_lyrics, synced_lyrics]
// We prefer synced lyrics if available, otherwise plain
final result = response['result'];
if (result is List && result.length >= 2) {
final plainLyrics = result[0] as String?;
final syncedLyrics = result[1] as String?;
if (syncedLyrics != null && syncedLyrics.isNotEmpty) {
_logger.log('🎤 Found synced lyrics: ${syncedLyrics.length} chars');
return syncedLyrics;
}
if (plainLyrics != null && plainLyrics.isNotEmpty) {
_logger.log('🎤 Found plain lyrics: ${plainLyrics.length} chars');
return plainLyrics;
}
}
_logger.log('🎤 No lyrics found in result: $result');
return null;
} catch (e) {
_logger.log('🎤 Error getting lyrics: $e');
return null;
}
}
/// Browse Music Assistant content by path
/// Returns a list of browse items (folders or media items)
Future<List<dynamic>> browse(String? path) async {
@@ -3367,10 +3439,22 @@ class MusicAssistantAPI {
try {
_logger.log('📥 Fetching initial state...');
// Fetch provider manifests (available providers)
// Fetch provider manifests (available providers with icons)
try {
await _sendCommand('providers/manifests');
_logger.log('✓ Provider manifests loaded');
final manifestResponse = await _sendCommand('providers/manifests');
final manifests = manifestResponse['result'];
if (manifests is List) {
_providerManifests.clear();
for (final manifestData in manifests) {
if (manifestData is Map<String, dynamic>) {
final manifest = ProviderManifest.fromJson(manifestData);
if (manifest.domain.isNotEmpty) {
_providerManifests[manifest.domain] = manifest;
}
}
}
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
}
} catch (e) {
_logger.log('⚠️ Could not load provider manifests: $e');
}
@@ -3415,6 +3499,18 @@ class MusicAssistantAPI {
}
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _providerManifests[domain];
}
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
final manifest = _providerManifests[domain];
return manifest?.getSvgIcon(isDark: isDark);
}
/// Get current authenticated user's profile
/// Returns user info including display_name, username, groups, etc.
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
@@ -3550,6 +3646,9 @@ class MusicAssistantAPI {
return;
}
// Cancel all pending requests before reconnecting to prevent memory leaks
_cancelPendingRequests('Connection lost, reconnecting');
await Future.delayed(Timings.reconnectDelay);
// Check again after delay
+36 -4
View File
@@ -100,6 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -155,17 +156,34 @@ class PositionTracker {
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
// 4. We're not playing (paused state should reflect server position)
// 5. Anchor is getting stale (> 20 seconds old) - prevents position freezing at _maxAnchorAge cap
//
// Key insight: When timestamp is stale, the raw position from server is likely outdated.
// Our interpolated position is probably more accurate, so don't let stale data override it.
// HOWEVER, if our anchor itself is getting old, we need to refresh it to prevent freezing.
final anchorAge = DateTime.now().difference(_anchorTime);
final anchorIsGettingStale = anchorAge.inSeconds > 20;
// When anchor is stale, always refresh it - the suspicious reset check should
// only prevent us from trusting the SERVER's position, not from refreshing
// our own interpolated anchor. The newAnchorPos logic below handles using
// interpolated position for stale anchors with suspicious server data.
final shouldUpdateAnchor = playerChanged
|| playStateChanged
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|| !isPlaying;
|| !isPlaying
|| anchorIsGettingStale;
if (shouldUpdateAnchor) {
_anchorPosition = anchorPos;
// When refreshing due to stale anchor (not player/state change), use interpolated position
// to avoid backward jumps from outdated server data
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -209,6 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -218,9 +237,20 @@ class PositionTracker {
}
void _startInterpolationTimer() {
if (_interpolationTimer != null) return;
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) {
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
});
@@ -229,6 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -240,6 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+3 -2
View File
@@ -154,7 +154,8 @@ class SendspinService {
}
/// 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 (_state == SendspinConnectionState.connected) return true;
@@ -173,7 +174,7 @@ class SendspinService {
_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) {
_updateState(SendspinConnectionState.error);
+43
View File
@@ -92,6 +92,7 @@ class SettingsService {
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 _keyDisableAutoSwitch = 'disable_auto_switch';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -167,6 +168,7 @@ class SettingsService {
// Hint System Settings
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
static const String _keyShowProviderIcons = 'show_provider_icons'; // Show provider icons on album art
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
@@ -176,6 +178,9 @@ class SettingsService {
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
// Remote Access Settings
static const String _keyRemoteAccessId = 'remote_access_id';
static Future<String?> getServerUrl() async {
final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl);
@@ -263,6 +268,22 @@ class SettingsService {
await SecureStorageService.clearAuthCredentials();
}
// Get Remote Access ID for remote connections
static Future<String?> getRemoteAccessId() async {
final prefs = await _getPrefs();
return prefs.getString(_keyRemoteAccessId);
}
// Set Remote Access ID for remote connections
static Future<void> setRemoteAccessId(String? id) async {
final prefs = await _getPrefs();
if (id == null || id.isEmpty) {
await prefs.remove(_keyRemoteAccessId);
} else {
await prefs.setString(_keyRemoteAccessId, id);
}
}
// Get username for authentication
static Future<String?> getUsername() async {
final prefs = await _getPrefs();
@@ -360,6 +381,10 @@ class SettingsService {
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
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
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
@@ -882,6 +907,24 @@ class SettingsService {
await prefs.setBool(_keyShowHints, show);
}
/// Get whether provider icons are shown on album art (default: true)
static Future<bool> getShowProviderIcons() async {
final prefs = await _getPrefs();
return prefs.getBool(_keyShowProviderIcons) ?? true;
}
/// Synchronous getter for provider icons setting (uses cached prefs)
/// Returns true if prefs not yet initialized
static bool getShowProviderIconsSync() {
return _prefsCache?.getBool(_keyShowProviderIcons) ?? true;
}
/// Set whether provider icons are shown on album art
static Future<void> setShowProviderIcons(bool show) async {
final prefs = await _getPrefs();
await prefs.setBool(_keyShowProviderIcons, show);
}
/// Check if user has ever used the player reveal gesture
static Future<bool> getHasUsedPlayerReveal() async {
final prefs = await _getPrefs();
+82 -93
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../database/database.dart' show BatchCacheItem;
import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
@@ -391,114 +392,84 @@ class SyncService with ChangeNotifier {
}
}
/// Save albums to database cache with source provider tracking
/// Save albums to database cache with source provider tracking (batched)
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final album in albums) {
try {
final providers = sourceProviders[album.itemId];
// Save with first source provider (merging happens in DB layer)
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
sourceProvider: provider,
);
}
// If no source providers, still cache the item
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
}
final providers = sourceProviders[album.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'album',
itemId: album.itemId,
data: jsonEncode(album.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${albums.length} albums to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache albums: $e');
}
_logger.log('💾 Saved ${albums.length} albums to cache');
}
/// Save artists to database cache with source provider tracking
/// Save artists to database cache with source provider tracking (batched)
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final artist in artists) {
try {
final providers = sourceProviders[artist.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
}
final providers = sourceProviders[artist.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: jsonEncode(artist.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${artists.length} artists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache artists: $e');
}
_logger.log('💾 Saved ${artists.length} artists to cache');
}
/// Save audiobooks to database cache with source provider tracking
/// Save audiobooks to database cache with source provider tracking (batched)
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final audiobook in audiobooks) {
try {
final providers = sourceProviders[audiobook.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
}
final providers = sourceProviders[audiobook.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: jsonEncode(audiobook.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
}
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
}
/// Save playlists to database cache with source provider tracking
/// Save playlists to database cache with source provider tracking (batched)
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final playlist in playlists) {
try {
final providers = sourceProviders[playlist.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
}
final providers = sourceProviders[playlist.itemId] ?? <String>[];
batchItems.add(BatchCacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: jsonEncode(playlist.toJson()),
sourceProviders: providers,
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache playlists: $e');
}
_logger.log('💾 Saved ${playlists.length} playlists to cache');
}
/// Force a fresh sync (for pull-to-refresh)
@@ -534,13 +505,11 @@ class SyncService with ChangeNotifier {
/// Empty enabledProviderIds = all providers enabled, show everything
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
// Empty set = all providers enabled, show everything
if (enabledProviderIds.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
// STRICT MODE: Hide items without tracking to ensure accurate filtering
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
@@ -553,7 +522,6 @@ class SyncService with ChangeNotifier {
}
return _cachedArtists.where((artist) {
final sources = _artistSourceProviders[artist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
@@ -607,4 +575,25 @@ class SyncService with ChangeNotifier {
/// Check if we have data available (from cache or sync)
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.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();
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
@@ -91,7 +91,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
+19 -1
View File
@@ -129,13 +129,31 @@ class Durations {
class ButtonSizes {
ButtonSizes._();
// Touch target sizes (Material Design minimum: 48x48)
// Touch target sizes
// WCAG 2.1 Level AA requires minimum 44x44, Level AAA requires 48x48
// Material Design recommends 48x48 minimum
// WARNING: sm and md are below WCAG minimum - use only for non-interactive elements
/// 32x32 - NOT WCAG compliant, use only for decorative/non-interactive elements
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double sm = 32.0;
/// 36x36 - NOT WCAG compliant, use only for dense layouts where space is critical
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double md = 36.0;
/// 44x44 - Meets WCAG Level AA minimum
static const double lg = 44.0;
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
static const double xl = 48.0;
/// 56x56 - Comfortable touch target for primary actions
static const double xxl = 56.0;
/// Minimum WCAG-compliant touch target size (48x48)
static const double minAccessible = 48.0;
// Icon sizes within buttons
static const double iconSm = 16.0;
static const double iconMd = 18.0;
+46
View File
@@ -0,0 +1,46 @@
/// Extension methods for formatting Duration values.
///
/// Provides consistent duration formatting across the codebase,
/// replacing duplicate _formatDuration() implementations.
extension DurationFormatter on Duration {
/// Format duration as "H:MM:SS" or "M:SS" depending on length.
///
/// Examples:
/// - 45 seconds -> "0:45"
/// - 3 minutes, 45 seconds -> "3:45"
/// - 1 hour, 3 minutes, 45 seconds -> "1:03:45"
String toFormattedString() {
final hours = inHours;
final minutes = inMinutes % 60;
final seconds = inSeconds % 60;
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
/// Format duration as "M:SS" (no hours, minutes not padded).
///
/// Use this for shorter content like songs where hours are unlikely.
String toMinutesSeconds() {
final totalSeconds = inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format an integer number of seconds as "H:MM:SS" or "M:SS".
///
/// This is a standalone function for cases where you have raw seconds
/// rather than a Duration object.
String formatDurationSeconds(int seconds) {
return Duration(seconds: seconds).toFormattedString();
}
/// Format a nullable Duration, returning empty string if null.
String formatDurationOrEmpty(Duration? duration) {
if (duration == null) return '';
return duration.toMinutesSeconds();
}
+139
View File
@@ -0,0 +1,139 @@
import 'dart:collection';
/// A simple Least Recently Used (LRU) cache implementation.
///
/// When the cache reaches its maximum size, the least recently used entries
/// are automatically evicted to make room for new entries.
///
/// Usage:
/// ```dart
/// final cache = LruCache<String, String>(maxSize: 100);
/// cache.put('key', 'value');
/// final value = cache.get('key');
/// ```
class LruCache<K, V> {
/// Maximum number of entries to store
final int maxSize;
/// Internal storage using LinkedHashMap for O(1) LRU operations
final LinkedHashMap<K, V> _cache = LinkedHashMap<K, V>();
/// Creates an LRU cache with the specified maximum size.
///
/// [maxSize] must be greater than 0.
LruCache({required this.maxSize}) : assert(maxSize > 0);
/// Returns the number of entries currently in the cache.
int get length => _cache.length;
/// Returns true if the cache is empty.
bool get isEmpty => _cache.isEmpty;
/// Returns true if the cache is not empty.
bool get isNotEmpty => _cache.isNotEmpty;
/// Returns true if the cache contains an entry for [key].
bool containsKey(K key) => _cache.containsKey(key);
/// Returns the value for [key], or null if not found.
///
/// This operation marks the entry as recently used.
V? get(K key) {
if (!_cache.containsKey(key)) {
return null;
}
// Move to end (most recently used) by removing and re-adding
final value = _cache.remove(key);
_cache[key] = value as V;
return value;
}
/// Returns the value for [key] without marking it as recently used.
///
/// Use this when you need to check a value without affecting LRU order.
V? peek(K key) => _cache[key];
/// Stores [value] for [key] in the cache.
///
/// If [key] already exists, the value is updated and marked as recently used.
/// If the cache is full, the least recently used entry is evicted.
void put(K key, V value) {
// If key exists, remove it first (will be re-added at end)
if (_cache.containsKey(key)) {
_cache.remove(key);
} else if (_cache.length >= maxSize) {
// Cache is full, remove least recently used (first entry)
_cache.remove(_cache.keys.first);
}
_cache[key] = value;
}
/// Operator access for getting values (same as [get]).
V? operator [](K key) => get(key);
/// Operator access for setting values (same as [put]).
void operator []=(K key, V value) => put(key, value);
/// Removes the entry for [key] if it exists.
///
/// Returns the removed value, or null if [key] was not in the cache.
V? remove(K key) => _cache.remove(key);
/// Removes all entries from the cache.
void clear() => _cache.clear();
/// Returns all keys currently in the cache.
Iterable<K> get keys => _cache.keys;
/// Returns all values currently in the cache.
Iterable<V> get values => _cache.values;
/// Returns all entries currently in the cache.
Iterable<MapEntry<K, V>> get entries => _cache.entries;
}
/// An LRU cache that can store null values and distinguish them from missing entries.
///
/// Regular LruCache returns null for both missing keys and keys with null values.
/// This class uses a wrapper to distinguish between the two cases.
class NullableLruCache<K, V> {
final LruCache<K, _CacheEntry<V>> _cache;
/// Creates a nullable LRU cache with the specified maximum size.
NullableLruCache({required int maxSize}) : _cache = LruCache(maxSize: maxSize);
/// Returns the number of entries currently in the cache.
int get length => _cache.length;
/// Returns true if the cache contains an entry for [key].
bool containsKey(K key) => _cache.containsKey(key);
/// Returns the value for [key], or null if not found.
///
/// Note: This will also return null if the cached value is null.
/// Use [containsKey] to distinguish between missing and null values.
V? get(K key) => _cache.get(key)?.value;
/// Stores [value] for [key] in the cache.
///
/// The value can be null.
void put(K key, V? value) => _cache.put(key, _CacheEntry(value));
/// Operator access for getting values.
V? operator [](K key) => get(key);
/// Operator access for setting values.
void operator []=(K key, V? value) => put(key, value);
/// Removes the entry for [key] if it exists.
V? remove(K key) => _cache.remove(key)?.value;
/// Removes all entries from the cache.
void clear() => _cache.clear();
}
/// Wrapper to store nullable values in the cache
class _CacheEntry<V> {
final V? value;
_CacheEntry(this.value);
}
+61 -60
View File
@@ -10,6 +10,7 @@ import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import 'provider_icon.dart';
class AlbumCard extends StatefulWidget {
final Album album;
@@ -40,7 +41,8 @@ class _AlbumCardState extends State<AlbumCard> {
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
void initState() {
@@ -123,8 +125,8 @@ class _AlbumCardState extends State<AlbumCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -151,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
);
},
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
);
},
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Album title with year
Hero(
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Artist name
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
+14 -18
View File
@@ -42,7 +42,8 @@ class _ArtistCardState extends State<ArtistCard> {
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
void initState() {
@@ -116,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -186,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
),
const SizedBox(height: 8),
// Artist name - fixed height container so image size is consistent
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
+6
View File
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart';
import 'provider_icon.dart';
class AudiobookRow extends StatefulWidget {
final String title;
@@ -285,6 +286,11 @@ class _AudiobookCard extends StatelessWidget {
),
),
),
// Provider icon overlay
if (audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: audiobook.providerMappings!.first.providerDomain,
),
],
),
),
File diff suppressed because it is too large Load Diff
+135 -62
View File
@@ -93,8 +93,9 @@ class GlobalPlayerOverlay extends StatefulWidget {
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
/// Collapse the player if it's expanded
static void collapsePlayer() {
globalPlayerKey.currentState?.collapse();
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
static void collapsePlayer({bool withHaptic = true}) {
globalPlayerKey.currentState?.collapse(withHaptic: withHaptic);
}
/// Force collapse player to mini state, instantly closing queue panel if open.
@@ -164,6 +165,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
/// Show player selector for a "Play On" action.
/// Instead of the default hints, shows a contextual hint like "Select player to play album".
/// When a player is tapped, calls onPlayerSelected instead of switching to that player.
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(dynamic player) onPlayerSelected,
IconData? hintIcon,
}) {
_overlayStateKey.currentState?._showPlayerSelectorForAction(
contextHint: contextHint,
onPlayerSelected: onPlayerSelected,
hintIcon: hintIcon,
);
}
}
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
@@ -208,6 +224,11 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
// State for "Play On" action - stored here so it can be passed to PlayerRevealOverlay
void Function(dynamic player)? _pendingPlayerAction;
String? _pendingContextHint;
IconData? _pendingHintIcon;
@override
void initState() {
super.initState();
@@ -394,6 +415,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0;
_clearPendingAction(); // Clear pending action when overlay is dismissed
setState(() {
_isRevealVisible = false;
_isOnboardingReveal = false;
@@ -413,6 +435,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
_singleBounceController.forward();
}
/// Clear pending action state (called when overlay is dismissed)
void _clearPendingAction() {
_pendingPlayerAction = null;
_pendingContextHint = null;
_pendingHintIcon = null;
}
/// Show player selector for a "Play On" action.
/// Stores the pending action and shows the reveal overlay with context hint.
void _showPlayerSelectorForAction({
required String contextHint,
required void Function(dynamic player) onPlayerSelected,
IconData? hintIcon,
}) {
// Store pending action state
_pendingPlayerAction = onPlayerSelected;
_pendingContextHint = contextHint;
_pendingHintIcon = hintIcon;
// Show the reveal overlay
_showPlayerReveal();
}
/// End hint mode (called when user taps skip button)
void _endHintMode() {
if (!_isHintModeActive) return;
@@ -485,10 +530,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
child: widget.child,
),
// Global persistent bottom navigation bar - positioned at bottom
// Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
// Hide when: not connected OR showing welcome screen
// Always show when connected so users can access Settings/Library even without a player
if (isConnected && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
@@ -536,62 +580,76 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
return Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
);
},
@@ -627,11 +685,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
duration: const Duration(seconds: 3),
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
builder: (context, opacity, child) {
// Use theme-appropriate background color for seamless transition
final isDark = Theme.of(context).brightness == Brightness.dark;
final backdropColor = isDark
? const Color(0xFF1a1a1a) // Dark theme background
: colorScheme.surface; // Light theme surface
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
// Use same dark color as connection screen for seamless transition
color: const Color(0xFF1a1a1a).withOpacity(opacity),
color: backdropColor.withOpacity(opacity),
),
);
},
@@ -727,6 +789,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
// Pass pending action parameters for "Play On" functionality
onPlayerSelected: _pendingPlayerAction,
contextHint: _pendingContextHint,
contextHintIcon: _pendingHintIcon,
),
// Global player overlay - renders ON TOP so cards slide behind it
@@ -740,6 +806,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, state, child) {
// Only show player if connected and has a selected player
if (!state.isConnected || !state.hasPlayer) {
// Reset expansion state to prevent stale values from causing
// invisible/non-interactive nav bar when connection restores.
// This fixes a bug where expanded player state persisted after
// disconnect, making nav bar opacity=0 and ignoring taps.
if (playerExpansionNotifier.value.progress != 0.0) {
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
}
return const SizedBox.shrink();
}
+101 -29
View File
@@ -1,10 +1,24 @@
import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
/// Display mode for the scrollbar popup indicator
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
/// section you're currently in via a popup indicator.
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows contextual info
/// 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 {
/// The scrollable child widget (ListView, GridView, etc.)
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.
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.
final void Function(int index)? onScrollToIndex;
@@ -45,6 +67,8 @@ class LetterScrollbar extends StatefulWidget {
required this.child,
required this.controller,
required this.items,
this.displayMode = ScrollbarDisplayMode.letter,
this.displayLabels,
this.onScrollToIndex,
this.onDragStateChanged,
this.itemExtent,
@@ -85,7 +109,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() {
_scrollFraction = newFraction;
_currentLetter = _getLetterAtFraction(newFraction);
_currentLetter = _getLabelAtFraction(newFraction);
});
}
}
@@ -102,15 +126,32 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
return '#';
}
/// Get the letter at a given scroll fraction (0.0 to 1.0)
String _getLetterAtFraction(double fraction) {
/// Get the display label at a given scroll fraction (0.0 to 1.0)
String _getLabelAtFraction(double fraction) {
if (widget.items.isEmpty) return '';
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) {
final item = widget.items[index];
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 '';
@@ -154,7 +195,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final fraction = clampedPosition / effectiveHeight;
// Get letter for display
final letter = _getLetterAtFraction(fraction);
final letter = _getLabelAtFraction(fraction);
// Scroll proportionally
final maxScroll = widget.controller.position.maxScrollExtent;
@@ -170,6 +211,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
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
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -236,32 +325,15 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
),
),
// The letter popup bubble
if (_isDragging && _currentLetter.isNotEmpty)
// The popup bubble (letter, year, or count) - hidden when displayMode is none
if (_isDragging && _currentLetter.isNotEmpty && widget.displayMode != ScrollbarDisplayMode.none)
Positioned(
right: 48,
top: (_dragPosition - 28).clamp(
0.0,
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
),
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
child: _buildPopupBubble(colorScheme),
),
],
);
+2 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' show Audiobook, Chapter;
import '../../theme/design_tokens.dart';
import '../../utils/duration_formatter.dart';
import '../common/empty_state.dart';
import '../../l10n/app_localizations.dart';
@@ -26,18 +27,6 @@ class ChaptersPanel extends StatelessWidget {
required this.onClose,
});
String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds);
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final secs = duration.inSeconds % 60;
if (hours > 0) {
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
return '${minutes}:${secs.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final chapters = audiobook?.chapters ?? [];
@@ -147,7 +136,7 @@ class ChaptersPanel extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatDuration(chapter.positionMs ~/ 1000),
formatDurationSeconds(chapter.positionMs ~/ 1000),
style: TextStyle(
color: textColor.withOpacity(0.5),
fontSize: 12,
+38 -12
View File
@@ -19,12 +19,25 @@ class PlayerRevealOverlay extends StatefulWidget {
final double miniPlayerHeight;
final bool showOnboardingHints; // Show additional hints for first-time users
/// Callback when player is selected for a pending action (e.g., "Play On").
/// If null, selecting a player just switches to that player.
final void Function(dynamic player)? onPlayerSelected;
/// Context hint to display instead of default hints (e.g., "Select player to play album").
final String? contextHint;
/// Icon for context hint (default: play_circle_outline).
final IconData? contextHintIcon;
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
this.showOnboardingHints = false,
this.onPlayerSelected,
this.contextHint,
this.contextHintIcon,
});
@override
@@ -383,16 +396,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
padding: const EdgeInsets.only(bottom: 12),
child: Material(
type: MaterialType.transparency,
child: isOnboarding
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
child: widget.contextHint != null
? buildHintRow(
widget.contextHintIcon ?? Icons.play_circle_outline,
widget.contextHint!,
)
: isOnboarding
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
),
),
);
@@ -436,7 +454,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
if (widget.onPlayerSelected != null) {
widget.onPlayerSelected!(data.player);
} else {
maProvider.selectPlayer(data.player);
}
dismiss();
},
onLongPress: () {
@@ -489,7 +511,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
if (widget.onPlayerSelected != null) {
widget.onPlayerSelected!(data.player);
} else {
maProvider.selectPlayer(data.player);
}
dismiss();
},
onLongPress: () {
+147 -35
View File
@@ -7,6 +7,7 @@ import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart';
import '../../theme/design_tokens.dart';
import '../../utils/duration_formatter.dart';
import '../common/empty_state.dart';
import '../global_player_overlay.dart';
@@ -27,8 +28,8 @@ class QueuePanel extends StatefulWidget {
final VoidCallback onRefresh;
final ValueChanged<bool>? onDraggingChanged;
final VoidCallback? onSwipeStart;
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
const QueuePanel({
super.key,
@@ -55,6 +56,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
List<QueueItem> _items = [];
final GlobalKey _stackKey = GlobalKey();
// Scroll controller to track list position (for swipe-to-close only at top)
final ScrollController _scrollController = ScrollController();
bool _isAtTop = true;
bool _wasAtTopOnSwipeStart = true; // Capture at gesture start
// Transfer dropdown animation
late AnimationController _dropdownController;
bool _showingTransferDropdown = false;
@@ -100,6 +106,17 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
vsync: this,
duration: const Duration(milliseconds: 300),
);
// Track scroll position for swipe-to-close (only allowed at top)
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final atTop = _scrollController.offset <= 0;
if (atTop != _isAtTop) {
setState(() {
_isAtTop = atTop;
});
}
}
@override
@@ -135,18 +152,13 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_dropdownController.dispose();
_pendingReorderTimer?.cancel();
super.dispose();
}
String _formatDuration(Duration? duration) {
if (duration == null) return '';
final totalSeconds = duration.inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
void _handleDelete(QueueItem item, int index) async {
// Store item for potential rollback
@@ -427,6 +439,29 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
}
double _calculateAverageVelocityVertical() {
if (_velocitySamples.length < 2) return 0.0;
// Use weighted average of recent samples (more recent = higher weight)
double totalVelocity = 0.0;
double totalWeight = 0.0;
for (int i = 1; i < _velocitySamples.length; i++) {
final prev = _velocitySamples[i - 1];
final curr = _velocitySamples[i];
final dt = curr.timeMs - prev.timeMs;
if (dt > 0 && dt < 100) { // Only use samples within 100ms
final dy = curr.position.dy - prev.position.dy;
final velocity = (dy / dt) * 1000; // px/s
final weight = i.toDouble(); // Later samples get higher weight
totalVelocity += velocity * weight;
totalWeight += weight;
}
}
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
}
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
if (_dragIndex != null) return;
@@ -588,15 +623,71 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
try {
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
} catch (e) {
// Error playing index - handled silently
// Error playing index - clear optimistic state
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
});
}
}
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
}
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
if (isCurrentItem) return;
final playerId = widget.queue?.playerId;
if (playerId == null) return;
// Get current index (where the currently playing track is)
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
// Target position is right after the current track
final targetIndex = currentIndex + 1;
// If already at target position, nothing to do
if (index == targetIndex) {
// Haptic to confirm it's already in position
HapticFeedback.lightImpact();
return;
}
// Clear optimistic state after delay - server state will take over
await Future.delayed(const Duration(milliseconds: 1500));
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
// Calculate the position shift for the API
final posShift = targetIndex - index;
// Cancel any existing timer to prevent stacking
_pendingReorderTimer?.cancel();
// Optimistic update - move item in local list
setState(() {
final movedItem = _items.removeAt(index);
_items.insert(targetIndex > index ? targetIndex - 1 : targetIndex, movedItem);
_pendingReorder = true;
});
// Haptic feedback
HapticFeedback.mediumImpact();
// Call API
try {
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
// Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
});
} catch (e) {
// Clear pending state immediately on error
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
// Refresh queue from server to get correct state
widget.onRefresh();
}
}
@@ -623,17 +714,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
_swipeLocked = false;
_velocitySamples.clear();
_addVelocitySample(event.position, _swipeLastTime!);
// Capture scroll position at gesture start - only allow close if already at top
_wasAtTopOnSwipeStart = _isAtTop;
} else if (startedInEdgeZone) {
// Clear swipe state for edge touches
_swipeStart = null;
}
},
onPointerMove: (event) {
// Ignore swipes from edge zone (let Android back gesture handle it)
// Ignore swipes while dragging a queue item
if (_swipeStart == null || _dragIndex != null) return;
final dx = event.position.dx - _swipeStart!.dx;
final dy = (event.position.dy - _swipeStart!.dy).abs();
final dx = (event.position.dx - _swipeStart!.dx).abs();
final dy = event.position.dy - _swipeStart!.dy;
final now = DateTime.now().millisecondsSinceEpoch;
// Track all move events for velocity calculation
@@ -643,31 +736,31 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
// Once direction is locked, maintain it (prevents accidental cancellation)
if (_swipeLocked && _isSwiping) {
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
return;
}
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
// Check if this is a vertical swipe (dy > dx * 1.2)
final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
if (isHorizontal && dx > 0) {
// Horizontal swipe right - close gesture
if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
// Vertical swipe down - close gesture (only when was at top when gesture started)
if (!_isSwiping) {
_isSwiping = true;
_swipeLocked = true; // Lock direction once swipe starts
widget.onSwipeStart?.call();
}
widget.onSwipeUpdate?.call(dx);
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
// Direction established as vertical - don't start swipe
widget.onSwipeUpdate?.call(dy);
} else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
// Direction established as horizontal - don't start swipe
_swipeLocked = true; // Lock as non-swipe
}
},
onPointerUp: (event) {
if (_isSwiping && _swipeStart != null) {
final velocity = _calculateAverageVelocity();
final totalDx = event.position.dx - _swipeStart!.dx;
widget.onSwipeEnd?.call(velocity, totalDx);
final velocity = _calculateAverageVelocityVertical();
final totalDy = event.position.dy - _swipeStart!.dy;
widget.onSwipeEnd?.call(velocity, totalDy);
}
_resetSwipeState();
},
@@ -690,11 +783,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
icon: Icon(Icons.keyboard_arrow_down_rounded, color: widget.textColor, size: IconSizes.lg),
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
const SizedBox(width: 8),
Text(
'Queue',
style: TextStyle(
@@ -827,6 +920,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
return ListView.builder(
key: const PageStorageKey('queue_list'),
controller: _scrollController,
padding: Spacing.paddingH8,
// Disable scrolling while dragging to prevent gesture conflict
physics: _dragIndex != null
@@ -850,8 +944,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
return Dismissible(
key: ValueKey(item.queueItemId),
direction: DismissDirection.endToStart,
direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
// Swipe right (startToEnd) = move to play next
background: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
decoration: BoxDecoration(
color: widget.primaryColor,
borderRadius: BorderRadius.zero,
),
child: const Icon(Icons.skip_next_rounded, color: Colors.white, size: 28),
),
// Swipe left (endToStart) = delete
secondaryBackground: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
@@ -863,8 +968,15 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
confirmDismiss: (direction) async {
// Block dismissal if swipe started from screen edge (Android back gesture)
if (lastTouchWasInEdgeZone) return false;
// Don't allow dismissing the currently playing item
return !isCurrentItem;
if (direction == DismissDirection.startToEnd) {
// Swipe right = move to play next position
_handleMoveToPlayNext(item, index, isCurrentItem);
return false; // Don't dismiss - item stays in queue (just moved)
} else {
// Swipe left = delete
return true;
}
},
onDismissed: (direction) => _handleDelete(item, index),
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
@@ -906,7 +1018,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
final duration = _formatDuration(item.track.duration);
final duration = formatDurationOrEmpty(item.track.duration);
return RepaintBoundary(
child: Opacity(
+46 -40
View File
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PlaylistCard extends StatefulWidget {
final Playlist playlist;
@@ -48,8 +49,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -76,51 +77,56 @@ class _PlaylistCardState extends State<PlaylistCard> {
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Playlist title
Hero(
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Owner/track count
+46 -40
View File
@@ -10,6 +10,7 @@ import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
@@ -54,8 +55,8 @@ class _PodcastCardState extends State<PodcastCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -82,51 +83,56 @@ class _PodcastCardState extends State<PodcastCard> {
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Podcast title
Hero(
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Podcast author (if available)
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
/// Displays a provider icon (SVG) from Music Assistant.
///
/// Shows a small icon in a rounded container, typically used as an overlay
/// on album art to indicate which provider the item comes from.
class ProviderIcon extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Whether to use dark mode icon variant
final bool? isDark;
const ProviderIcon({
super.key,
required this.domain,
this.size = 20,
this.isDark,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final brightness = Theme.of(context).brightness;
final useDark = isDark ?? brightness == Brightness.dark;
final svgString = maProvider.getProviderIconSvg(domain, isDark: useDark);
if (svgString == null || svgString.isEmpty) {
return const SizedBox.shrink();
}
return SvgPicture.string(
svgString,
width: size,
height: size,
fit: BoxFit.contain,
);
}
}
/// A positioned provider icon overlay for use in Stack widgets.
///
/// Typically positioned in a corner of album art.
class ProviderIconOverlay extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Position from the edges
final double margin;
const ProviderIconOverlay({
super.key,
required this.domain,
this.size = 20,
this.margin = 4,
});
@override
Widget build(BuildContext context) {
// Check if provider icons are enabled in settings
if (!SettingsService.getShowProviderIconsSync()) {
return const SizedBox.shrink();
}
return Positioned(
right: margin,
bottom: margin,
child: ProviderIcon(
domain: domain,
size: size,
),
);
}
}
+126 -67
View File
@@ -5,23 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "72.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "6.7.0"
version: "8.4.1"
archive:
dependency: transitive
description:
@@ -90,10 +85,10 @@ packages:
dependency: transitive
description:
name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "3.0.1"
build_config:
dependency: transitive
description:
@@ -114,26 +109,26 @@ packages:
dependency: transitive
description:
name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "3.0.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
version: "2.6.1"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
url: "https://pub.dev"
source: hosted
version: "7.3.2"
version: "9.2.1"
built_collection:
dependency: transitive
description:
@@ -178,10 +173,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
charcode:
dependency: transitive
description:
@@ -210,10 +205,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@@ -226,10 +221,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@@ -258,10 +253,18 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "2.3.7"
version: "3.1.3"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
device_info_plus:
dependency: "direct main"
description:
@@ -282,18 +285,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
url: "https://pub.dev"
source: hosted
version: "2.23.1"
version: "2.30.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
url: "https://pub.dev"
source: hosted
version: "2.23.1"
version: "2.30.1"
drift_flutter:
dependency: "direct main"
description:
@@ -314,10 +317,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -440,6 +443,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -458,6 +469,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
frontend_server_client:
dependency: transitive
description:
@@ -518,10 +537,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
io:
dependency: transitive
description:
@@ -574,26 +593,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -602,6 +621,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
@@ -610,22 +637,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -646,10 +665,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -710,10 +729,18 @@ packages:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -934,15 +961,15 @@ packages:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "4.2.0"
source_span:
dependency: transitive
description:
@@ -1011,26 +1038,26 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41"
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
url: "https://pub.dev"
source: hosted
version: "0.40.0"
version: "0.43.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
stream_transform:
dependency: transitive
description:
@@ -1067,10 +1094,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.7.7"
timing:
dependency: transitive
description:
@@ -1127,14 +1154,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -1167,6 +1218,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
win32:
dependency: transitive
description:
@@ -1208,5 +1267,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.5.0 <4.0.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0"
+6 -2
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.9-beta+47
version: 2.9.1-beta+49
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -11,12 +11,13 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
intl: ^0.20.2
# UI
material_design_icons_flutter: ^7.0.7296
dynamic_color: 1.6.8
palette_generator: ^0.3.3+3
flutter_svg: ^2.0.10+1
# State management
provider: ^6.1.1
@@ -26,6 +27,9 @@ dependencies:
http: ^1.1.0
cached_network_image: ^3.3.1
# WebRTC (for remote access)
flutter_webrtc: ^1.2.1
# Storage
shared_preferences: ^2.2.2
flutter_secure_storage: ^9.2.2