Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c00fb3dc78 | ||
|
|
ff34479169 | ||
|
|
d7127ea7da | ||
|
|
29b4887d0d | ||
|
|
18603c34e1 | ||
|
|
5ed2bf7725 | ||
|
|
67a99c88a0 | ||
|
|
ffbb41580b | ||
|
|
6942b56cfb | ||
|
|
14ba524ed0 | ||
|
|
49fc2a77e7 | ||
|
|
6632fa14c6 |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 909 KiB |
@@ -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
|
||||||
@@ -10,4 +10,7 @@ class NetworkConstants {
|
|||||||
|
|
||||||
/// Default HTTP port
|
/// Default HTTP port
|
||||||
static const int defaultHttpPort = 80;
|
static const int defaultHttpPort = 80;
|
||||||
|
|
||||||
|
/// Maximum number of pending WebSocket requests to prevent memory leaks
|
||||||
|
static const int maxPendingRequests = 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -695,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
Future<void> clearSearchHistory() async {
|
Future<void> clearSearchHistory() async {
|
||||||
await delete(searchHistory).go();
|
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 [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1292,6 +1292,16 @@
|
|||||||
"description": "Description for smart sort players setting"
|
"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": "Unavailable",
|
||||||
"@playerStateUnavailable": {
|
"@playerStateUnavailable": {
|
||||||
"description": "Player state when unavailable"
|
"description": "Player state when unavailable"
|
||||||
@@ -1347,6 +1357,21 @@
|
|||||||
"description": "Description for show hints toggle"
|
"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": "Pull to select players",
|
||||||
"@pullToSelectPlayers": {
|
"@pullToSelectPlayers": {
|
||||||
"description": "Hint shown when mini player bounces"
|
"description": "Hint shown when mini player bounces"
|
||||||
@@ -1593,5 +1618,40 @@
|
|||||||
"transferQueueToPlayer": "Transfer queue to another player",
|
"transferQueueToPlayer": "Transfer queue to another player",
|
||||||
"@transferQueueToPlayer": {
|
"@transferQueueToPlayer": {
|
||||||
"description": "Tooltip for transfer queue button"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1761,6 +1761,18 @@ abstract class S {
|
|||||||
/// **'Sort by status (playing, on, off) instead of alphabetically'**
|
/// **'Sort by status (playing, on, off) instead of alphabetically'**
|
||||||
String get smartSortPlayersDescription;
|
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
|
/// Player state when unavailable
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1821,6 +1833,24 @@ abstract class S {
|
|||||||
/// **'Display helpful tips for discovering features'**
|
/// **'Display helpful tips for discovering features'**
|
||||||
String get showHintsDescription;
|
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
|
/// Hint shown when mini player bounces
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2048,6 +2078,48 @@ abstract class S {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Transfer queue to another player'**
|
/// **'Transfer queue to another player'**
|
||||||
String get transferQueueToPlayer;
|
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> {
|
class _SDelegate extends LocalizationsDelegate<S> {
|
||||||
|
|||||||
@@ -909,6 +909,12 @@ class SDe extends S {
|
|||||||
@override
|
@override
|
||||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||||
|
|
||||||
@@ -941,6 +947,15 @@ class SDe extends S {
|
|||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get display => 'Display';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIconsDescription => 'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||||
|
|
||||||
@@ -1076,4 +1091,25 @@ class SDe extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -909,6 +909,12 @@ class SEn extends S {
|
|||||||
@override
|
@override
|
||||||
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Unavailable';
|
String get playerStateUnavailable => 'Unavailable';
|
||||||
|
|
||||||
@@ -941,6 +947,15 @@ class SEn extends S {
|
|||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get display => 'Display';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIconsDescription => 'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Pull to select players';
|
String get pullToSelectPlayers => 'Pull to select players';
|
||||||
|
|
||||||
@@ -1076,4 +1091,25 @@ class SEn extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -909,6 +909,12 @@ class SEs extends S {
|
|||||||
@override
|
@override
|
||||||
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'No disponible';
|
String get playerStateUnavailable => 'No disponible';
|
||||||
|
|
||||||
@@ -941,6 +947,15 @@ class SEs extends S {
|
|||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get display => 'Display';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIconsDescription => 'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||||
|
|
||||||
@@ -1076,4 +1091,25 @@ class SEs extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -909,6 +909,12 @@ class SFr extends S {
|
|||||||
@override
|
@override
|
||||||
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Indisponible';
|
String get playerStateUnavailable => 'Indisponible';
|
||||||
|
|
||||||
@@ -941,6 +947,15 @@ class SFr extends S {
|
|||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get display => 'Display';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showProviderIconsDescription => 'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||||
|
|
||||||
@@ -1076,4 +1091,25 @@ class SFr extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,8 +223,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then check expanded player
|
// Then check expanded player
|
||||||
|
// No haptic - Android back gesture provides its own
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||||
return true; // We handled it, don't let Navigator process it
|
return true; // We handled it, don't let Navigator process it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ class MediaItem {
|
|||||||
if (duration != null) 'duration': duration!.inSeconds,
|
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 {
|
class Artist extends MediaItem {
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../models/provider_instance.dart';
|
import '../models/provider_instance.dart';
|
||||||
|
import '../models/provider_manifest.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -63,6 +64,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Player? _selectedPlayer;
|
Player? _selectedPlayer;
|
||||||
List<Player> _availablePlayers = [];
|
List<Player> _availablePlayers = [];
|
||||||
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
||||||
|
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
|
||||||
|
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
|
||||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||||
Track? _currentTrack;
|
Track? _currentTrack;
|
||||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||||
@@ -70,6 +73,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Timer? _playerStateTimer;
|
Timer? _playerStateTimer;
|
||||||
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
|
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
|
// Local player state
|
||||||
bool _isLocalPlayerPowered = true;
|
bool _isLocalPlayerPowered = true;
|
||||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||||
@@ -221,6 +230,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// List of available music providers from MA
|
/// List of available music providers from MA
|
||||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
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 of enabled provider instance IDs (empty = all enabled)
|
||||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||||
|
|
||||||
@@ -510,6 +534,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Track? get currentTrack => _currentTrack;
|
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)
|
/// Whether we have cached players available (for instant UI display on app resume)
|
||||||
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
|
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
|
||||||
|
|
||||||
@@ -3712,8 +3745,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_availablePlayers.isNotEmpty) {
|
if (_availablePlayers.isNotEmpty) {
|
||||||
Player? playerToSelect;
|
Player? playerToSelect;
|
||||||
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
||||||
|
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
|
||||||
|
// Log settings for debugging player selection issues
|
||||||
|
_logger.log('⚙️ 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
|
// If "Prefer Local Player" is ON, always try to select local player first
|
||||||
// This takes priority even over the currently selected player
|
// This takes priority even over the currently selected player
|
||||||
if (preferLocalPlayer && builtinPlayerId != null) {
|
if (preferLocalPlayer && builtinPlayerId != null) {
|
||||||
@@ -3724,12 +3762,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
|
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Local player not available yet, will fall through to other options
|
// Local player not available yet, will fall through to other options
|
||||||
|
_logger.log('⚠️ Prefer local player ON but local player not available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
// Keep currently selected player if still available (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
|
||||||
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
|
||||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||||
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
||||||
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
|
// 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),
|
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If preferLocalPlayer is OFF, check if we should switch to a playing player
|
// Keep current selection - no auto-switching during non-coldStart refreshes
|
||||||
if (!preferLocalPlayer) {
|
// Auto-switch to playing player is now ONLY done on coldStart (see below)
|
||||||
final currentIsPlaying = currentPlayer.state == 'playing';
|
playerToSelect = currentPlayer;
|
||||||
// Exclude external sources - they're not playing MA content
|
if (currentPlayer.playerId != selectedId) {
|
||||||
final playingPlayers = _availablePlayers.where(
|
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
} else {
|
||||||
).toList();
|
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
|
||||||
|
|
||||||
// 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}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coldStart) {
|
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) {
|
if (playerToSelect == null) {
|
||||||
// Smart auto-selection priority:
|
// Smart auto-selection priority (coldStart only for auto-switch to playing):
|
||||||
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
// 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: Auto-switch to single playing player
|
||||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
// ONLY on coldStart AND only if disableAutoSwitch is OFF
|
||||||
// Exclude external sources - they're not playing MA content
|
// 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(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
).toList();
|
).toList();
|
||||||
if (playingPlayers.length == 1) {
|
if (playingPlayers.length == 1) {
|
||||||
playerToSelect = playingPlayers.first;
|
playerToSelect = playingPlayers.first;
|
||||||
|
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
|
||||||
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
|
_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
|
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
|
||||||
if (playerToSelect == null && builtinPlayerId != null) {
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
try {
|
try {
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
);
|
);
|
||||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Local player not found or not available - check why
|
// Local player not found or not available - check why
|
||||||
final localPlayer = _availablePlayers
|
final localPlayer = _availablePlayers
|
||||||
.where((p) => p.playerId == builtinPlayerId)
|
.where((p) => p.playerId == builtinPlayerId)
|
||||||
.toList();
|
.toList();
|
||||||
if (localPlayer.isEmpty) {
|
if (localPlayer.isEmpty) {
|
||||||
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||||
} else {
|
} else {
|
||||||
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
_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
|
// Priority 3: Last manually selected player
|
||||||
@@ -3840,6 +3880,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
(p) => p.available,
|
(p) => p.available,
|
||||||
orElse: () => _availablePlayers.first,
|
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
|
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||||
// stale track info when switching to a non-playing player.
|
// stale track info when switching to a non-playing player.
|
||||||
// For grouped child players, this returns the leader's track
|
// For grouped child players, this returns the leader's track
|
||||||
// IMPORTANT: Clear track for external sources - they're playing non-MA content
|
// NOTE: Always use cache even for external sources - the overlay shows cached
|
||||||
if (player.isExternalSource) {
|
// track info, so we should match that. The async _updatePlayerState() will
|
||||||
_currentTrack = null;
|
// clear it if the player is truly playing external content.
|
||||||
} else {
|
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize position tracker for this player (skip for external sources)
|
// Initialize position tracker for this player (skip for external sources)
|
||||||
_positionTracker.onPlayerSelected(player.playerId);
|
_positionTracker.onPlayerSelected(player.playerId);
|
||||||
@@ -3931,6 +3970,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
item: mediaItem,
|
item: mediaItem,
|
||||||
playing: player.state == 'playing',
|
playing: player.state == 'playing',
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Builtin player is idle - clear notification (fixes issue #42)
|
||||||
|
audioHandler.clearRemotePlaybackState();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For remote players, immediately show notification if we have cached track info
|
// For remote players, immediately show notification if we have cached track info
|
||||||
@@ -3973,6 +4015,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
position: position,
|
position: position,
|
||||||
duration: Duration.zero,
|
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)
|
/// 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) {
|
if (_selectedPlayer == null || _currentTrack == null) {
|
||||||
_notificationPositionTimer?.cancel();
|
_notificationPositionTimer?.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't update if player is not playing
|
final isPlaying = _selectedPlayer!.state == 'playing';
|
||||||
if (_selectedPlayer!.state != 'playing') {
|
|
||||||
|
// Don't update if player is not playing (unless forced for seek)
|
||||||
|
if (!isPlaying && !forceUpdate) {
|
||||||
_notificationPositionTimer?.cancel();
|
_notificationPositionTimer?.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
Duration position;
|
|
||||||
|
|
||||||
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
|
// Always use position tracker for notification position - it's the single source of truth.
|
||||||
// For remote players, use the position tracker (server-based interpolation)
|
// Previously used PCM elapsed time for Sendspin, but this breaks when:
|
||||||
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
|
// 1. Stream starts mid-track (resume from pause) - PCM elapsed resets to 0
|
||||||
position = _pcmAudioPlayer!.elapsedTime;
|
// 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
|
// Check if track has ended (position reached duration)
|
||||||
if (track.duration != null && position >= track.duration!) {
|
if (_positionTracker.hasReachedEnd) {
|
||||||
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)');
|
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
||||||
return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
@@ -4135,7 +4174,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
audioHandler.setRemotePlaybackState(
|
audioHandler.setRemotePlaybackState(
|
||||||
item: mediaItem,
|
item: mediaItem,
|
||||||
playing: true,
|
playing: isPlaying,
|
||||||
position: position,
|
position: position,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
);
|
);
|
||||||
@@ -4320,6 +4359,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> _updatePlayerState() async {
|
Future<void> _updatePlayerState() async {
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
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 {
|
try {
|
||||||
bool stateChanged = false;
|
bool stateChanged = false;
|
||||||
|
|
||||||
@@ -4338,6 +4381,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Try to refresh from API first for latest state
|
// Try to refresh from API first for latest state
|
||||||
final allPlayers = await getPlayers();
|
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(
|
Player? rawPlayer = allPlayers.firstWhere(
|
||||||
(p) => p.playerId == selectedId ||
|
(p) => p.playerId == selectedId ||
|
||||||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
|
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
|
||||||
@@ -4448,6 +4498,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
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) {
|
if (queue != null && queue.currentItem != null) {
|
||||||
final queueTrack = queue.currentItem!.track;
|
final queueTrack = queue.currentItem!.track;
|
||||||
final trackChanged = _currentTrack == null ||
|
final trackChanged = _currentTrack == null ||
|
||||||
@@ -4455,6 +4511,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_currentTrack!.name != queueTrack.name;
|
_currentTrack!.name != queueTrack.name;
|
||||||
|
|
||||||
if (trackChanged) {
|
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)
|
// Check if cached track has better metadata (images, proper artist info)
|
||||||
// This prevents losing album art and artist data when resuming the app
|
// This prevents losing album art and artist data when resuming the app
|
||||||
// For grouped child players, this gets the leader's cached track
|
// 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)
|
/// Sync a player to the currently selected player (temporary group)
|
||||||
/// The target player will play the same audio as the selected player
|
/// The target player will play the same audio as the selected player
|
||||||
Future<void> syncPlayerToSelected(String targetPlayerId) async {
|
Future<void> syncPlayerToSelected(String targetPlayerId) async {
|
||||||
@@ -5563,6 +5712,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// Immediately update position tracker for responsive UI
|
// Immediately update position tracker for responsive UI
|
||||||
_positionTracker.onSeek(position.toDouble());
|
_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);
|
await _api?.seek(playerId, position);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Seek', e);
|
ErrorHandler.logError('Seek', e);
|
||||||
@@ -5723,6 +5875,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_playerStateTimer?.cancel();
|
_playerStateTimer?.cancel();
|
||||||
_notificationPositionTimer?.cancel();
|
_notificationPositionTimer?.cancel();
|
||||||
_localPlayerStateReportTimer?.cancel();
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_sleepTimer?.cancel();
|
||||||
|
_sleepTimerDisplayTimer?.cancel();
|
||||||
_connectionStateSubscription?.cancel();
|
_connectionStateSubscription?.cancel();
|
||||||
_localPlayerEventSubscription?.cancel();
|
_localPlayerEventSubscription?.cancel();
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import '../services/metadata_service.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
import '../widgets/provider_icon.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
@@ -878,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
|
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceVariant,
|
child: Stack(
|
||||||
child: imageUrl != null
|
fit: StackFit.expand,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: imageUrl,
|
Container(
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceVariant,
|
||||||
// Match source memCacheWidth for smooth Hero
|
child: imageUrl != null
|
||||||
memCacheWidth: 256,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: 256,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
// Match source memCacheWidth for smooth Hero
|
||||||
placeholder: (_, __) => const SizedBox(),
|
memCacheWidth: 256,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
memCacheHeight: 256,
|
||||||
Icons.album_rounded,
|
fadeInDuration: Duration.zero,
|
||||||
size: coverSize * 0.43,
|
fadeOutDuration: Duration.zero,
|
||||||
color: colorScheme.onSurfaceVariant,
|
placeholder: (_, __) => const SizedBox(),
|
||||||
),
|
errorWidget: (_, __, ___) => Icon(
|
||||||
)
|
Icons.album_rounded,
|
||||||
: Icon(
|
size: coverSize * 0.43,
|
||||||
Icons.album_rounded,
|
color: colorScheme.onSurfaceVariant,
|
||||||
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) {
|
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
await maProvider.playTracks(
|
await maProvider.playTracks(
|
||||||
@@ -1272,48 +1281,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
startIndex: startIndex,
|
startIndex: startIndex,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
await maProvider.playRadio(player.playerId, track);
|
await maProvider.playRadio(player.playerId, track);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
await maProvider.playTracks(player.playerId, _tracks);
|
await maProvider.playTracks(player.playerId, _tracks);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
String _formatDuration(Duration duration) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/provider_icon.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
@@ -366,6 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
_isInLibrary = !newState;
|
_isInLibrary = !newState;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Remove from library
|
// Remove from library
|
||||||
@@ -413,6 +415,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
_isInLibrary = !newState;
|
_isInLibrary = !newState;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -430,171 +433,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
void _showRadioMenu(BuildContext context) {
|
void _showRadioMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
showModalBottomSheet(
|
try {
|
||||||
context: context,
|
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||||
backgroundColor: Colors.transparent,
|
if (mounted) {
|
||||||
builder: (context) => Container(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
decoration: BoxDecoration(
|
SnackBar(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
);
|
||||||
],
|
}
|
||||||
),
|
} catch (e) {
|
||||||
),
|
_logger.log('Error starting artist radio on player: $e');
|
||||||
).whenComplete(() {
|
if (mounted) {
|
||||||
// Slide mini player back up when sheet is dismissed
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
GlobalPlayerOverlay.showPlayer();
|
SnackBar(
|
||||||
});
|
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddToQueueMenu(BuildContext context) {
|
void _showAddToQueueMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
contextHint: S.of(context)!.selectPlayerToAddToQueue,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
showModalBottomSheet(
|
final api = maProvider.api;
|
||||||
context: context,
|
if (api == null) {
|
||||||
backgroundColor: Colors.transparent,
|
if (mounted) {
|
||||||
builder: (context) => Container(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
decoration: BoxDecoration(
|
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||||
color: Theme.of(context).colorScheme.surface,
|
);
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
}
|
||||||
),
|
return;
|
||||||
child: Column(
|
}
|
||||||
mainAxisSize: MainAxisSize.min,
|
try {
|
||||||
children: [
|
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||||
Spacing.vGap16,
|
if (mounted) {
|
||||||
Text(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
S.of(context)!.addToQueueOn,
|
SnackBar(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||||
),
|
duration: const Duration(seconds: 1),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
);
|
||||||
],
|
}
|
||||||
),
|
} catch (e) {
|
||||||
),
|
_logger.log('Error adding artist radio to queue: $e');
|
||||||
).whenComplete(() {
|
if (mounted) {
|
||||||
// Slide mini player back up when sheet is dismissed
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
GlobalPlayerOverlay.showPlayer();
|
SnackBar(
|
||||||
});
|
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadArtistImage() async {
|
Future<void> _loadArtistImage() async {
|
||||||
@@ -1100,37 +1005,46 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1.0, // Square album art
|
aspectRatio: 1.0, // Square album art
|
||||||
child: Hero(
|
child: Stack(
|
||||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
children: [
|
||||||
child: ClipRRect(
|
Hero(
|
||||||
borderRadius: BorderRadius.circular(12),
|
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
child: Container(
|
child: ClipRRect(
|
||||||
color: colorScheme.surfaceVariant,
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: imageUrl != null
|
child: Container(
|
||||||
? CachedNetworkImage(
|
color: colorScheme.surfaceVariant,
|
||||||
imageUrl: imageUrl,
|
child: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? CachedNetworkImage(
|
||||||
width: double.infinity,
|
imageUrl: imageUrl,
|
||||||
height: double.infinity,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
width: double.infinity,
|
||||||
fadeOutDuration: Duration.zero,
|
height: double.infinity,
|
||||||
errorWidget: (_, __, ___) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
child: Icon(
|
fadeOutDuration: Duration.zero,
|
||||||
Icons.album_rounded,
|
errorWidget: (_, __, ___) => Center(
|
||||||
size: 64,
|
child: Icon(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
Spacing.vGap8,
|
||||||
|
|||||||
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../theme/palette_helper.dart';
|
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'audiobook_detail_screen.dart';
|
import 'audiobook_detail_screen.dart';
|
||||||
|
import '../widgets/provider_icon.dart';
|
||||||
|
|
||||||
class AudiobookAuthorScreen extends StatefulWidget {
|
class AudiobookAuthorScreen extends StatefulWidget {
|
||||||
final String authorName;
|
final String authorName;
|
||||||
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||||
final _logger = DebugLogger();
|
|
||||||
late List<Audiobook> _audiobooks;
|
late List<Audiobook> _audiobooks;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
@@ -407,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
|||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Provider icon overlay
|
||||||
|
if (book.providerMappings?.isNotEmpty == true)
|
||||||
|
ProviderIconOverlay(
|
||||||
|
domain: book.providerMappings!.first.providerDomain,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
import '../widgets/provider_icon.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -212,13 +212,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
maProvider.setCurrentAudiobook(_audiobook);
|
maProvider.setCurrentAudiobook(_audiobook);
|
||||||
@@ -227,9 +222,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
widget.audiobook,
|
widget.audiobook,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAsFinished() async {
|
Future<void> _markAsFinished() async {
|
||||||
@@ -505,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: Stack(
|
||||||
child: imageUrl != null
|
fit: StackFit.expand,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: imageUrl,
|
Container(
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
// Match source memCacheWidth for smooth Hero animation
|
child: imageUrl != null
|
||||||
memCacheWidth: 256,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: 256,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
// Match source memCacheWidth for smooth Hero animation
|
||||||
placeholder: (_, __) => Center(
|
memCacheWidth: 256,
|
||||||
child: Icon(
|
memCacheHeight: 256,
|
||||||
MdiIcons.bookOutline,
|
fadeInDuration: Duration.zero,
|
||||||
size: coverSize * 0.43,
|
fadeOutDuration: Duration.zero,
|
||||||
color: colorScheme.onSurfaceVariant,
|
placeholder: (_, __) => Center(
|
||||||
),
|
child: Icon(
|
||||||
),
|
MdiIcons.bookOutline,
|
||||||
errorWidget: (_, __, ___) => Center(
|
size: coverSize * 0.43,
|
||||||
child: Icon(
|
color: colorScheme.onSurfaceVariant,
|
||||||
MdiIcons.bookOutline,
|
),
|
||||||
size: coverSize * 0.43,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
errorWidget: (_, __, ___) => Center(
|
||||||
),
|
child: Icon(
|
||||||
),
|
MdiIcons.bookOutline,
|
||||||
)
|
size: coverSize * 0.43,
|
||||||
: Center(
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||||
|
import '../widgets/provider_icon.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../utils/page_transitions.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
|
// Progress indicator
|
||||||
if (book.progress > 0)
|
if (book.progress > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Stack(
|
child: SizedBox(
|
||||||
children: [
|
width: 56,
|
||||||
Container(
|
height: 56,
|
||||||
width: 56,
|
child: Stack(
|
||||||
height: 56,
|
children: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
Container(
|
||||||
child: imageUrl != null
|
width: 56,
|
||||||
? CachedNetworkImage(
|
height: 56,
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
fadeInDuration: Duration.zero,
|
? CachedNetworkImage(
|
||||||
fadeOutDuration: Duration.zero,
|
imageUrl: imageUrl,
|
||||||
placeholder: (_, __) => Icon(
|
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,
|
MdiIcons.bookOutline,
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../models/provider_instance.dart';
|
|||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/album_card.dart';
|
import '../widgets/album_card.dart';
|
||||||
import '../widgets/artist_avatar.dart';
|
import '../widgets/artist_avatar.dart';
|
||||||
|
import '../widgets/provider_icon.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/theme_provider.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 _showOnlyArtistsWithAlbums = false; // Filter artists tab to only show those with albums
|
||||||
bool _isSyncingArtists = false; // Show progress while re-syncing artists
|
bool _isSyncingArtists = false; // Show progress while re-syncing artists
|
||||||
bool _isSyncingLibraries = false; // Show progress while re-syncing ABS libraries
|
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
|
// All tracks with lazy loading
|
||||||
List<Track> _allTracks = [];
|
List<Track> _allTracks = [];
|
||||||
@@ -79,6 +81,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
List<AudiobookSeries> _sortedSeries = [];
|
List<AudiobookSeries> _sortedSeries = [];
|
||||||
List<String> _seriesNames = [];
|
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)
|
// Media type selection (Music, Books, Podcasts)
|
||||||
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
|
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
|
||||||
|
|
||||||
@@ -221,10 +234,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onSyncServiceChanged() {
|
void _onSyncServiceChanged() {
|
||||||
// Rebuild when SyncService data changes (e.g., after sync completes)
|
// Only rebuild when sync completes, not during sync
|
||||||
if (mounted) {
|
// SyncService.isSyncing is already checked in menu button spinner
|
||||||
setState(() {});
|
if (!mounted || SyncService.instance.isSyncing) return;
|
||||||
}
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavigationChanged() {
|
void _onNavigationChanged() {
|
||||||
@@ -283,13 +296,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
void _handleProviderToggle(String providerId, bool enabled) {
|
void _handleProviderToggle(String providerId, bool enabled) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
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
|
// Instant UI update - client-side filtering will use the new enabled set
|
||||||
// The toggle updates MusicAssistantProvider.enabledProviderIds which we read during build
|
// The toggle updates MusicAssistantProvider.enabledProviderIds which we read during build
|
||||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||||
|
|
||||||
// Immediately rebuild the UI with client-side filtering
|
|
||||||
setState(() {});
|
|
||||||
|
|
||||||
// Cancel any pending debounce for background sync
|
// Cancel any pending debounce for background sync
|
||||||
_providerFilterDebounce?.cancel();
|
_providerFilterDebounce?.cancel();
|
||||||
|
|
||||||
@@ -300,7 +314,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
DebugLogger().log('🔄 Background sync to refresh source tracking...');
|
DebugLogger().log('🔄 Background sync to refresh source tracking...');
|
||||||
await maProvider.forceLibrarySync();
|
await maProvider.forceLibrarySync();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
_isSyncingProviders = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -429,6 +445,139 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
_audiobookNames = sorted.map((a) => a.name).toList();
|
_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() {
|
String _getCurrentViewMode() {
|
||||||
// Return the view mode for the currently selected tab
|
// Return the view mode for the currently selected tab
|
||||||
final tabIndex = _selectedTabIndex.value;
|
final tabIndex = _selectedTabIndex.value;
|
||||||
@@ -1660,8 +1809,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: () => _showOptionsMenu(colorScheme),
|
onTap: () => _showOptionsMenu(colorScheme),
|
||||||
// Show spinner on button while syncing artists or libraries filter
|
// Show spinner on button while syncing (artists filter, libraries filter, provider filter, or initial library build)
|
||||||
child: (_isSyncingArtists || _isSyncingLibraries)
|
child: (_isSyncingArtists || _isSyncingLibraries || _isSyncingProviders || SyncService.instance.isSyncing)
|
||||||
? Center(
|
? Center(
|
||||||
child: SizedBox.square(
|
child: SizedBox.square(
|
||||||
dimension: 18,
|
dimension: 18,
|
||||||
@@ -1770,7 +1919,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
await context.read<MusicAssistantProvider>().forceLibrarySync();
|
await context.read<MusicAssistantProvider>().forceLibrarySync();
|
||||||
if (mounted) {
|
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
|
// PERF: Use cached grouping/sorting - only re-sorts when data or sort order changes
|
||||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
final (sortedAuthorNames, groupedByAuthor) = _getSortedAuthors(audiobooks);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match music artists tab layout - no header, direct list/grid
|
// Match music artists tab layout - no header, direct list/grid
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -2239,8 +2369,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
// Only set width - height scales proportionally to preserve aspect ratio
|
// PERF: Match display size to avoid unnecessary memory usage
|
||||||
memCacheWidth: 128,
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
// Transparent placeholder - icon shows through
|
// Transparent placeholder - icon shows through
|
||||||
@@ -2589,8 +2720,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 512,
|
// PERF: Reduced from 512 to 128 - display is ~56px
|
||||||
memCacheHeight: 512,
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
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(
|
child: CachedNetworkImage(
|
||||||
imageUrl: displayCovers[index],
|
imageUrl: displayCovers[index],
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// PERF: Add cache sizing for series cover thumbnails
|
||||||
|
memCacheWidth: 150,
|
||||||
|
memCacheHeight: 150,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
@@ -3303,33 +3443,43 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
// Match detail screen structure exactly
|
// Match detail screen structure exactly
|
||||||
child: Container(
|
child: Stack(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
fit: StackFit.expand,
|
||||||
child: imageUrl != null
|
children: [
|
||||||
? CachedNetworkImage(
|
Container(
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
|
? CachedNetworkImage(
|
||||||
memCacheWidth: 256,
|
imageUrl: imageUrl,
|
||||||
memCacheHeight: 256,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: 256,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
memCacheHeight: 256,
|
||||||
errorWidget: (_, __, ___) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
child: Icon(
|
fadeOutDuration: Duration.zero,
|
||||||
MdiIcons.podcast,
|
placeholder: (_, __) => const SizedBox(),
|
||||||
size: 48,
|
errorWidget: (_, __, ___) => Center(
|
||||||
color: colorScheme.onSurfaceVariant,
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
// Provider icon overlay
|
||||||
: Center(
|
if (podcast.providerMappings?.isNotEmpty == true)
|
||||||
child: Icon(
|
ProviderIconOverlay(
|
||||||
MdiIcons.podcast,
|
domain: podcast.providerMappings!.first.providerDomain,
|
||||||
size: 48,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -3650,15 +3800,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply client-side sorting since we're combining data from multiple providers
|
// PERF: Use cached sorting - only re-sorts when data or sort order changes
|
||||||
final sortedArtists = List<Artist>.from(artists);
|
final sortedArtists = _getSortedArtists(artists);
|
||||||
if (_artistsSortOrder == 'name_desc') {
|
final artistNames = _cachedArtistNames;
|
||||||
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();
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
@@ -3866,39 +4010,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply client-side sorting since we're combining data from multiple providers
|
// PERF: Use cached sorting - only re-sorts when data or sort order changes
|
||||||
final sortedAlbums = List<Album>.from(albums);
|
final sortedAlbums = _getSortedAlbums(albums);
|
||||||
switch (_albumsSortOrder) {
|
final albumNames = _cachedAlbumNames;
|
||||||
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();
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
@@ -4102,28 +4216,41 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: Stack(
|
||||||
child: imageUrl != null
|
fit: StackFit.expand,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: imageUrl,
|
Container(
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
memCacheWidth: 96,
|
child: imageUrl != null
|
||||||
memCacheHeight: 96,
|
? CachedNetworkImage(
|
||||||
fadeInDuration: Duration.zero,
|
imageUrl: imageUrl,
|
||||||
fadeOutDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
memCacheWidth: 96,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
memCacheHeight: 96,
|
||||||
Icons.playlist_play_rounded,
|
fadeInDuration: Duration.zero,
|
||||||
color: colorScheme.onSurfaceVariant,
|
fadeOutDuration: Duration.zero,
|
||||||
),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
)
|
errorWidget: (_, __, ___) => Icon(
|
||||||
: Icon(
|
Icons.playlist_play_rounded,
|
||||||
Icons.playlist_play_rounded,
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Stack(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
fit: StackFit.expand,
|
||||||
child: imageUrl != null
|
children: [
|
||||||
? CachedNetworkImage(
|
Container(
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
width: double.infinity,
|
? CachedNetworkImage(
|
||||||
height: double.infinity,
|
imageUrl: imageUrl,
|
||||||
memCacheWidth: 256,
|
fit: BoxFit.cover,
|
||||||
memCacheHeight: 256,
|
width: double.infinity,
|
||||||
fadeInDuration: Duration.zero,
|
height: double.infinity,
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: 256,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
memCacheHeight: 256,
|
||||||
errorWidget: (_, __, ___) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
child: Icon(
|
fadeOutDuration: Duration.zero,
|
||||||
Icons.playlist_play_rounded,
|
placeholder: (_, __) => const SizedBox(),
|
||||||
size: 48,
|
errorWidget: (_, __, ___) => Center(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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
|
||||||
: Center(
|
if (playlist.providerMappings?.isNotEmpty == true)
|
||||||
child: Icon(
|
ProviderIconOverlay(
|
||||||
Icons.playlist_play_rounded,
|
domain: playlist.providerMappings!.first.providerDomain,
|
||||||
size: 48,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -4302,21 +4439,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build items list based on current sort order for accurate letter display
|
// PERF: Use cached track sort keys - only recompute when data or sort order changes
|
||||||
final List<String> trackSortKeys;
|
final trackSortKeys = _getTrackSortKeys(displayTracks);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../theme/theme_provider.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
import '../widgets/provider_icon.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
@@ -378,20 +378,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
|||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
await maProvider.playTracks(player.playerId, _tracks);
|
await maProvider.playTracks(player.playerId, _tracks);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addPlaylistToQueue() {
|
void _addPlaylistToQueue() {
|
||||||
@@ -552,20 +545,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
await maProvider.playRadio(player.playerId, track);
|
await maProvider.playRadio(player.playerId, track);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
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,
|
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: Stack(
|
||||||
child: imageUrl != null
|
fit: StackFit.expand,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: imageUrl,
|
Container(
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
memCacheWidth: 256,
|
child: imageUrl != null
|
||||||
memCacheHeight: 256,
|
? CachedNetworkImage(
|
||||||
fadeInDuration: Duration.zero,
|
imageUrl: imageUrl,
|
||||||
fadeOutDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
memCacheWidth: 256,
|
||||||
errorWidget: (_, __, ___) => Icon(
|
memCacheHeight: 256,
|
||||||
Icons.playlist_play_rounded,
|
fadeInDuration: Duration.zero,
|
||||||
size: coverSize * 0.43,
|
fadeOutDuration: Duration.zero,
|
||||||
color: colorScheme.onSurfaceVariant,
|
placeholder: (_, __) => const SizedBox(),
|
||||||
),
|
errorWidget: (_, __, ___) => Icon(
|
||||||
)
|
Icons.playlist_play_rounded,
|
||||||
: Icon(
|
size: coverSize * 0.43,
|
||||||
Icons.playlist_play_rounded,
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
import '../widgets/provider_icon.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -308,22 +308,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForEpisode,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
// Set podcast context before playing so player UI shows correct podcast name
|
// Set podcast context before playing so player UI shows correct podcast name
|
||||||
maProvider.setCurrentPodcastName(widget.podcast.name);
|
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||||
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration? duration) {
|
String _formatDuration(Duration? duration) {
|
||||||
@@ -486,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: Stack(
|
||||||
child: imageUrl != null
|
fit: StackFit.expand,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: imageUrl,
|
Container(
|
||||||
fit: BoxFit.cover,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
// Match source memCacheWidth for smooth Hero animation
|
child: imageUrl != null
|
||||||
memCacheWidth: 256,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: 256,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
// Match source memCacheWidth for smooth Hero animation
|
||||||
placeholder: (_, __) => Center(
|
memCacheWidth: 256,
|
||||||
child: Icon(
|
memCacheHeight: 256,
|
||||||
MdiIcons.podcast,
|
fadeInDuration: Duration.zero,
|
||||||
size: coverSize * 0.43,
|
fadeOutDuration: Duration.zero,
|
||||||
color: colorScheme.onSurfaceVariant,
|
placeholder: (_, __) => Center(
|
||||||
),
|
child: Icon(
|
||||||
),
|
MdiIcons.podcast,
|
||||||
errorWidget: (_, __, ___) => Center(
|
size: coverSize * 0.43,
|
||||||
child: Icon(
|
color: colorScheme.onSurfaceVariant,
|
||||||
MdiIcons.podcast,
|
),
|
||||||
size: coverSize * 0.43,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
errorWidget: (_, __, ___) => Center(
|
||||||
),
|
child: Icon(
|
||||||
),
|
MdiIcons.podcast,
|
||||||
)
|
size: coverSize * 0.43,
|
||||||
: Center(
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_picker_sheet.dart';
|
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import '../widgets/artist_avatar.dart';
|
import '../widgets/artist_avatar.dart';
|
||||||
@@ -2473,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showRadioOnMenu(Track track) {
|
void _showRadioOnMenu(Track track) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
try {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
@@ -2500,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add track to queue on current player (1 tap)
|
/// Add track to queue on current player (1 tap)
|
||||||
@@ -2943,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showAlbumPlayOnMenu(Album album) {
|
void _showAlbumPlayOnMenu(Album album) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
try {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
@@ -2970,9 +2957,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add album to queue
|
/// Add album to queue
|
||||||
@@ -3122,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showPlaylistPlayOnMenu(Playlist playlist) {
|
void _showPlaylistPlayOnMenu(Playlist playlist) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
try {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
@@ -3149,9 +3129,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play audiobook on current player
|
/// Play audiobook on current player
|
||||||
@@ -3189,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
|
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
try {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
@@ -3216,9 +3189,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle audiobook favorite status
|
/// Toggle audiobook favorite status
|
||||||
@@ -3306,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showRadioPlayOnMenu(MediaItem radio) {
|
void _showRadioPlayOnMenu(MediaItem radio) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
try {
|
try {
|
||||||
maProvider.selectPlayer(player);
|
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)
|
/// Play podcast (navigate to detail screen where episodes can be played)
|
||||||
@@ -3363,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _showPodcastPlayOnMenu(MediaItem podcast) {
|
void _showPodcastPlayOnMenu(MediaItem podcast) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||||
|
contextHint: S.of(context)!.selectPlayerToPlay,
|
||||||
showPlayerPickerSheet(
|
|
||||||
context: context,
|
|
||||||
title: S.of(context)!.playOn,
|
|
||||||
players: maProvider.availablePlayers,
|
|
||||||
selectedPlayer: maProvider.selectedPlayer,
|
|
||||||
onPlayerSelected: (player) async {
|
onPlayerSelected: (player) async {
|
||||||
// Select the player, then navigate to podcast detail
|
// Select the player, then navigate to podcast detail
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
@@ -3389,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
).whenComplete(() {
|
);
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
String _formatDuration(Duration duration) {
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Player settings
|
// Player settings
|
||||||
bool _preferLocalPlayer = false;
|
bool _preferLocalPlayer = false;
|
||||||
bool _smartSortPlayers = false;
|
bool _smartSortPlayers = false;
|
||||||
|
bool _disableAutoSwitch = false;
|
||||||
bool _volumePrecisionMode = true;
|
bool _volumePrecisionMode = true;
|
||||||
// Hint settings
|
// Hint settings
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
|
// Display settings
|
||||||
|
bool _showProviderIcons = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -84,11 +87,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Load player settings
|
// Load player settings
|
||||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||||
|
|
||||||
// Load hint settings
|
// Load hint settings
|
||||||
final showHints = await SettingsService.getShowHints();
|
final showHints = await SettingsService.getShowHints();
|
||||||
|
|
||||||
|
// Load display settings
|
||||||
|
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -106,8 +113,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_homeRowOrder = rowOrder;
|
_homeRowOrder = rowOrder;
|
||||||
_preferLocalPlayer = preferLocal;
|
_preferLocalPlayer = preferLocal;
|
||||||
_smartSortPlayers = smartSort;
|
_smartSortPlayers = smartSort;
|
||||||
|
_disableAutoSwitch = disableAutoSwitch;
|
||||||
_volumePrecisionMode = volumePrecision;
|
_volumePrecisionMode = volumePrecision;
|
||||||
_showHints = showHints;
|
_showHints = showHints;
|
||||||
|
_showProviderIcons = showProviderIcons;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
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),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Home Screen section
|
// Home Screen section
|
||||||
|
|||||||
@@ -193,6 +193,13 @@ class DatabaseService {
|
|||||||
return db.markItemsDeleted(itemType, [itemId]);
|
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
|
// Player Cache Convenience Methods
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
|
import '../utils/lru_cache.dart';
|
||||||
|
|
||||||
class MetadataService {
|
class MetadataService {
|
||||||
static final _logger = DebugLogger();
|
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
|
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
|
||||||
static final Map<String, String?> _artistImageCache = {};
|
// 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:
|
/// Fetches artist biography/description with fallback chain:
|
||||||
/// 1. Music Assistant metadata (passed in)
|
/// 1. Music Assistant metadata (passed in)
|
||||||
@@ -34,8 +39,8 @@ class MetadataService {
|
|||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
final cacheKey = 'artist:$artistName';
|
final cacheKey = 'artist:$artistName';
|
||||||
if (_cache.containsKey(cacheKey)) {
|
if (_descriptionCache.containsKey(cacheKey)) {
|
||||||
return _cache[cacheKey];
|
return _descriptionCache[cacheKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Last.fm API
|
// Try Last.fm API
|
||||||
@@ -43,7 +48,7 @@ class MetadataService {
|
|||||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||||
if (lastFmDesc != null) {
|
if (lastFmDesc != null) {
|
||||||
_cache[cacheKey] = lastFmDesc;
|
_descriptionCache[cacheKey] = lastFmDesc;
|
||||||
return lastFmDesc;
|
return lastFmDesc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,7 @@ class MetadataService {
|
|||||||
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||||
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||||
if (audioDbDesc != null) {
|
if (audioDbDesc != null) {
|
||||||
_cache[cacheKey] = audioDbDesc;
|
_descriptionCache[cacheKey] = audioDbDesc;
|
||||||
return audioDbDesc;
|
return audioDbDesc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,8 +88,8 @@ class MetadataService {
|
|||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
final cacheKey = 'album:$artistName:$albumName';
|
final cacheKey = 'album:$artistName:$albumName';
|
||||||
if (_cache.containsKey(cacheKey)) {
|
if (_descriptionCache.containsKey(cacheKey)) {
|
||||||
return _cache[cacheKey];
|
return _descriptionCache[cacheKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||||
@@ -92,7 +97,7 @@ class MetadataService {
|
|||||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||||
if (lastFmDesc != null) {
|
if (lastFmDesc != null) {
|
||||||
_cache[cacheKey] = lastFmDesc;
|
_descriptionCache[cacheKey] = lastFmDesc;
|
||||||
return lastFmDesc;
|
return lastFmDesc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,11 +256,11 @@ class MetadataService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for album images
|
/// Cache for album images - includes null entries for failed lookups (200 entries)
|
||||||
static final Map<String, String?> _albumImageCache = {};
|
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
|
||||||
|
|
||||||
// Cache for author images (audiobooks)
|
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
|
||||||
static final Map<String, String?> _authorImageCache = {};
|
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
|
||||||
|
|
||||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||||
/// Returns the image URL if found, null otherwise
|
/// Returns the image URL if found, null otherwise
|
||||||
@@ -740,11 +745,90 @@ class MetadataService {
|
|||||||
return null;
|
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() {
|
static void clearCache() {
|
||||||
_cache.clear();
|
_descriptionCache.clear();
|
||||||
_artistImageCache.clear();
|
_artistImageCache.clear();
|
||||||
_albumImageCache.clear();
|
_albumImageCache.clear();
|
||||||
_authorImageCache.clear();
|
_authorImageCache.clear();
|
||||||
|
_lyricsCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../constants/timings.dart' show Timings, LibraryConstants;
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../models/provider_instance.dart';
|
import '../models/provider_instance.dart';
|
||||||
|
import '../models/provider_manifest.dart';
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
import 'device_id_service.dart';
|
import 'device_id_service.dart';
|
||||||
@@ -60,6 +61,10 @@ class MusicAssistantAPI {
|
|||||||
int? get schemaVersion => _schemaVersion;
|
int? get schemaVersion => _schemaVersion;
|
||||||
Map<String, dynamic>? get serverInfo => _serverInfo;
|
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);
|
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||||
|
|
||||||
// Guard to prevent multiple simultaneous connection attempts
|
// Guard to prevent multiple simultaneous connection attempts
|
||||||
@@ -80,13 +85,15 @@ class MusicAssistantAPI {
|
|||||||
return;
|
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) {
|
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||||
_logger.log('Connection already in progress, waiting...');
|
_logger.log('Connection already in progress, waiting...');
|
||||||
return _connectionInProgress!.future;
|
return _connectionInProgress!.future;
|
||||||
}
|
}
|
||||||
|
// Set immediately before any async operations to close the race window
|
||||||
_connectionInProgress = Completer<void>();
|
_connectionInProgress = newCompleter;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_updateConnectionState(MAConnectionState.connecting);
|
_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 messageId = _uuid.v4();
|
||||||
final completer = Completer<Map<String, dynamic>>();
|
final completer = Completer<Map<String, dynamic>>();
|
||||||
_pendingRequests[messageId] = completer;
|
_pendingRequests[messageId] = completer;
|
||||||
@@ -811,10 +826,11 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
// Only call library_items API if:
|
// Only call library_items API if:
|
||||||
// 1. No ABS libraries configured (traditional mode), OR
|
// 1. No ABS libraries configured (traditional mode), OR
|
||||||
// 2. We have 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 ||
|
final shouldFetchFromApi = enabledLibraries == null ||
|
||||||
enabledLibraries.isEmpty ||
|
enabledLibraries.isEmpty ||
|
||||||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
|
|
||||||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
|
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
|
||||||
|
|
||||||
if (shouldFetchFromApi) {
|
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
|
/// Browse Music Assistant content by path
|
||||||
/// Returns a list of browse items (folders or media items)
|
/// Returns a list of browse items (folders or media items)
|
||||||
Future<List<dynamic>> browse(String? path) async {
|
Future<List<dynamic>> browse(String? path) async {
|
||||||
@@ -3367,10 +3428,22 @@ class MusicAssistantAPI {
|
|||||||
try {
|
try {
|
||||||
_logger.log('📥 Fetching initial state...');
|
_logger.log('📥 Fetching initial state...');
|
||||||
|
|
||||||
// Fetch provider manifests (available providers)
|
// Fetch provider manifests (available providers with icons)
|
||||||
try {
|
try {
|
||||||
await _sendCommand('providers/manifests');
|
final manifestResponse = await _sendCommand('providers/manifests');
|
||||||
_logger.log('✓ Provider manifests loaded');
|
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) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Could not load provider manifests: $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
|
/// Get current authenticated user's profile
|
||||||
/// Returns user info including display_name, username, groups, etc.
|
/// Returns user info including display_name, username, groups, etc.
|
||||||
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
|
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
|
||||||
@@ -3550,6 +3635,9 @@ class MusicAssistantAPI {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel all pending requests before reconnecting to prevent memory leaks
|
||||||
|
_cancelPendingRequests('Connection lost, reconnecting');
|
||||||
|
|
||||||
await Future.delayed(Timings.reconnectDelay);
|
await Future.delayed(Timings.reconnectDelay);
|
||||||
|
|
||||||
// Check again after delay
|
// Check again after delay
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class PositionTracker {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
double? serverTimestamp,
|
double? serverTimestamp,
|
||||||
}) {
|
}) {
|
||||||
|
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
|
||||||
final bool playerChanged = _playerId != playerId;
|
final bool playerChanged = _playerId != playerId;
|
||||||
final bool playStateChanged = _isPlaying != isPlaying;
|
final bool playStateChanged = _isPlaying != isPlaying;
|
||||||
final bool durationChanged = duration != null && _duration != duration;
|
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
|
// 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)
|
// 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)
|
// 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.
|
// 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.
|
// 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
|
final shouldUpdateAnchor = playerChanged
|
||||||
|| playStateChanged
|
|| playStateChanged
|
||||||
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|
||||||
|| !isPlaying;
|
|| !isPlaying
|
||||||
|
|| anchorIsGettingStale;
|
||||||
|
|
||||||
if (shouldUpdateAnchor) {
|
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();
|
_anchorTime = DateTime.now();
|
||||||
|
} else if (positionDiff > 2) {
|
||||||
|
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isPlaying = isPlaying;
|
_isPlaying = isPlaying;
|
||||||
@@ -209,6 +227,7 @@ class PositionTracker {
|
|||||||
|
|
||||||
/// Clear tracker state (e.g., when disconnecting)
|
/// Clear tracker state (e.g., when disconnecting)
|
||||||
void clear() {
|
void clear() {
|
||||||
|
_logger.log('⏱️ PositionTracker.clear() called');
|
||||||
_stopInterpolationTimer();
|
_stopInterpolationTimer();
|
||||||
_playerId = null;
|
_playerId = null;
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
@@ -218,9 +237,20 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _startInterpolationTimer() {
|
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();
|
_emitPosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,6 +259,7 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _stopInterpolationTimer() {
|
void _stopInterpolationTimer() {
|
||||||
|
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||||
_interpolationTimer?.cancel();
|
_interpolationTimer?.cancel();
|
||||||
_interpolationTimer = null;
|
_interpolationTimer = null;
|
||||||
}
|
}
|
||||||
@@ -240,6 +271,7 @@ class PositionTracker {
|
|||||||
// Only emit if second changed (reduces unnecessary updates)
|
// Only emit if second changed (reduces unnecessary updates)
|
||||||
if (seconds != _lastEmittedSeconds) {
|
if (seconds != _lastEmittedSeconds) {
|
||||||
_lastEmittedSeconds = seconds;
|
_lastEmittedSeconds = seconds;
|
||||||
|
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
|
||||||
_positionController.add(pos);
|
_positionController.add(pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class SettingsService {
|
|||||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
||||||
static const String _keySmartSortPlayers = 'smart_sort_players';
|
static const String _keySmartSortPlayers = 'smart_sort_players';
|
||||||
|
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
|
||||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
@@ -167,6 +168,7 @@ class SettingsService {
|
|||||||
|
|
||||||
// Hint System Settings
|
// Hint System Settings
|
||||||
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
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 _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
|
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<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
||||||
|
|
||||||
|
// Disable Auto-Switch - prevents automatic switching to playing players
|
||||||
|
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
|
||||||
|
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
// Automatically detects Phone vs Tablet based on screen size
|
// Automatically detects Phone vs Tablet based on screen size
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
@@ -882,6 +888,24 @@ class SettingsService {
|
|||||||
await prefs.setBool(_keyShowHints, show);
|
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
|
/// Check if user has ever used the player reveal gesture
|
||||||
static Future<bool> getHasUsedPlayerReveal() async {
|
static Future<bool> getHasUsedPlayerReveal() async {
|
||||||
final prefs = await _getPrefs();
|
final prefs = await _getPrefs();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../database/database.dart' show BatchCacheItem;
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import 'database_service.dart';
|
import 'database_service.dart';
|
||||||
import 'debug_logger.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 {
|
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
|
||||||
|
final batchItems = <BatchCacheItem>[];
|
||||||
for (final album in albums) {
|
for (final album in albums) {
|
||||||
try {
|
final providers = sourceProviders[album.itemId] ?? <String>[];
|
||||||
final providers = sourceProviders[album.itemId];
|
batchItems.add(BatchCacheItem(
|
||||||
// Save with first source provider (merging happens in DB layer)
|
itemType: 'album',
|
||||||
for (final provider in providers ?? <String>[]) {
|
itemId: album.itemId,
|
||||||
await _db.cacheItem(
|
data: jsonEncode(album.toJson()),
|
||||||
itemType: 'album',
|
sourceProviders: providers,
|
||||||
itemId: album.itemId,
|
));
|
||||||
data: album.toJson(),
|
}
|
||||||
sourceProvider: provider,
|
try {
|
||||||
);
|
await _db.batchCacheItems(batchItems);
|
||||||
}
|
_logger.log('💾 Batch saved ${albums.length} albums to cache');
|
||||||
// If no source providers, still cache the item
|
} catch (e) {
|
||||||
if (providers == null || providers.isEmpty) {
|
_logger.log('⚠️ Failed to batch cache albums: $e');
|
||||||
await _db.cacheItem(
|
|
||||||
itemType: 'album',
|
|
||||||
itemId: album.itemId,
|
|
||||||
data: album.toJson(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Failed to cache album ${album.name}: $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 {
|
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
|
||||||
|
final batchItems = <BatchCacheItem>[];
|
||||||
for (final artist in artists) {
|
for (final artist in artists) {
|
||||||
try {
|
final providers = sourceProviders[artist.itemId] ?? <String>[];
|
||||||
final providers = sourceProviders[artist.itemId];
|
batchItems.add(BatchCacheItem(
|
||||||
for (final provider in providers ?? <String>[]) {
|
itemType: 'artist',
|
||||||
await _db.cacheItem(
|
itemId: artist.itemId,
|
||||||
itemType: 'artist',
|
data: jsonEncode(artist.toJson()),
|
||||||
itemId: artist.itemId,
|
sourceProviders: providers,
|
||||||
data: artist.toJson(),
|
));
|
||||||
sourceProvider: provider,
|
}
|
||||||
);
|
try {
|
||||||
}
|
await _db.batchCacheItems(batchItems);
|
||||||
if (providers == null || providers.isEmpty) {
|
_logger.log('💾 Batch saved ${artists.length} artists to cache');
|
||||||
await _db.cacheItem(
|
} catch (e) {
|
||||||
itemType: 'artist',
|
_logger.log('⚠️ Failed to batch cache artists: $e');
|
||||||
itemId: artist.itemId,
|
|
||||||
data: artist.toJson(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Failed to cache artist ${artist.name}: $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 {
|
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
|
||||||
|
final batchItems = <BatchCacheItem>[];
|
||||||
for (final audiobook in audiobooks) {
|
for (final audiobook in audiobooks) {
|
||||||
try {
|
final providers = sourceProviders[audiobook.itemId] ?? <String>[];
|
||||||
final providers = sourceProviders[audiobook.itemId];
|
batchItems.add(BatchCacheItem(
|
||||||
for (final provider in providers ?? <String>[]) {
|
itemType: 'audiobook',
|
||||||
await _db.cacheItem(
|
itemId: audiobook.itemId,
|
||||||
itemType: 'audiobook',
|
data: jsonEncode(audiobook.toJson()),
|
||||||
itemId: audiobook.itemId,
|
sourceProviders: providers,
|
||||||
data: audiobook.toJson(),
|
));
|
||||||
sourceProvider: provider,
|
}
|
||||||
);
|
try {
|
||||||
}
|
await _db.batchCacheItems(batchItems);
|
||||||
if (providers == null || providers.isEmpty) {
|
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
|
||||||
await _db.cacheItem(
|
} catch (e) {
|
||||||
itemType: 'audiobook',
|
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
|
||||||
itemId: audiobook.itemId,
|
|
||||||
data: audiobook.toJson(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $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 {
|
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
|
||||||
|
final batchItems = <BatchCacheItem>[];
|
||||||
for (final playlist in playlists) {
|
for (final playlist in playlists) {
|
||||||
try {
|
final providers = sourceProviders[playlist.itemId] ?? <String>[];
|
||||||
final providers = sourceProviders[playlist.itemId];
|
batchItems.add(BatchCacheItem(
|
||||||
for (final provider in providers ?? <String>[]) {
|
itemType: 'playlist',
|
||||||
await _db.cacheItem(
|
itemId: playlist.itemId,
|
||||||
itemType: 'playlist',
|
data: jsonEncode(playlist.toJson()),
|
||||||
itemId: playlist.itemId,
|
sourceProviders: providers,
|
||||||
data: playlist.toJson(),
|
));
|
||||||
sourceProvider: provider,
|
}
|
||||||
);
|
try {
|
||||||
}
|
await _db.batchCacheItems(batchItems);
|
||||||
if (providers == null || providers.isEmpty) {
|
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
|
||||||
await _db.cacheItem(
|
} catch (e) {
|
||||||
itemType: 'playlist',
|
_logger.log('⚠️ Failed to batch cache playlists: $e');
|
||||||
itemId: playlist.itemId,
|
|
||||||
data: playlist.toJson(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_logger.log('💾 Saved ${playlists.length} playlists to cache');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force a fresh sync (for pull-to-refresh)
|
/// Force a fresh sync (for pull-to-refresh)
|
||||||
|
|||||||
@@ -129,13 +129,31 @@ class Durations {
|
|||||||
class ButtonSizes {
|
class ButtonSizes {
|
||||||
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;
|
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;
|
static const double md = 36.0;
|
||||||
|
|
||||||
|
/// 44x44 - Meets WCAG Level AA minimum
|
||||||
static const double lg = 44.0;
|
static const double lg = 44.0;
|
||||||
|
|
||||||
|
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
|
||||||
static const double xl = 48.0;
|
static const double xl = 48.0;
|
||||||
|
|
||||||
|
/// 56x56 - Comfortable touch target for primary actions
|
||||||
static const double xxl = 56.0;
|
static const double xxl = 56.0;
|
||||||
|
|
||||||
|
/// Minimum WCAG-compliant touch target size (48x48)
|
||||||
|
static const double minAccessible = 48.0;
|
||||||
|
|
||||||
// Icon sizes within buttons
|
// Icon sizes within buttons
|
||||||
static const double iconSm = 16.0;
|
static const double iconSm = 16.0;
|
||||||
static const double iconMd = 18.0;
|
static const double iconMd = 18.0;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../constants/timings.dart';
|
|||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
|
import 'provider_icon.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatefulWidget {
|
class AlbumCard extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
@@ -40,7 +41,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -123,8 +125,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
if (_isNavigating) return;
|
if (_isNavigating) return;
|
||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// PERF: Color extraction deferred to detail screen's initState
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
// to avoid competing with Hero animation for GPU resources
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -151,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
child: Container(
|
child: Stack(
|
||||||
color: colorScheme.surfaceVariant,
|
fit: StackFit.expand,
|
||||||
child: imageUrl != null
|
children: [
|
||||||
? CachedNetworkImage(
|
Container(
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceVariant,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
memCacheWidth: cacheSize,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: cacheSize,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: cacheSize,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
memCacheHeight: cacheSize,
|
||||||
errorWidget: (context, url, error) {
|
fadeInDuration: Duration.zero,
|
||||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
fadeOutDuration: Duration.zero,
|
||||||
if (!_maImageFailed && url == maImageUrl) {
|
placeholder: (context, url) => const SizedBox(),
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
errorWidget: (context, url, error) {
|
||||||
_onImageError();
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
});
|
if (!_maImageFailed && url == maImageUrl) {
|
||||||
}
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
return Icon(
|
_onImageError();
|
||||||
Icons.album_rounded,
|
});
|
||||||
size: 64,
|
}
|
||||||
color: colorScheme.onSurfaceVariant,
|
return Icon(
|
||||||
);
|
Icons.album_rounded,
|
||||||
},
|
size: 64,
|
||||||
)
|
color: colorScheme.onSurfaceVariant,
|
||||||
: Center(
|
);
|
||||||
child: Icon(
|
},
|
||||||
Icons.album_rounded,
|
)
|
||||||
size: 64,
|
: Center(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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),
|
const SizedBox(height: 8),
|
||||||
// Album title with year
|
// Album title with year
|
||||||
Hero(
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
Text(
|
||||||
child: Material(
|
widget.album.nameWithYear,
|
||||||
color: Colors.transparent,
|
maxLines: 1,
|
||||||
child: Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
widget.album.nameWithYear,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
maxLines: 1,
|
color: colorScheme.onSurface,
|
||||||
overflow: TextOverflow.ellipsis,
|
fontWeight: FontWeight.w500,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name
|
// Artist name
|
||||||
Hero(
|
Text(
|
||||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
|
widget.album.artistsString,
|
||||||
child: Material(
|
maxLines: 1,
|
||||||
color: Colors.transparent,
|
overflow: TextOverflow.ellipsis,
|
||||||
child: Text(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
widget.album.artistsString,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -116,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
if (_isNavigating) return;
|
if (_isNavigating) return;
|
||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// PERF: Color extraction deferred to detail screen's initState
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
// to avoid competing with Hero animation for GPU resources
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -186,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Artist name - fixed height container so image size is consistent
|
// Artist name - fixed height container so image size is consistent
|
||||||
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 36, // Fixed height for 2 lines of text
|
height: 36, // Fixed height for 2 lines of text
|
||||||
child: Hero(
|
child: Text(
|
||||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
widget.artist.name,
|
||||||
child: Material(
|
maxLines: 2,
|
||||||
color: Colors.transparent,
|
overflow: TextOverflow.ellipsis,
|
||||||
child: Text(
|
textAlign: TextAlign.center,
|
||||||
widget.artist.name,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
maxLines: 2,
|
color: colorScheme.onSurface,
|
||||||
overflow: TextOverflow.ellipsis,
|
fontWeight: FontWeight.w500,
|
||||||
textAlign: TextAlign.center,
|
height: 1.15,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
height: 1.15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
|
|||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../screens/audiobook_detail_screen.dart';
|
import '../screens/audiobook_detail_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import 'provider_icon.dart';
|
||||||
|
|
||||||
class AudiobookRow extends StatefulWidget {
|
class AudiobookRow extends StatefulWidget {
|
||||||
final String title;
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
|
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
|
||||||
|
|
||||||
/// Collapse the player if it's expanded
|
/// Collapse the player if it's expanded
|
||||||
static void collapsePlayer() {
|
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||||
globalPlayerKey.currentState?.collapse();
|
static void collapsePlayer({bool withHaptic = true}) {
|
||||||
|
globalPlayerKey.currentState?.collapse(withHaptic: withHaptic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force collapse player to mini state, instantly closing queue panel if open.
|
/// 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
|
/// Check if the player reveal is currently visible
|
||||||
static bool get isPlayerRevealVisible =>
|
static bool get isPlayerRevealVisible =>
|
||||||
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
_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>
|
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||||
@@ -208,6 +224,11 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
// Key for the reveal overlay
|
// Key for the reveal overlay
|
||||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -394,6 +415,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
void _hidePlayerReveal() {
|
void _hidePlayerReveal() {
|
||||||
if (!_isRevealVisible) return;
|
if (!_isRevealVisible) return;
|
||||||
_bounceOffsetNotifier.value = 0;
|
_bounceOffsetNotifier.value = 0;
|
||||||
|
_clearPendingAction(); // Clear pending action when overlay is dismissed
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRevealVisible = false;
|
_isRevealVisible = false;
|
||||||
_isOnboardingReveal = false;
|
_isOnboardingReveal = false;
|
||||||
@@ -413,6 +435,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
_singleBounceController.forward();
|
_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)
|
/// End hint mode (called when user taps skip button)
|
||||||
void _endHintMode() {
|
void _endHintMode() {
|
||||||
if (!_isHintModeActive) return;
|
if (!_isHintModeActive) return;
|
||||||
@@ -536,62 +581,76 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
}
|
}
|
||||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||||
|
|
||||||
return Container(
|
// Fade out and slide down nav bar as player expands
|
||||||
decoration: BoxDecoration(
|
// Use IgnorePointer when faded to prevent accidental taps
|
||||||
color: navBgColor,
|
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
|
||||||
boxShadow: expansionState.progress < 0.5
|
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
|
||||||
? [
|
|
||||||
BoxShadow(
|
return IgnorePointer(
|
||||||
color: Colors.black.withOpacity(0.1),
|
ignoring: navOpacity < 0.1,
|
||||||
blurRadius: 10,
|
child: Transform.translate(
|
||||||
offset: const Offset(0, -2),
|
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,
|
||||||
),
|
),
|
||||||
]
|
BottomNavigationBarItem(
|
||||||
: null,
|
icon: const Icon(Icons.library_music_outlined),
|
||||||
),
|
activeIcon: const Icon(Icons.library_music_rounded),
|
||||||
child: BottomNavigationBar(
|
label: S.of(context)!.library,
|
||||||
currentIndex: navigationProvider.selectedIndex,
|
),
|
||||||
onTap: (index) {
|
BottomNavigationBarItem(
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
icon: const Icon(Icons.search_rounded),
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
activeIcon: const Icon(Icons.search_rounded),
|
||||||
}
|
label: S.of(context)!.search,
|
||||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
),
|
||||||
if (index == 3) {
|
BottomNavigationBarItem(
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
icon: const Icon(Icons.settings_outlined),
|
||||||
} else if (navigationProvider.selectedIndex == 3) {
|
activeIcon: const Icon(Icons.settings_rounded),
|
||||||
GlobalPlayerOverlay.showPlayer();
|
label: S.of(context)!.settings,
|
||||||
}
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -627,11 +686,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||||
builder: (context, opacity, child) {
|
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(
|
return BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
// Use same dark color as connection screen for seamless transition
|
color: backdropColor.withOpacity(opacity),
|
||||||
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -727,6 +790,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||||
miniPlayerHeight: 64,
|
miniPlayerHeight: 64,
|
||||||
showOnboardingHints: _isOnboardingReveal,
|
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
|
// Global player overlay - renders ON TOP so cards slide behind it
|
||||||
@@ -740,6 +807,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
// Only show player if connected and has a selected player
|
// Only show player if connected and has a selected player
|
||||||
if (!state.isConnected || !state.hasPlayer) {
|
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();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../models/media_item.dart' show Audiobook, Chapter;
|
import '../../models/media_item.dart' show Audiobook, Chapter;
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../utils/duration_formatter.dart';
|
||||||
import '../common/empty_state.dart';
|
import '../common/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -26,18 +27,6 @@ class ChaptersPanel extends StatelessWidget {
|
|||||||
required this.onClose,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chapters = audiobook?.chapters ?? [];
|
final chapters = audiobook?.chapters ?? [];
|
||||||
@@ -147,7 +136,7 @@ class ChaptersPanel extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
_formatDuration(chapter.positionMs ~/ 1000),
|
formatDurationSeconds(chapter.positionMs ~/ 1000),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(0.5),
|
color: textColor.withOpacity(0.5),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -19,12 +19,25 @@ class PlayerRevealOverlay extends StatefulWidget {
|
|||||||
final double miniPlayerHeight;
|
final double miniPlayerHeight;
|
||||||
final bool showOnboardingHints; // Show additional hints for first-time users
|
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({
|
const PlayerRevealOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
required this.miniPlayerBottom,
|
required this.miniPlayerBottom,
|
||||||
required this.miniPlayerHeight,
|
required this.miniPlayerHeight,
|
||||||
this.showOnboardingHints = false,
|
this.showOnboardingHints = false,
|
||||||
|
this.onPlayerSelected,
|
||||||
|
this.contextHint,
|
||||||
|
this.contextHintIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -383,16 +396,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Material(
|
child: Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: isOnboarding
|
child: widget.contextHint != null
|
||||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
? buildHintRow(
|
||||||
: Column(
|
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||||
mainAxisSize: MainAxisSize.min,
|
widget.contextHint!,
|
||||||
children: [
|
)
|
||||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
: isOnboarding
|
||||||
const SizedBox(height: 4),
|
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
: 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,
|
textColor: data.cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
maProvider.selectPlayer(data.player);
|
if (widget.onPlayerSelected != null) {
|
||||||
|
widget.onPlayerSelected!(data.player);
|
||||||
|
} else {
|
||||||
|
maProvider.selectPlayer(data.player);
|
||||||
|
}
|
||||||
dismiss();
|
dismiss();
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
@@ -489,7 +511,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
textColor: data.cardTextColor,
|
textColor: data.cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
maProvider.selectPlayer(data.player);
|
if (widget.onPlayerSelected != null) {
|
||||||
|
widget.onPlayerSelected!(data.player);
|
||||||
|
} else {
|
||||||
|
maProvider.selectPlayer(data.player);
|
||||||
|
}
|
||||||
dismiss();
|
dismiss();
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../models/player.dart';
|
import '../../models/player.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../utils/duration_formatter.dart';
|
||||||
import '../common/empty_state.dart';
|
import '../common/empty_state.dart';
|
||||||
import '../global_player_overlay.dart';
|
import '../global_player_overlay.dart';
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ class QueuePanel extends StatefulWidget {
|
|||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
final ValueChanged<bool>? onDraggingChanged;
|
final ValueChanged<bool>? onDraggingChanged;
|
||||||
final VoidCallback? onSwipeStart;
|
final VoidCallback? onSwipeStart;
|
||||||
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
|
final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
|
||||||
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
|
final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
|
||||||
|
|
||||||
const QueuePanel({
|
const QueuePanel({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -55,6 +56,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
List<QueueItem> _items = [];
|
List<QueueItem> _items = [];
|
||||||
final GlobalKey _stackKey = GlobalKey();
|
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
|
// Transfer dropdown animation
|
||||||
late AnimationController _dropdownController;
|
late AnimationController _dropdownController;
|
||||||
bool _showingTransferDropdown = false;
|
bool _showingTransferDropdown = false;
|
||||||
@@ -100,6 +106,17 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 300),
|
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
|
@override
|
||||||
@@ -135,18 +152,13 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
_dropdownController.dispose();
|
_dropdownController.dispose();
|
||||||
_pendingReorderTimer?.cancel();
|
_pendingReorderTimer?.cancel();
|
||||||
super.dispose();
|
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 {
|
void _handleDelete(QueueItem item, int index) async {
|
||||||
// Store item for potential rollback
|
// Store item for potential rollback
|
||||||
@@ -427,6 +439,29 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
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) {
|
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
|
||||||
if (_dragIndex != null) return;
|
if (_dragIndex != null) return;
|
||||||
|
|
||||||
@@ -588,15 +623,71 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
try {
|
try {
|
||||||
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
||||||
} catch (e) {
|
} 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
|
// Calculate the position shift for the API
|
||||||
await Future.delayed(const Duration(milliseconds: 1500));
|
final posShift = targetIndex - index;
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
// Cancel any existing timer to prevent stacking
|
||||||
_optimisticCurrentIndex = null;
|
_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;
|
_swipeLocked = false;
|
||||||
_velocitySamples.clear();
|
_velocitySamples.clear();
|
||||||
_addVelocitySample(event.position, _swipeLastTime!);
|
_addVelocitySample(event.position, _swipeLastTime!);
|
||||||
|
// Capture scroll position at gesture start - only allow close if already at top
|
||||||
|
_wasAtTopOnSwipeStart = _isAtTop;
|
||||||
} else if (startedInEdgeZone) {
|
} else if (startedInEdgeZone) {
|
||||||
// Clear swipe state for edge touches
|
// Clear swipe state for edge touches
|
||||||
_swipeStart = null;
|
_swipeStart = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerMove: (event) {
|
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;
|
if (_swipeStart == null || _dragIndex != null) return;
|
||||||
|
|
||||||
final dx = event.position.dx - _swipeStart!.dx;
|
final dx = (event.position.dx - _swipeStart!.dx).abs();
|
||||||
final dy = (event.position.dy - _swipeStart!.dy).abs();
|
final dy = event.position.dy - _swipeStart!.dy;
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
// Track all move events for velocity calculation
|
// 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)
|
// Once direction is locked, maintain it (prevents accidental cancellation)
|
||||||
if (_swipeLocked && _isSwiping) {
|
if (_swipeLocked && _isSwiping) {
|
||||||
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
|
widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
|
// Check if this is a vertical swipe (dy > dx * 1.2)
|
||||||
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
|
final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
|
||||||
|
|
||||||
if (isHorizontal && dx > 0) {
|
if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
|
||||||
// Horizontal swipe right - close gesture
|
// Vertical swipe down - close gesture (only when was at top when gesture started)
|
||||||
if (!_isSwiping) {
|
if (!_isSwiping) {
|
||||||
_isSwiping = true;
|
_isSwiping = true;
|
||||||
_swipeLocked = true; // Lock direction once swipe starts
|
_swipeLocked = true; // Lock direction once swipe starts
|
||||||
widget.onSwipeStart?.call();
|
widget.onSwipeStart?.call();
|
||||||
}
|
}
|
||||||
widget.onSwipeUpdate?.call(dx);
|
widget.onSwipeUpdate?.call(dy);
|
||||||
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
|
} else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
|
||||||
// Direction established as vertical - don't start swipe
|
// Direction established as horizontal - don't start swipe
|
||||||
_swipeLocked = true; // Lock as non-swipe
|
_swipeLocked = true; // Lock as non-swipe
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerUp: (event) {
|
onPointerUp: (event) {
|
||||||
if (_isSwiping && _swipeStart != null) {
|
if (_isSwiping && _swipeStart != null) {
|
||||||
final velocity = _calculateAverageVelocity();
|
final velocity = _calculateAverageVelocityVertical();
|
||||||
final totalDx = event.position.dx - _swipeStart!.dx;
|
final totalDy = event.position.dy - _swipeStart!.dy;
|
||||||
widget.onSwipeEnd?.call(velocity, totalDx);
|
widget.onSwipeEnd?.call(velocity, totalDy);
|
||||||
}
|
}
|
||||||
_resetSwipeState();
|
_resetSwipeState();
|
||||||
},
|
},
|
||||||
@@ -690,11 +783,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
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,
|
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
||||||
padding: Spacing.paddingAll12,
|
padding: Spacing.paddingAll12,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Queue',
|
'Queue',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -827,6 +920,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
key: const PageStorageKey('queue_list'),
|
key: const PageStorageKey('queue_list'),
|
||||||
|
controller: _scrollController,
|
||||||
padding: Spacing.paddingH8,
|
padding: Spacing.paddingH8,
|
||||||
// Disable scrolling while dragging to prevent gesture conflict
|
// Disable scrolling while dragging to prevent gesture conflict
|
||||||
physics: _dragIndex != null
|
physics: _dragIndex != null
|
||||||
@@ -850,8 +944,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: ValueKey(item.queueItemId),
|
key: ValueKey(item.queueItemId),
|
||||||
direction: DismissDirection.endToStart,
|
direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
|
||||||
|
// Swipe right (startToEnd) = move to play next
|
||||||
background: Container(
|
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,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 20),
|
padding: const EdgeInsets.only(right: 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -863,8 +968,15 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
confirmDismiss: (direction) async {
|
confirmDismiss: (direction) async {
|
||||||
// Block dismissal if swipe started from screen edge (Android back gesture)
|
// Block dismissal if swipe started from screen edge (Android back gesture)
|
||||||
if (lastTouchWasInEdgeZone) return false;
|
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),
|
onDismissed: (direction) => _handleDelete(item, index),
|
||||||
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
|
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}) {
|
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
|
||||||
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||||
final duration = _formatDuration(item.track.duration);
|
final duration = formatDurationOrEmpty(item.track.duration);
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import 'provider_icon.dart';
|
||||||
|
|
||||||
class PlaylistCard extends StatefulWidget {
|
class PlaylistCard extends StatefulWidget {
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
@@ -48,8 +49,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
if (_isNavigating) return;
|
if (_isNavigating) return;
|
||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// PERF: Color extraction deferred to detail screen's initState
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
// to avoid competing with Hero animation for GPU resources
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -76,51 +77,56 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
child: Container(
|
child: Stack(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
fit: StackFit.expand,
|
||||||
child: imageUrl != null
|
children: [
|
||||||
? CachedNetworkImage(
|
Container(
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
memCacheWidth: cacheSize,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: cacheSize,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: cacheSize,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
memCacheHeight: cacheSize,
|
||||||
errorWidget: (context, url, error) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
child: Icon(
|
fadeOutDuration: Duration.zero,
|
||||||
Icons.playlist_play_rounded,
|
placeholder: (context, url) => const SizedBox(),
|
||||||
size: 64,
|
errorWidget: (context, url, error) => Center(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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
|
||||||
: Center(
|
if (widget.playlist.providerMappings?.isNotEmpty == true)
|
||||||
child: Icon(
|
ProviderIconOverlay(
|
||||||
Icons.playlist_play_rounded,
|
domain: widget.playlist.providerMappings!.first.providerDomain,
|
||||||
size: 64,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Playlist title
|
// Playlist title
|
||||||
Hero(
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
Text(
|
||||||
child: Material(
|
widget.playlist.name,
|
||||||
color: Colors.transparent,
|
maxLines: 1,
|
||||||
child: Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
widget.playlist.name,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
maxLines: 1,
|
color: colorScheme.onSurface,
|
||||||
overflow: TextOverflow.ellipsis,
|
fontWeight: FontWeight.w500,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Owner/track count
|
// Owner/track count
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../constants/hero_tags.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import 'provider_icon.dart';
|
||||||
|
|
||||||
class PodcastCard extends StatefulWidget {
|
class PodcastCard extends StatefulWidget {
|
||||||
final MediaItem podcast;
|
final MediaItem podcast;
|
||||||
@@ -54,8 +55,8 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
if (_isNavigating) return;
|
if (_isNavigating) return;
|
||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
// Update adaptive colors immediately on tap
|
// PERF: Color extraction deferred to detail screen's initState
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
// to avoid competing with Hero animation for GPU resources
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -82,51 +83,56 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
child: Container(
|
child: Stack(
|
||||||
color: colorScheme.surfaceVariant,
|
fit: StackFit.expand,
|
||||||
child: imageUrl != null
|
children: [
|
||||||
? CachedNetworkImage(
|
Container(
|
||||||
imageUrl: imageUrl,
|
color: colorScheme.surfaceVariant,
|
||||||
fit: BoxFit.cover,
|
child: imageUrl != null
|
||||||
memCacheWidth: cacheSize,
|
? CachedNetworkImage(
|
||||||
memCacheHeight: cacheSize,
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: Duration.zero,
|
fit: BoxFit.cover,
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: cacheSize,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
memCacheHeight: cacheSize,
|
||||||
errorWidget: (context, url, error) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
child: Icon(
|
fadeOutDuration: Duration.zero,
|
||||||
MdiIcons.podcast,
|
placeholder: (context, url) => const SizedBox(),
|
||||||
size: 64,
|
errorWidget: (context, url, error) => Center(
|
||||||
color: colorScheme.onSurfaceVariant,
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.podcast,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
// Provider icon overlay
|
||||||
: Center(
|
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||||
child: Icon(
|
ProviderIconOverlay(
|
||||||
MdiIcons.podcast,
|
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||||
size: 64,
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Podcast title
|
// Podcast title
|
||||||
Hero(
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
Text(
|
||||||
child: Material(
|
widget.podcast.name,
|
||||||
color: Colors.transparent,
|
maxLines: 1,
|
||||||
child: Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
widget.podcast.name,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
maxLines: 1,
|
color: colorScheme.onSurface,
|
||||||
overflow: TextOverflow.ellipsis,
|
fontWeight: FontWeight.w500,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Podcast author (if available)
|
// Podcast author (if available)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -440,6 +440,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -714,6 +722,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.0"
|
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:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1127,6 +1143,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.8.9-beta+47
|
version: 2.9.1-beta+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -17,6 +17,7 @@ dependencies:
|
|||||||
material_design_icons_flutter: ^7.0.7296
|
material_design_icons_flutter: ^7.0.7296
|
||||||
dynamic_color: 1.6.8
|
dynamic_color: 1.6.8
|
||||||
palette_generator: ^0.3.3+3
|
palette_generator: ^0.3.3+3
|
||||||
|
flutter_svg: ^2.0.10+1
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
|
|||||||