Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7127ea7da | ||
|
|
29b4887d0d | ||
|
|
18603c34e1 | ||
|
|
5ed2bf7725 | ||
|
|
67a99c88a0 | ||
|
|
ffbb41580b | ||
|
|
6942b56cfb | ||
|
|
14ba524ed0 | ||
|
|
49fc2a77e7 | ||
|
|
6632fa14c6 |
@@ -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
|
||||
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 {
|
||||
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 [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1593,5 +1593,40 @@
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"selectPlayerToPlay": "Select a player",
|
||||
"@selectPlayerToPlay": {
|
||||
"description": "Generic hint for selecting a player in player selector"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"@selectPlayerToPlayAlbum": {
|
||||
"description": "Hint for selecting player to play album"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"@selectPlayerToPlayPlaylist": {
|
||||
"description": "Hint for selecting player to play playlist"
|
||||
},
|
||||
|
||||
"selectPlayerForRadio": "Select player for radio",
|
||||
"@selectPlayerForRadio": {
|
||||
"description": "Hint for selecting player to start radio"
|
||||
},
|
||||
|
||||
"selectPlayerForEpisode": "Select player for episode",
|
||||
"@selectPlayerForEpisode": {
|
||||
"description": "Hint for selecting player to play episode"
|
||||
},
|
||||
|
||||
"selectPlayerForAudiobook": "Select player for audiobook",
|
||||
"@selectPlayerForAudiobook": {
|
||||
"description": "Hint for selecting player to play audiobook"
|
||||
},
|
||||
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2048,6 +2048,48 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue to another player'**
|
||||
String get transferQueueToPlayer;
|
||||
|
||||
/// Generic hint for selecting a player in player selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a player'**
|
||||
String get selectPlayerToPlay;
|
||||
|
||||
/// Hint for selecting player to play album
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play album'**
|
||||
String get selectPlayerToPlayAlbum;
|
||||
|
||||
/// Hint for selecting player to play playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play playlist'**
|
||||
String get selectPlayerToPlayPlaylist;
|
||||
|
||||
/// Hint for selecting player to start radio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for radio'**
|
||||
String get selectPlayerForRadio;
|
||||
|
||||
/// Hint for selecting player to play episode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for episode'**
|
||||
String get selectPlayerForEpisode;
|
||||
|
||||
/// Hint for selecting player to play audiobook
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for audiobook'**
|
||||
String get selectPlayerForAudiobook;
|
||||
|
||||
/// Hint for selecting player to add to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -1076,4 +1076,25 @@ class SDe extends S {
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
}
|
||||
|
||||
@@ -1076,4 +1076,25 @@ class SEn extends S {
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
}
|
||||
|
||||
@@ -1076,4 +1076,25 @@ class SEs extends S {
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
}
|
||||
|
||||
@@ -1076,4 +1076,25 @@ class SFr extends S {
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
}
|
||||
|
||||
+2
-1
@@ -223,8 +223,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
// No haptic - Android back gesture provides its own
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,17 @@ class MediaItem {
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MediaItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
provider == other.provider;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, provider);
|
||||
}
|
||||
|
||||
class Artist extends MediaItem {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
|
||||
|
||||
// Sleep timer state
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
|
||||
|
||||
// Local player state
|
||||
bool _isLocalPlayerPowered = true;
|
||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||
@@ -510,6 +516,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
Track? get currentTrack => _currentTrack;
|
||||
|
||||
// Sleep timer getters
|
||||
bool get sleepTimerActive => _sleepTimerMinutes != null;
|
||||
int? get sleepTimerMinutes => _sleepTimerMinutes;
|
||||
Duration? get sleepTimerRemaining {
|
||||
if (_sleepTimerEndTime == null) return null;
|
||||
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
/// Whether we have cached players available (for instant UI display on app resume)
|
||||
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
|
||||
|
||||
@@ -3931,6 +3946,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
);
|
||||
} else {
|
||||
// Builtin player is idle - clear notification (fixes issue #42)
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
}
|
||||
} else {
|
||||
// For remote players, immediately show notification if we have cached track info
|
||||
@@ -3973,6 +3991,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
position: position,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
} else {
|
||||
// Player is idle - clear the notification to prevent stale metadata
|
||||
// from previous player showing (fixes issue #42)
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4084,40 +4106,33 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Update just the notification position (called every 500ms for remote/Sendspin players)
|
||||
void _updateNotificationPosition() {
|
||||
/// Set [forceUpdate] to true to update even when paused (used after seek)
|
||||
void _updateNotificationPosition({bool forceUpdate = false}) {
|
||||
if (_selectedPlayer == null || _currentTrack == null) {
|
||||
_notificationPositionTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't update if player is not playing
|
||||
if (_selectedPlayer!.state != 'playing') {
|
||||
final isPlaying = _selectedPlayer!.state == 'playing';
|
||||
|
||||
// Don't update if player is not playing (unless forced for seek)
|
||||
if (!isPlaying && !forceUpdate) {
|
||||
_notificationPositionTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
final track = _currentTrack!;
|
||||
Duration position;
|
||||
|
||||
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played)
|
||||
// For remote players, use the position tracker (server-based interpolation)
|
||||
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) {
|
||||
position = _pcmAudioPlayer!.elapsedTime;
|
||||
// Always use position tracker for notification position - it's the single source of truth.
|
||||
// Previously used PCM elapsed time for Sendspin, but this breaks when:
|
||||
// 1. Stream starts mid-track (resume from pause) - PCM elapsed resets to 0
|
||||
// 2. Switching between multiple Sendspin players - PCM elapsed doesn't match
|
||||
final position = _positionTracker.currentPosition;
|
||||
|
||||
// Check if track has ended based on PCM elapsed time
|
||||
if (track.duration != null && position >= track.duration!) {
|
||||
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Use position tracker for remote players
|
||||
position = _positionTracker.currentPosition;
|
||||
|
||||
// Check if track has ended (position reached duration)
|
||||
if (_positionTracker.hasReachedEnd) {
|
||||
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
||||
return;
|
||||
}
|
||||
// Check if track has ended (position reached duration)
|
||||
if (_positionTracker.hasReachedEnd) {
|
||||
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
|
||||
return;
|
||||
}
|
||||
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
@@ -4135,7 +4150,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: true,
|
||||
playing: isPlaying,
|
||||
position: position,
|
||||
duration: track.duration,
|
||||
);
|
||||
@@ -4320,6 +4335,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
// Capture player ID at start to detect stale operations
|
||||
// If player changes during async operations, we should abort
|
||||
final operationPlayerId = _selectedPlayer!.playerId;
|
||||
|
||||
try {
|
||||
bool stateChanged = false;
|
||||
|
||||
@@ -4338,6 +4357,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Try to refresh from API first for latest state
|
||||
final allPlayers = await getPlayers();
|
||||
|
||||
// Check for stale operation - player may have changed during async call
|
||||
if (_selectedPlayer?.playerId != operationPlayerId) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player changed during operation, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
Player? rawPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == selectedId ||
|
||||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
|
||||
@@ -4448,6 +4474,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
// Check for stale operation - player may have changed during async call
|
||||
if (_selectedPlayer?.playerId != operationPlayerId) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player changed during getQueue, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final queueTrack = queue.currentItem!.track;
|
||||
final trackChanged = _currentTrack == null ||
|
||||
@@ -4455,6 +4487,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_currentTrack!.name != queueTrack.name;
|
||||
|
||||
if (trackChanged) {
|
||||
// Check "end of track" sleep timer - previous track just ended
|
||||
if (_currentTrack != null) {
|
||||
checkEndOfTrackSleepTimer();
|
||||
}
|
||||
|
||||
// Check if cached track has better metadata (images, proper artist info)
|
||||
// This prevents losing album art and artist data when resuming the app
|
||||
// For grouped child players, this gets the leader's cached track
|
||||
@@ -5293,6 +5330,94 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
|
||||
void setSleepTimer(int? minutes) {
|
||||
// Cancel any existing timer
|
||||
_cancelSleepTimerInternal();
|
||||
|
||||
_sleepTimerMinutes = minutes;
|
||||
|
||||
if (minutes == null) {
|
||||
// Timer off
|
||||
_logger.log('😴 Sleep timer: OFF');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes == -1) {
|
||||
// End of track - handled in player state updates
|
||||
_sleepTimerEndTime = null; // No fixed end time
|
||||
_logger.log('😴 Sleep timer: End of track');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timer for specified minutes
|
||||
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
|
||||
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
|
||||
|
||||
// Create the actual timer
|
||||
_sleepTimer = Timer(Duration(minutes: minutes), _onSleepTimerExpired);
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel sleep timer
|
||||
void cancelSleepTimer() {
|
||||
_cancelSleepTimerInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
_logger.log('😴 Sleep timer: Cancelled');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelSleepTimerInternal() {
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimer = null;
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_sleepTimerDisplayTimer = null;
|
||||
}
|
||||
|
||||
void _onSleepTimerExpired() {
|
||||
_logger.log('😴 Sleep timer expired - pausing playback');
|
||||
_cancelSleepTimerInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
|
||||
// Pause the current player
|
||||
final playerId = _selectedPlayer?.playerId;
|
||||
if (playerId != null) {
|
||||
pausePlayer(playerId);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called when track ends - checks if "end of track" sleep timer is active
|
||||
void checkEndOfTrackSleepTimer() {
|
||||
if (_sleepTimerMinutes == -1) {
|
||||
_logger.log('😴 End of track sleep timer triggered');
|
||||
_sleepTimerMinutes = null;
|
||||
|
||||
// Pause the current player
|
||||
final playerId = _selectedPlayer?.playerId;
|
||||
if (playerId != null) {
|
||||
pausePlayer(playerId);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync a player to the currently selected player (temporary group)
|
||||
/// The target player will play the same audio as the selected player
|
||||
Future<void> syncPlayerToSelected(String targetPlayerId) async {
|
||||
@@ -5563,6 +5688,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
// Immediately update position tracker for responsive UI
|
||||
_positionTracker.onSeek(position.toDouble());
|
||||
// Immediately update notification position to stay in sync with app
|
||||
// Use forceUpdate since seek can happen while paused
|
||||
_updateNotificationPosition(forceUpdate: true);
|
||||
await _api?.seek(playerId, position);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Seek', e);
|
||||
@@ -5723,6 +5851,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_playerStateTimer?.cancel();
|
||||
_notificationPositionTimer?.cancel();
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_connectionStateSubscription?.cancel();
|
||||
_localPlayerEventSubscription?.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,6 @@ import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
@@ -1257,13 +1256,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(
|
||||
@@ -1272,48 +1266,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
startIndex: startIndex,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
|
||||
@@ -430,171 +430,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
void _showRadioMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.startRadioOn(widget.artist.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddToQueueMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToAddToQueue,
|
||||
onPlayerSelected: (player) async {
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadArtistImage() async {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -212,13 +211,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.setCurrentAudiobook(_audiobook);
|
||||
@@ -227,9 +221,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
widget.audiobook,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsFinished() async {
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
@@ -378,20 +377,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _addPlaylistToQueue() {
|
||||
@@ -552,20 +544,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -308,22 +307,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForEpisode,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
// Set podcast context before playing so player UI shows correct podcast name
|
||||
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import '../widgets/artist_avatar.dart';
|
||||
@@ -2473,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showRadioOnMenu(Track track) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -2500,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Add track to queue on current player (1 tap)
|
||||
@@ -2943,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showAlbumPlayOnMenu(Album album) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -2970,9 +2957,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Add album to queue
|
||||
@@ -3122,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showPlaylistPlayOnMenu(Playlist playlist) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3149,9 +3129,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Play audiobook on current player
|
||||
@@ -3189,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3216,9 +3189,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggle audiobook favorite status
|
||||
@@ -3306,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showRadioPlayOnMenu(MediaItem radio) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3333,9 +3299,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Play podcast (navigate to detail screen where episodes can be played)
|
||||
@@ -3363,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showPodcastPlayOnMenu(MediaItem podcast) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlay,
|
||||
onPlayerSelected: (player) async {
|
||||
// Select the player, then navigate to podcast detail
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3389,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
|
||||
@@ -193,6 +193,13 @@ class DatabaseService {
|
||||
return db.markItemsDeleted(itemType, [itemId]);
|
||||
}
|
||||
|
||||
/// Batch cache multiple items efficiently
|
||||
/// Uses drift's batch API for much better performance on large datasets.
|
||||
/// Items are automatically chunked into batches of 500.
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) {
|
||||
return db.batchCacheItems(items);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Player Cache Convenience Methods
|
||||
// ============================================
|
||||
|
||||
@@ -2,14 +2,19 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/lru_cache.dart';
|
||||
|
||||
class MetadataService {
|
||||
static final _logger = DebugLogger();
|
||||
// Cache to avoid repeated API calls for the same artist/album
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
// Cache for artist images
|
||||
static final Map<String, String?> _artistImageCache = {};
|
||||
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
|
||||
// Using NullableLruCache for caches that store null values (failed lookups)
|
||||
|
||||
/// Cache for artist/album descriptions (100 entries)
|
||||
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
|
||||
|
||||
/// Cache for artist images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
/// Fetches artist biography/description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
@@ -34,8 +39,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'artist:$artistName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API
|
||||
@@ -43,7 +48,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +58,7 @@ class MetadataService {
|
||||
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||
if (audioDbDesc != null) {
|
||||
_cache[cacheKey] = audioDbDesc;
|
||||
_descriptionCache[cacheKey] = audioDbDesc;
|
||||
return audioDbDesc;
|
||||
}
|
||||
}
|
||||
@@ -83,8 +88,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'album:$artistName:$albumName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||
@@ -92,7 +97,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -251,11 +256,11 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache for album images
|
||||
static final Map<String, String?> _albumImageCache = {};
|
||||
/// Cache for album images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
// Cache for author images (audiobooks)
|
||||
static final Map<String, String?> _authorImageCache = {};
|
||||
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
|
||||
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
|
||||
|
||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
@@ -740,11 +745,90 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the metadata cache
|
||||
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
|
||||
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
|
||||
static final Map<String, (String?, String?)?> _lyricsCache = {};
|
||||
static const int _lyricsCacheMaxSize = 500;
|
||||
|
||||
/// Fetches lyrics from LRCLIB (free, no API key required)
|
||||
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
|
||||
/// LRCLIB provides both synced (LRC format) and plain lyrics
|
||||
static Future<(String?, String?)?> getTrackLyrics({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
String? albumName,
|
||||
int? durationSeconds,
|
||||
}) async {
|
||||
// Build cache key
|
||||
final cacheKey = 'lyrics:$artistName:$trackName';
|
||||
if (_lyricsCache.containsKey(cacheKey)) {
|
||||
return _lyricsCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try LRCLIB API
|
||||
try {
|
||||
final params = <String, String>{
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
};
|
||||
if (albumName != null && albumName.isNotEmpty) {
|
||||
params['album_name'] = albumName;
|
||||
}
|
||||
if (durationSeconds != null && durationSeconds > 0) {
|
||||
params['duration'] = durationSeconds.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.https('lrclib.net', '/api/get', params);
|
||||
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
|
||||
},
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final plainLyrics = data['plainLyrics'] as String?;
|
||||
final syncedLyrics = data['syncedLyrics'] as String?;
|
||||
|
||||
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
|
||||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
|
||||
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
|
||||
final result = (plainLyrics, syncedLyrics);
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
} else if (response.statusCode == 404) {
|
||||
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
|
||||
} else {
|
||||
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Evict oldest entries from lyrics cache if over max size
|
||||
static void _evictLyricsCacheIfNeeded() {
|
||||
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
|
||||
_lyricsCache.remove(_lyricsCache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all metadata caches
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
_descriptionCache.clear();
|
||||
_artistImageCache.clear();
|
||||
_albumImageCache.clear();
|
||||
_authorImageCache.clear();
|
||||
_lyricsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,15 @@ class MusicAssistantAPI {
|
||||
return;
|
||||
}
|
||||
|
||||
// If connection is in progress, wait for it instead of starting another
|
||||
// Atomic check-and-set: create new completer first, then check if one exists
|
||||
// This ensures no async operations happen between check and set
|
||||
final newCompleter = Completer<void>();
|
||||
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||
_logger.log('Connection already in progress, waiting...');
|
||||
return _connectionInProgress!.future;
|
||||
}
|
||||
|
||||
_connectionInProgress = Completer<void>();
|
||||
// Set immediately before any async operations to close the race window
|
||||
_connectionInProgress = newCompleter;
|
||||
|
||||
try {
|
||||
_updateConnectionState(MAConnectionState.connecting);
|
||||
@@ -345,6 +347,14 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent memory leaks by limiting pending requests
|
||||
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
|
||||
throw Exception(
|
||||
'Too many pending requests (${_pendingRequests.length}). '
|
||||
'Please wait for existing requests to complete.',
|
||||
);
|
||||
}
|
||||
|
||||
final messageId = _uuid.v4();
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[messageId] = completer;
|
||||
@@ -1084,6 +1094,51 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get lyrics for a track
|
||||
/// Returns the lyrics text if available, null otherwise
|
||||
/// Uses the dedicated metadata/get_track_lyrics endpoint
|
||||
Future<String?> getTrackLyrics(Map<String, dynamic> track) async {
|
||||
try {
|
||||
_logger.log('🎤 Getting lyrics for track: ${track['name']}');
|
||||
|
||||
// Use the dedicated lyrics endpoint
|
||||
final response = await _sendCommand(
|
||||
'metadata/get_track_lyrics',
|
||||
args: {
|
||||
'track': track,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.containsKey('error_code')) {
|
||||
_logger.log('🎤 Error getting lyrics: ${response['error_code']}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Result is a tuple: [plain_lyrics, synced_lyrics]
|
||||
// We prefer synced lyrics if available, otherwise plain
|
||||
final result = response['result'];
|
||||
if (result is List && result.length >= 2) {
|
||||
final plainLyrics = result[0] as String?;
|
||||
final syncedLyrics = result[1] as String?;
|
||||
|
||||
if (syncedLyrics != null && syncedLyrics.isNotEmpty) {
|
||||
_logger.log('🎤 Found synced lyrics: ${syncedLyrics.length} chars');
|
||||
return syncedLyrics;
|
||||
}
|
||||
if (plainLyrics != null && plainLyrics.isNotEmpty) {
|
||||
_logger.log('🎤 Found plain lyrics: ${plainLyrics.length} chars');
|
||||
return plainLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('🎤 No lyrics found in result: $result');
|
||||
return null;
|
||||
} catch (e) {
|
||||
_logger.log('🎤 Error getting lyrics: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse Music Assistant content by path
|
||||
/// Returns a list of browse items (folders or media items)
|
||||
Future<List<dynamic>> browse(String? path) async {
|
||||
@@ -3550,6 +3605,9 @@ class MusicAssistantAPI {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel all pending requests before reconnecting to prevent memory leaks
|
||||
_cancelPendingRequests('Connection lost, reconnecting');
|
||||
|
||||
await Future.delayed(Timings.reconnectDelay);
|
||||
|
||||
// Check again after delay
|
||||
|
||||
@@ -100,6 +100,7 @@ class PositionTracker {
|
||||
Duration? duration,
|
||||
double? serverTimestamp,
|
||||
}) {
|
||||
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
|
||||
final bool playerChanged = _playerId != playerId;
|
||||
final bool playStateChanged = _isPlaying != isPlaying;
|
||||
final bool durationChanged = duration != null && _duration != duration;
|
||||
@@ -155,17 +156,34 @@ class PositionTracker {
|
||||
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
|
||||
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
|
||||
// 4. We're not playing (paused state should reflect server position)
|
||||
// 5. Anchor is getting stale (> 20 seconds old) - prevents position freezing at _maxAnchorAge cap
|
||||
//
|
||||
// Key insight: When timestamp is stale, the raw position from server is likely outdated.
|
||||
// Our interpolated position is probably more accurate, so don't let stale data override it.
|
||||
// HOWEVER, if our anchor itself is getting old, we need to refresh it to prevent freezing.
|
||||
final anchorAge = DateTime.now().difference(_anchorTime);
|
||||
final anchorIsGettingStale = anchorAge.inSeconds > 20;
|
||||
// When anchor is stale, always refresh it - the suspicious reset check should
|
||||
// only prevent us from trusting the SERVER's position, not from refreshing
|
||||
// our own interpolated anchor. The newAnchorPos logic below handles using
|
||||
// interpolated position for stale anchors with suspicious server data.
|
||||
final shouldUpdateAnchor = playerChanged
|
||||
|| playStateChanged
|
||||
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|
||||
|| !isPlaying;
|
||||
|| !isPlaying
|
||||
|| anchorIsGettingStale;
|
||||
|
||||
if (shouldUpdateAnchor) {
|
||||
_anchorPosition = anchorPos;
|
||||
// When refreshing due to stale anchor (not player/state change), use interpolated position
|
||||
// to avoid backward jumps from outdated server data
|
||||
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
||||
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
||||
: anchorPos; // Use server position for other cases
|
||||
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
|
||||
_anchorPosition = newAnchorPos;
|
||||
_anchorTime = DateTime.now();
|
||||
} else if (positionDiff > 2) {
|
||||
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
|
||||
}
|
||||
|
||||
_isPlaying = isPlaying;
|
||||
@@ -209,6 +227,7 @@ class PositionTracker {
|
||||
|
||||
/// Clear tracker state (e.g., when disconnecting)
|
||||
void clear() {
|
||||
_logger.log('⏱️ PositionTracker.clear() called');
|
||||
_stopInterpolationTimer();
|
||||
_playerId = null;
|
||||
_isPlaying = false;
|
||||
@@ -218,9 +237,20 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _startInterpolationTimer() {
|
||||
if (_interpolationTimer != null) return;
|
||||
if (_interpolationTimer != null) {
|
||||
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
|
||||
return;
|
||||
}
|
||||
|
||||
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) {
|
||||
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
|
||||
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
|
||||
// Log every 4 ticks (1 second) if no emission happened
|
||||
if (timer.tick % 4 == 0) {
|
||||
final pos = currentPosition;
|
||||
if (pos.inSeconds == _lastEmittedSeconds) {
|
||||
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
|
||||
}
|
||||
}
|
||||
_emitPosition();
|
||||
});
|
||||
|
||||
@@ -229,6 +259,7 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _stopInterpolationTimer() {
|
||||
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||
_interpolationTimer?.cancel();
|
||||
_interpolationTimer = null;
|
||||
}
|
||||
@@ -240,6 +271,7 @@ class PositionTracker {
|
||||
// Only emit if second changed (reduces unnecessary updates)
|
||||
if (seconds != _lastEmittedSeconds) {
|
||||
_lastEmittedSeconds = seconds;
|
||||
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
|
||||
_positionController.add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../database/database.dart' show BatchCacheItem;
|
||||
import '../models/media_item.dart';
|
||||
import 'database_service.dart';
|
||||
import 'debug_logger.dart';
|
||||
@@ -391,114 +392,84 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save albums to database cache with source provider tracking
|
||||
/// Save albums to database cache with source provider tracking (batched)
|
||||
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final album in albums) {
|
||||
try {
|
||||
final providers = sourceProviders[album.itemId];
|
||||
// Save with first source provider (merging happens in DB layer)
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
// If no source providers, still cache the item
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[album.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: jsonEncode(album.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${albums.length} albums to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache albums: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${albums.length} albums to cache');
|
||||
}
|
||||
|
||||
/// Save artists to database cache with source provider tracking
|
||||
/// Save artists to database cache with source provider tracking (batched)
|
||||
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final artist in artists) {
|
||||
try {
|
||||
final providers = sourceProviders[artist.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[artist.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: jsonEncode(artist.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${artists.length} artists to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache artists: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${artists.length} artists to cache');
|
||||
}
|
||||
|
||||
/// Save audiobooks to database cache with source provider tracking
|
||||
/// Save audiobooks to database cache with source provider tracking (batched)
|
||||
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final audiobook in audiobooks) {
|
||||
try {
|
||||
final providers = sourceProviders[audiobook.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[audiobook.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: jsonEncode(audiobook.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
|
||||
}
|
||||
|
||||
/// Save playlists to database cache with source provider tracking
|
||||
/// Save playlists to database cache with source provider tracking (batched)
|
||||
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final playlist in playlists) {
|
||||
try {
|
||||
final providers = sourceProviders[playlist.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[playlist.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: jsonEncode(playlist.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache playlists: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${playlists.length} playlists to cache');
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
|
||||
@@ -129,13 +129,31 @@ class Durations {
|
||||
class ButtonSizes {
|
||||
ButtonSizes._();
|
||||
|
||||
// Touch target sizes (Material Design minimum: 48x48)
|
||||
// Touch target sizes
|
||||
// WCAG 2.1 Level AA requires minimum 44x44, Level AAA requires 48x48
|
||||
// Material Design recommends 48x48 minimum
|
||||
// WARNING: sm and md are below WCAG minimum - use only for non-interactive elements
|
||||
|
||||
/// 32x32 - NOT WCAG compliant, use only for decorative/non-interactive elements
|
||||
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
|
||||
static const double sm = 32.0;
|
||||
|
||||
/// 36x36 - NOT WCAG compliant, use only for dense layouts where space is critical
|
||||
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
|
||||
static const double md = 36.0;
|
||||
|
||||
/// 44x44 - Meets WCAG Level AA minimum
|
||||
static const double lg = 44.0;
|
||||
|
||||
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
|
||||
static const double xl = 48.0;
|
||||
|
||||
/// 56x56 - Comfortable touch target for primary actions
|
||||
static const double xxl = 56.0;
|
||||
|
||||
/// Minimum WCAG-compliant touch target size (48x48)
|
||||
static const double minAccessible = 48.0;
|
||||
|
||||
// Icon sizes within buttons
|
||||
static const double iconSm = 16.0;
|
||||
static const double iconMd = 18.0;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1117
-108
File diff suppressed because it is too large
Load Diff
@@ -93,8 +93,9 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
|
||||
|
||||
/// Collapse the player if it's expanded
|
||||
static void collapsePlayer() {
|
||||
globalPlayerKey.currentState?.collapse();
|
||||
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||
static void collapsePlayer({bool withHaptic = true}) {
|
||||
globalPlayerKey.currentState?.collapse(withHaptic: withHaptic);
|
||||
}
|
||||
|
||||
/// Force collapse player to mini state, instantly closing queue panel if open.
|
||||
@@ -164,6 +165,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
/// Check if the player reveal is currently visible
|
||||
static bool get isPlayerRevealVisible =>
|
||||
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
||||
|
||||
/// Show player selector for a "Play On" action.
|
||||
/// Instead of the default hints, shows a contextual hint like "Select player to play album".
|
||||
/// When a player is tapped, calls onPlayerSelected instead of switching to that player.
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(dynamic player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
_overlayStateKey.currentState?._showPlayerSelectorForAction(
|
||||
contextHint: contextHint,
|
||||
onPlayerSelected: onPlayerSelected,
|
||||
hintIcon: hintIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
@@ -208,6 +224,11 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
// Key for the reveal overlay
|
||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||
|
||||
// State for "Play On" action - stored here so it can be passed to PlayerRevealOverlay
|
||||
void Function(dynamic player)? _pendingPlayerAction;
|
||||
String? _pendingContextHint;
|
||||
IconData? _pendingHintIcon;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -394,6 +415,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
void _hidePlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
_bounceOffsetNotifier.value = 0;
|
||||
_clearPendingAction(); // Clear pending action when overlay is dismissed
|
||||
setState(() {
|
||||
_isRevealVisible = false;
|
||||
_isOnboardingReveal = false;
|
||||
@@ -413,6 +435,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
_singleBounceController.forward();
|
||||
}
|
||||
|
||||
/// Clear pending action state (called when overlay is dismissed)
|
||||
void _clearPendingAction() {
|
||||
_pendingPlayerAction = null;
|
||||
_pendingContextHint = null;
|
||||
_pendingHintIcon = null;
|
||||
}
|
||||
|
||||
/// Show player selector for a "Play On" action.
|
||||
/// Stores the pending action and shows the reveal overlay with context hint.
|
||||
void _showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(dynamic player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Store pending action state
|
||||
_pendingPlayerAction = onPlayerSelected;
|
||||
_pendingContextHint = contextHint;
|
||||
_pendingHintIcon = hintIcon;
|
||||
|
||||
// Show the reveal overlay
|
||||
_showPlayerReveal();
|
||||
}
|
||||
|
||||
/// End hint mode (called when user taps skip button)
|
||||
void _endHintMode() {
|
||||
if (!_isHintModeActive) return;
|
||||
@@ -536,62 +581,76 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
// Fade out and slide down nav bar as player expands
|
||||
// Use IgnorePointer when faded to prevent accidental taps
|
||||
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
|
||||
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: navOpacity < 0.1,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, navSlideDown),
|
||||
child: Opacity(
|
||||
opacity: navOpacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -627,11 +686,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
duration: const Duration(seconds: 3),
|
||||
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||
builder: (context, opacity, child) {
|
||||
// Use theme-appropriate background color for seamless transition
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final backdropColor = isDark
|
||||
? const Color(0xFF1a1a1a) // Dark theme background
|
||||
: colorScheme.surface; // Light theme surface
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||
child: Container(
|
||||
// Use same dark color as connection screen for seamless transition
|
||||
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
||||
color: backdropColor.withOpacity(opacity),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -727,6 +790,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
showOnboardingHints: _isOnboardingReveal,
|
||||
// Pass pending action parameters for "Play On" functionality
|
||||
onPlayerSelected: _pendingPlayerAction,
|
||||
contextHint: _pendingContextHint,
|
||||
contextHintIcon: _pendingHintIcon,
|
||||
),
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
@@ -740,6 +807,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
// Reset expansion state to prevent stale values from causing
|
||||
// invisible/non-interactive nav bar when connection restores.
|
||||
// This fixes a bug where expanded player state persisted after
|
||||
// disconnect, making nav bar opacity=0 and ignoring taps.
|
||||
if (playerExpansionNotifier.value.progress != 0.0) {
|
||||
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/media_item.dart' show Audiobook, Chapter;
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../utils/duration_formatter.dart';
|
||||
import '../common/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -26,18 +27,6 @@ class ChaptersPanel extends StatelessWidget {
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final duration = Duration(seconds: seconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
final secs = duration.inSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${minutes}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chapters = audiobook?.chapters ?? [];
|
||||
@@ -147,7 +136,7 @@ class ChaptersPanel extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
_formatDuration(chapter.positionMs ~/ 1000),
|
||||
formatDurationSeconds(chapter.positionMs ~/ 1000),
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
|
||||
@@ -19,12 +19,25 @@ class PlayerRevealOverlay extends StatefulWidget {
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||
|
||||
/// Callback when player is selected for a pending action (e.g., "Play On").
|
||||
/// If null, selecting a player just switches to that player.
|
||||
final void Function(dynamic player)? onPlayerSelected;
|
||||
|
||||
/// Context hint to display instead of default hints (e.g., "Select player to play album").
|
||||
final String? contextHint;
|
||||
|
||||
/// Icon for context hint (default: play_circle_outline).
|
||||
final IconData? contextHintIcon;
|
||||
|
||||
const PlayerRevealOverlay({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.miniPlayerBottom,
|
||||
required this.miniPlayerHeight,
|
||||
this.showOnboardingHints = false,
|
||||
this.onPlayerSelected,
|
||||
this.contextHint,
|
||||
this.contextHintIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -383,16 +396,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: isOnboarding
|
||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
child: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: isOnboarding
|
||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -436,7 +454,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
if (widget.onPlayerSelected != null) {
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -489,7 +511,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
if (widget.onPlayerSelected != null) {
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../utils/duration_formatter.dart';
|
||||
import '../common/empty_state.dart';
|
||||
import '../global_player_overlay.dart';
|
||||
|
||||
@@ -27,8 +28,8 @@ class QueuePanel extends StatefulWidget {
|
||||
final VoidCallback onRefresh;
|
||||
final ValueChanged<bool>? onDraggingChanged;
|
||||
final VoidCallback? onSwipeStart;
|
||||
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
|
||||
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
|
||||
final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
|
||||
final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
|
||||
|
||||
const QueuePanel({
|
||||
super.key,
|
||||
@@ -55,6 +56,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
List<QueueItem> _items = [];
|
||||
final GlobalKey _stackKey = GlobalKey();
|
||||
|
||||
// Scroll controller to track list position (for swipe-to-close only at top)
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isAtTop = true;
|
||||
bool _wasAtTopOnSwipeStart = true; // Capture at gesture start
|
||||
|
||||
// Transfer dropdown animation
|
||||
late AnimationController _dropdownController;
|
||||
bool _showingTransferDropdown = false;
|
||||
@@ -100,6 +106,17 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
// Track scroll position for swipe-to-close (only allowed at top)
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final atTop = _scrollController.offset <= 0;
|
||||
if (atTop != _isAtTop) {
|
||||
setState(() {
|
||||
_isAtTop = atTop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -135,18 +152,13 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_dropdownController.dispose();
|
||||
_pendingReorderTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) return '';
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleDelete(QueueItem item, int index) async {
|
||||
// Store item for potential rollback
|
||||
@@ -427,6 +439,29 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
double _calculateAverageVelocityVertical() {
|
||||
if (_velocitySamples.length < 2) return 0.0;
|
||||
|
||||
// Use weighted average of recent samples (more recent = higher weight)
|
||||
double totalVelocity = 0.0;
|
||||
double totalWeight = 0.0;
|
||||
|
||||
for (int i = 1; i < _velocitySamples.length; i++) {
|
||||
final prev = _velocitySamples[i - 1];
|
||||
final curr = _velocitySamples[i];
|
||||
final dt = curr.timeMs - prev.timeMs;
|
||||
if (dt > 0 && dt < 100) { // Only use samples within 100ms
|
||||
final dy = curr.position.dy - prev.position.dy;
|
||||
final velocity = (dy / dt) * 1000; // px/s
|
||||
final weight = i.toDouble(); // Later samples get higher weight
|
||||
totalVelocity += velocity * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
|
||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
|
||||
if (_dragIndex != null) return;
|
||||
|
||||
@@ -600,6 +635,64 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Calculate the position shift for the API
|
||||
final posShift = targetIndex - index;
|
||||
|
||||
// Cancel any existing timer to prevent stacking
|
||||
_pendingReorderTimer?.cancel();
|
||||
|
||||
// Optimistic update - move item in local list
|
||||
setState(() {
|
||||
final movedItem = _items.removeAt(index);
|
||||
_items.insert(targetIndex > index ? targetIndex - 1 : targetIndex, movedItem);
|
||||
_pendingReorder = true;
|
||||
});
|
||||
|
||||
// Haptic feedback
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Call API
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Clear pending state immediately on error
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
// Refresh queue from server to get correct state
|
||||
widget.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Listener for raw pointer events to bypass gesture arena
|
||||
@@ -623,17 +716,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
_swipeLocked = false;
|
||||
_velocitySamples.clear();
|
||||
_addVelocitySample(event.position, _swipeLastTime!);
|
||||
// Capture scroll position at gesture start - only allow close if already at top
|
||||
_wasAtTopOnSwipeStart = _isAtTop;
|
||||
} else if (startedInEdgeZone) {
|
||||
// Clear swipe state for edge touches
|
||||
_swipeStart = null;
|
||||
}
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
// Ignore swipes from edge zone (let Android back gesture handle it)
|
||||
// Ignore swipes while dragging a queue item
|
||||
if (_swipeStart == null || _dragIndex != null) return;
|
||||
|
||||
final dx = event.position.dx - _swipeStart!.dx;
|
||||
final dy = (event.position.dy - _swipeStart!.dy).abs();
|
||||
final dx = (event.position.dx - _swipeStart!.dx).abs();
|
||||
final dy = event.position.dy - _swipeStart!.dy;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Track all move events for velocity calculation
|
||||
@@ -643,31 +738,31 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
// Once direction is locked, maintain it (prevents accidental cancellation)
|
||||
if (_swipeLocked && _isSwiping) {
|
||||
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
|
||||
widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
|
||||
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
|
||||
// Check if this is a vertical swipe (dy > dx * 1.2)
|
||||
final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
|
||||
|
||||
if (isHorizontal && dx > 0) {
|
||||
// Horizontal swipe right - close gesture
|
||||
if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
|
||||
// Vertical swipe down - close gesture (only when was at top when gesture started)
|
||||
if (!_isSwiping) {
|
||||
_isSwiping = true;
|
||||
_swipeLocked = true; // Lock direction once swipe starts
|
||||
widget.onSwipeStart?.call();
|
||||
}
|
||||
widget.onSwipeUpdate?.call(dx);
|
||||
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
|
||||
// Direction established as vertical - don't start swipe
|
||||
widget.onSwipeUpdate?.call(dy);
|
||||
} else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
|
||||
// Direction established as horizontal - don't start swipe
|
||||
_swipeLocked = true; // Lock as non-swipe
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_isSwiping && _swipeStart != null) {
|
||||
final velocity = _calculateAverageVelocity();
|
||||
final totalDx = event.position.dx - _swipeStart!.dx;
|
||||
widget.onSwipeEnd?.call(velocity, totalDx);
|
||||
final velocity = _calculateAverageVelocityVertical();
|
||||
final totalDy = event.position.dy - _swipeStart!.dy;
|
||||
widget.onSwipeEnd?.call(velocity, totalDy);
|
||||
}
|
||||
_resetSwipeState();
|
||||
},
|
||||
@@ -690,7 +785,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
icon: Icon(Icons.keyboard_arrow_down_rounded, color: widget.textColor, size: IconSizes.lg),
|
||||
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
@@ -827,6 +922,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
return ListView.builder(
|
||||
key: const PageStorageKey('queue_list'),
|
||||
controller: _scrollController,
|
||||
padding: Spacing.paddingH8,
|
||||
// Disable scrolling while dragging to prevent gesture conflict
|
||||
physics: _dragIndex != null
|
||||
@@ -850,8 +946,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
||||
return Dismissible(
|
||||
key: ValueKey(item.queueItemId),
|
||||
direction: DismissDirection.endToStart,
|
||||
direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
|
||||
// Swipe right (startToEnd) = move to play next
|
||||
background: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.primaryColor,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: const Icon(Icons.skip_next_rounded, color: Colors.white, size: 28),
|
||||
),
|
||||
// Swipe left (endToStart) = delete
|
||||
secondaryBackground: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -863,8 +970,15 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
confirmDismiss: (direction) async {
|
||||
// Block dismissal if swipe started from screen edge (Android back gesture)
|
||||
if (lastTouchWasInEdgeZone) return false;
|
||||
// Don't allow dismissing the currently playing item
|
||||
return !isCurrentItem;
|
||||
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
// Swipe right = move to play next position
|
||||
_handleMoveToPlayNext(item, index, isCurrentItem);
|
||||
return false; // Don't dismiss - item stays in queue (just moved)
|
||||
} else {
|
||||
// Swipe left = delete
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onDismissed: (direction) => _handleDelete(item, index),
|
||||
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
|
||||
@@ -906,7 +1020,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
|
||||
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
final duration = _formatDuration(item.track.duration);
|
||||
final duration = formatDurationOrEmpty(item.track.duration);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.9-beta+47
|
||||
version: 2.9.0-beta+48
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user