Compare commits

..
Author SHA1 Message Date
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
59 changed files with 4268 additions and 1234 deletions
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.
+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"
}
}
+72
View File
@@ -1761,6 +1761,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 +1833,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 +2078,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> {
+36
View File
@@ -909,6 +909,12 @@ class SDe extends S {
@override
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';
@@ -941,6 +947,15 @@ class SDe extends S {
@override
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';
@@ -1076,4 +1091,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';
}
+36
View File
@@ -909,6 +909,12 @@ class SEn extends S {
@override
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';
@@ -941,6 +947,15 @@ class SEn extends S {
@override
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';
@@ -1076,4 +1091,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';
}
+36
View File
@@ -909,6 +909,12 @@ class SEs extends S {
@override
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';
@@ -941,6 +947,15 @@ class SEs extends S {
@override
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';
@@ -1076,4 +1091,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';
}
+36
View File
@@ -909,6 +909,12 @@ class SFr extends S {
@override
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';
@@ -941,6 +947,15 @@ class SFr extends S {
@override
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';
@@ -1076,4 +1091,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
}
+11
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 {
+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();
}
}
+231 -77
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';
@@ -63,6 +64,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 +73,12 @@ 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
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -221,6 +230,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;
@@ -510,6 +534,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;
@@ -3712,8 +3745,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 +3762,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 +3794,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 +3880,7 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
}
@@ -3875,12 +3916,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 +3970,9 @@ class MusicAssistantProvider with ChangeNotifier {
item: mediaItem,
playing: player.state == 'playing',
);
} else {
// Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState();
}
} else {
// For remote players, immediately show notification if we have cached track info
@@ -3973,6 +4015,10 @@ class MusicAssistantProvider with ChangeNotifier {
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();
}
}
@@ -4084,40 +4130,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);
@@ -4135,7 +4174,7 @@ class MusicAssistantProvider with ChangeNotifier {
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
playing: isPlaying,
position: position,
duration: track.duration,
);
@@ -4320,6 +4359,10 @@ class MusicAssistantProvider with ChangeNotifier {
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 +4381,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) ||
@@ -4448,6 +4498,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 +4511,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
@@ -5293,6 +5354,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 +5712,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 +5875,8 @@ class MusicAssistantProvider with ChangeNotifier {
_playerStateTimer?.cancel();
_notificationPositionTimer?.cancel();
_localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.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,
),
),
],
),
),
),
),
+292 -168
View File
@@ -11,6 +11,7 @@ import '../models/provider_instance.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/album_card.dart';
import '../widgets/artist_avatar.dart';
import '../widgets/provider_icon.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
@@ -57,6 +58,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
bool _showOnlyArtistsWithAlbums = false; // Filter artists tab to only show those with albums
bool _isSyncingArtists = false; // Show progress while re-syncing artists
bool _isSyncingLibraries = false; // Show progress while re-syncing ABS libraries
bool _isSyncingProviders = false; // Show progress while syncing after provider toggle
// All tracks with lazy loading
List<Track> _allTracks = [];
@@ -79,6 +81,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
List<AudiobookSeries> _sortedSeries = [];
List<String> _seriesNames = [];
// PERF: Cached sorted albums/artists - only re-sort when data or sort order changes
List<Album> _cachedSortedAlbums = [];
List<String> _cachedAlbumNames = [];
String _albumsCacheKey = '';
List<Artist> _cachedSortedArtists = [];
List<String> _cachedArtistNames = [];
String _artistsCacheKey = '';
String _authorsCacheKey = '';
List<String> _cachedTrackSortKeys = [];
String _trackSortKeysCacheKey = '';
// Media type selection (Music, Books, Podcasts)
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
@@ -221,10 +234,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
void _onSyncServiceChanged() {
// Rebuild when SyncService data changes (e.g., after sync completes)
if (mounted) {
setState(() {});
}
// Only rebuild when sync completes, not during sync
// SyncService.isSyncing is already checked in menu button spinner
if (!mounted || SyncService.instance.isSyncing) return;
setState(() {});
}
void _onNavigationChanged() {
@@ -283,13 +296,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// PERF: Set flag without setState - Provider change already triggers rebuild
// This avoids a redundant double rebuild
_isSyncingProviders = true;
// Instant UI update - client-side filtering will use the new enabled set
// The toggle updates MusicAssistantProvider.enabledProviderIds which we read during build
maProvider.toggleProviderEnabled(providerId, enabled);
// Immediately rebuild the UI with client-side filtering
setState(() {});
// Cancel any pending debounce for background sync
_providerFilterDebounce?.cancel();
@@ -300,7 +314,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
DebugLogger().log('🔄 Background sync to refresh source tracking...');
await maProvider.forceLibrarySync();
if (mounted) {
setState(() {});
setState(() {
_isSyncingProviders = false;
});
}
}
});
@@ -429,6 +445,139 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_audiobookNames = sorted.map((a) => a.name).toList();
}
/// Sort albums and cache the result - only re-sorts when key changes
/// Returns cached sorted list if data hasn't changed
List<Album> _getSortedAlbums(List<Album> albums) {
final cacheKey = '${albums.length}_${albums.isNotEmpty ? albums.first.itemId : ''}_${albums.isNotEmpty ? albums.last.itemId : ''}_$_albumsSortOrder';
if (cacheKey == _albumsCacheKey && _cachedSortedAlbums.isNotEmpty) {
return _cachedSortedAlbums;
}
final sortedAlbums = List<Album>.from(albums);
switch (_albumsSortOrder) {
case 'name_desc':
sortedAlbums.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
case 'year':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'artist_name':
sortedAlbums.sort((a, b) => (a.artistsString).toLowerCase().compareTo((b.artistsString).toLowerCase()));
break;
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
default:
// Default: name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
_albumsCacheKey = cacheKey;
_cachedSortedAlbums = sortedAlbums;
_cachedAlbumNames = sortedAlbums.map((a) => a.name ?? '').toList();
return sortedAlbums;
}
/// Sort artists and cache the result - only re-sorts when key changes
List<Artist> _getSortedArtists(List<Artist> artists) {
final cacheKey = '${artists.length}_${artists.isNotEmpty ? artists.first.itemId : ''}_${artists.isNotEmpty ? artists.last.itemId : ''}_$_artistsSortOrder';
if (cacheKey == _artistsCacheKey && _cachedSortedArtists.isNotEmpty) {
return _cachedSortedArtists;
}
final sortedArtists = List<Artist>.from(artists);
if (_artistsSortOrder == 'name_desc') {
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
} else {
// Default: name ascending
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
_artistsCacheKey = cacheKey;
_cachedSortedArtists = sortedArtists;
_cachedArtistNames = sortedArtists.map((a) => a.name ?? '').toList();
return sortedArtists;
}
/// Sort authors and cache the result - groups audiobooks by author and sorts names
(List<String>, Map<String, List<Audiobook>>) _getSortedAuthors(List<Audiobook> audiobooks) {
final cacheKey = '${audiobooks.length}_${audiobooks.isNotEmpty ? audiobooks.first.itemId : ''}_${audiobooks.isNotEmpty ? audiobooks.last.itemId : ''}_$_authorsSortOrder';
if (cacheKey == _authorsCacheKey && _sortedAuthorNames.isNotEmpty) {
return (_sortedAuthorNames, _groupedAudiobooksByAuthor);
}
// Group audiobooks by author
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
// Sort authors based on current sort order
final sortedNames = groupedByAuthor.keys.toList();
switch (_authorsSortOrder) {
case 'alpha':
sortedNames.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
break;
case 'alpha_desc':
sortedNames.sort((a, b) => b.toLowerCase().compareTo(a.toLowerCase()));
break;
case 'books':
sortedNames.sort((a, b) {
final aCount = groupedByAuthor[a]?.length ?? 0;
final bCount = groupedByAuthor[b]?.length ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.toLowerCase().compareTo(b.toLowerCase());
});
break;
}
_authorsCacheKey = cacheKey;
_sortedAuthorNames = sortedNames;
_groupedAudiobooksByAuthor = groupedByAuthor;
return (sortedNames, groupedByAuthor);
}
/// Get track sort keys for LetterScrollbar - cached to avoid recomputation on every build
List<String> _getTrackSortKeys(List<Track> tracks) {
final cacheKey = '${tracks.length}_${tracks.isNotEmpty ? tracks.first.itemId : ''}_${tracks.isNotEmpty ? tracks.last.itemId : ''}_$_tracksSortOrder';
if (cacheKey == _trackSortKeysCacheKey && _cachedTrackSortKeys.isNotEmpty) {
return _cachedTrackSortKeys;
}
final List<String> sortKeys;
switch (_tracksSortOrder) {
case 'artist':
case 'artist_name':
sortKeys = tracks.map((t) => t.artistsString).toList();
break;
case 'album':
sortKeys = tracks.map((t) => t.album?.name ?? t.name).toList();
break;
default:
// For name, duration, timestamp, etc. - use track name for letter scrollbar
sortKeys = tracks.map((t) => t.name).toList();
break;
}
_trackSortKeysCacheKey = cacheKey;
_cachedTrackSortKeys = sortKeys;
return sortKeys;
}
String _getCurrentViewMode() {
// Return the view mode for the currently selected tab
final tabIndex = _selectedTabIndex.value;
@@ -1660,8 +1809,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showOptionsMenu(colorScheme),
// Show spinner on button while syncing artists or libraries filter
child: (_isSyncingArtists || _isSyncingLibraries)
// Show spinner on button while syncing (artists filter, libraries filter, provider filter, or initial library build)
child: (_isSyncingArtists || _isSyncingLibraries || _isSyncingProviders || SyncService.instance.isSyncing)
? Center(
child: SizedBox.square(
dimension: 18,
@@ -1770,7 +1919,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (mounted) {
await context.read<MusicAssistantProvider>().forceLibrarySync();
if (mounted) {
setState(() => _isSyncingLibraries = false);
// Reload audiobooks to reflect the new filter
await _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null);
if (mounted) {
setState(() => _isSyncingLibraries = false);
}
}
}
}
@@ -2130,31 +2283,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Group filtered audiobooks by author
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
// Sort authors based on current sort order
final sortedAuthorNames = groupedByAuthor.keys.toList();
switch (_authorsSortOrder) {
case 'alpha':
sortedAuthorNames.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
break;
case 'alpha_desc':
sortedAuthorNames.sort((a, b) => b.toLowerCase().compareTo(a.toLowerCase()));
break;
case 'books':
sortedAuthorNames.sort((a, b) {
final aCount = groupedByAuthor[a]?.length ?? 0;
final bCount = groupedByAuthor[b]?.length ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.toLowerCase().compareTo(b.toLowerCase());
});
break;
}
// PERF: Use cached grouping/sorting - only re-sorts when data or sort order changes
final (sortedAuthorNames, groupedByAuthor) = _getSortedAuthors(audiobooks);
// Match music artists tab layout - no header, direct list/grid
return RefreshIndicator(
@@ -2239,8 +2369,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: 48,
height: 48,
// Only set width - height scales proportionally to preserve aspect ratio
// PERF: Match display size to avoid unnecessary memory usage
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
// Transparent placeholder - icon shows through
@@ -2589,8 +2720,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 512,
memCacheHeight: 512,
// PERF: Reduced from 512 to 128 - display is ~56px
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
@@ -2628,6 +2760,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
@@ -3022,6 +3159,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: CachedNetworkImage(
imageUrl: displayCovers[index],
fit: BoxFit.cover,
// PERF: Add cache sizing for series cover thumbnails
memCacheWidth: 150,
memCacheHeight: 150,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
@@ -3303,33 +3443,43 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
// Match detail screen structure exactly
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
@@ -3650,15 +3800,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Apply client-side sorting since we're combining data from multiple providers
final sortedArtists = List<Artist>.from(artists);
if (_artistsSortOrder == 'name_desc') {
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
} else {
// Default: name ascending
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedArtists = _getSortedArtists(artists);
final artistNames = _cachedArtistNames;
return RefreshIndicator(
color: colorScheme.primary,
@@ -3866,39 +4010,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Apply client-side sorting since we're combining data from multiple providers
final sortedAlbums = List<Album>.from(albums);
switch (_albumsSortOrder) {
case 'name_desc':
sortedAlbums.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
case 'year':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'artist_name':
sortedAlbums.sort((a, b) => (a.artistsString).toLowerCase().compareTo((b.artistsString).toLowerCase()));
break;
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
default:
// Default: name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
final albumNames = sortedAlbums.map((a) => a.name ?? '').toList();
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedAlbums = _getSortedAlbums(albums);
final albumNames = _cachedAlbumNames;
return RefreshIndicator(
color: colorScheme.primary,
@@ -4102,28 +4216,41 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
child: SizedBox(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
size: 16,
),
],
),
),
),
),
@@ -4202,34 +4329,44 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
),
],
),
),
),
@@ -4302,21 +4439,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Build items list based on current sort order for accurate letter display
final List<String> trackSortKeys;
switch (_tracksSortOrder) {
case 'artist':
case 'artist_name':
trackSortKeys = displayTracks.map((t) => t.artistsString).toList();
break;
case 'album':
trackSortKeys = displayTracks.map((t) => t.album?.name ?? t.name).toList();
break;
default:
// For name, duration, timestamp, etc. - use track name for letter scrollbar
trackSortKeys = displayTracks.map((t) => t.name).toList();
break;
}
// PERF: Use cached track sort keys - only recompute when data or sort order changes
final trackSortKeys = _getTrackSortKeys(displayTracks);
return RefreshIndicator(
color: colorScheme.primary,
+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
+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();
}
}
+96 -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;
@@ -811,10 +826,11 @@ 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
// 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) ||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) {
@@ -1084,6 +1100,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 +3428,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 +3488,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 +3635,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);
}
}
+24
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
@@ -360,6 +362,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 +888,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();
+61 -90
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)
+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
+132 -58
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;
@@ -536,62 +581,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 +686,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 +790,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 +807,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();
}
+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,
),
);
}
}
+40
View File
@@ -440,6 +440,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
@@ -714,6 +722,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
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:
@@ -1127,6 +1143,30 @@ 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:
+2 -1
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'
@@ -17,6 +17,7 @@ dependencies:
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