Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7912c4b2ef | ||
|
|
ed589e45ee | ||
|
|
0767412765 | ||
|
|
79cabeeb40 | ||
|
|
b713dfd61a | ||
|
|
d3ab3c17a7 | ||
|
|
20926ddfdd | ||
|
|
929584cdae | ||
|
|
503293d0c9 | ||
|
|
4ad71c3cda | ||
|
|
beee3d099e | ||
|
|
05c18e8535 | ||
|
|
9c656ec269 | ||
|
|
53c6d5142e | ||
|
|
d5935fba44 | ||
|
|
2396f32c47 | ||
|
|
e8195d6522 | ||
|
|
6c6feb9c21 | ||
|
|
17e03a2598 | ||
|
|
4f242e1d8c | ||
|
|
5d1bb2eb90 | ||
|
|
1215f9292e | ||
|
|
63dc401e7b | ||
|
|
bc2d922859 | ||
|
|
1d970d64c2 | ||
|
|
44dce05223 | ||
|
|
3681fd2b1a | ||
|
|
f2a85cdb68 | ||
|
|
077e8483a0 | ||
|
|
0dffc4a5e7 | ||
|
|
bdbffd212e | ||
|
|
aca420bdbf | ||
|
|
d6b4bb10c2 | ||
|
|
4eadd3f8d7 | ||
|
|
a98d8cccb2 | ||
|
|
881303edd2 | ||
|
|
e9c917fa4c | ||
|
|
81b427611e | ||
|
|
eca7514a71 | ||
|
|
cfb87fcaa3 | ||
|
|
093d7aacd1 | ||
|
|
71bd6ee599 | ||
|
|
3853658f45 | ||
|
|
5d46e0ae36 | ||
|
|
ad27b28bb9 | ||
|
|
ddc3e58f4d | ||
|
|
e8ac45f886 | ||
|
|
d1ba29c44c |
@@ -0,0 +1,154 @@
|
|||||||
|
# Ensemble App - Audit Progress Tracker
|
||||||
|
|
||||||
|
**Audit Date:** 2025-12-08
|
||||||
|
**Branch:** `refactor/audit-cleanup`
|
||||||
|
**Overall Health Score:** 6.5/10 (before fixes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Quick Wins (1-2 days)
|
||||||
|
*Low effort, immediate impact*
|
||||||
|
|
||||||
|
- [x] Remove unused `cupertino_icons` dependency from `pubspec.yaml`
|
||||||
|
- [x] Remove 12 debug `print()` statements from `main.dart` (replaced with `_logger.log()`)
|
||||||
|
- [x] Fix empty `setState(() {})` in progress timer (`expandable_player.dart`) - converted to `ValueNotifier`
|
||||||
|
- [x] Fix empty `setState(() {})` on text input (`search_screen.dart`) - removed unnecessary setState
|
||||||
|
- [x] Complete or remove TODO comments:
|
||||||
|
- [x] `queue_screen.dart` - enhanced comment with API requirements
|
||||||
|
- [x] `library_screen.dart` - **IMPLEMENTED** play track functionality
|
||||||
|
- [x] `expandable_player.dart` - enhanced comment with API limitations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Performance Fixes (2-3 days)
|
||||||
|
*Medium effort, significant performance gains*
|
||||||
|
|
||||||
|
- [x] Fix slider setState spam - use ValueNotifier pattern (`expandable_player.dart`)
|
||||||
|
- [x] Replace volume polling with events (`volume_control.dart`) - removed polling loop
|
||||||
|
- [x] Cache `Theme.of(context)` lookups - removed unused lookup in `search_screen.dart`
|
||||||
|
- [x] Move `SystemChrome` out of build() (`main.dart`) - created SystemUIWrapper widget
|
||||||
|
- [x] Replace `Image.network` with `CachedNetworkImage` (`expandable_player.dart`)
|
||||||
|
- [x] Use `ListView.builder` instead of spread operators (`search_screen.dart`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Dead Code Cleanup (1 day)
|
||||||
|
*Consolidate and remove unused files*
|
||||||
|
|
||||||
|
- [x] Delete unused `library_screen.dart` (old version, `new_library_screen.dart` is active)
|
||||||
|
- [x] Remove unused `PlayerStateService` (`player_state_service.dart`)
|
||||||
|
- [x] Remove unused `Player` import from `debug_log_screen.dart`
|
||||||
|
|
||||||
|
Note: `new_home_screen.dart` and `new_library_screen.dart` are NOT dead code - they are
|
||||||
|
the active screens used in `home_screen.dart`. The naming suggests they replaced older
|
||||||
|
versions during a refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: State Management Refactor (3-4 days)
|
||||||
|
*Split monolithic provider (2,106 lines)*
|
||||||
|
|
||||||
|
- [x] Extract cache logic → `CacheService` (lib/services/cache_service.dart)
|
||||||
|
- [x] Update `MusicAssistantProvider` to delegate caching to `CacheService`
|
||||||
|
- [x] Delete unused `library_service.dart`
|
||||||
|
- [ ] Extract connection logic → `ConnectionProvider` (skeleton created)
|
||||||
|
- [ ] Extract library data → `LibraryProvider` (skeleton created)
|
||||||
|
- [ ] Extract player state → `PlayerProvider` (skeleton created)
|
||||||
|
- [ ] Remove global singleton (`navigation_provider.dart:40`)
|
||||||
|
- [ ] Add stream filtering (`.distinct()`, `.where()`)
|
||||||
|
|
||||||
|
**Note:** Phase 4 partial completion - extracted CacheService and reduced
|
||||||
|
`music_assistant_provider.dart` from 2,106 to ~1,650 lines. Full provider
|
||||||
|
split deferred as lower priority.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Widget Decomposition (3-4 days)
|
||||||
|
*Split mega-widgets into focused components*
|
||||||
|
|
||||||
|
### expandable_player.dart (1,365 → 1,150 lines) - DONE
|
||||||
|
- [x] Extract `DeviceSelectorBar` → `lib/widgets/player/device_selector_bar.dart`
|
||||||
|
- [x] Extract `QueuePanel` → `lib/widgets/player/queue_panel.dart`
|
||||||
|
- [x] Create `PlayerControls` → `lib/widgets/player/player_controls.dart` (available for future use)
|
||||||
|
|
||||||
|
### login_screen.dart (968 → 843 lines) - DONE
|
||||||
|
- [x] Extract `DebugConsole` → `lib/widgets/debug/debug_console.dart`
|
||||||
|
- [x] Extract `DebugToggleButton` → included in debug_console.dart
|
||||||
|
|
||||||
|
### album_details_screen.dart (818 lines)
|
||||||
|
- [ ] Extract `TrackListSection`
|
||||||
|
- [ ] Extract `PlayOnBottomSheet` (shared)
|
||||||
|
|
||||||
|
### search_screen.dart (516 lines)
|
||||||
|
- [ ] Extract `MediaItemTile`
|
||||||
|
- [ ] Extract `SearchFilters`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Code Deduplication (2-3 days)
|
||||||
|
*Extract reusable widgets*
|
||||||
|
|
||||||
|
- [ ] Create `MediaItemTile` (consolidate 3 list tile builders)
|
||||||
|
- [ ] Create `PlayOnBottomSheet` (consolidate 4 implementations)
|
||||||
|
- [x] Create `EmptyState` (consolidate 6+ implementations) → `lib/widgets/common/empty_state.dart`
|
||||||
|
- [ ] Create `FilterChipRow` (consolidate 3 implementations)
|
||||||
|
- [x] Create `DisconnectedState` (consolidate 3 implementations) → `lib/widgets/common/disconnected_state.dart`
|
||||||
|
|
||||||
|
**Note:** Phase 6 partial completion - extracted EmptyState (8 usages, ~227 lines removed)
|
||||||
|
and DisconnectedState (3 usages, ~140 lines removed). Total ~367 lines of duplicate code removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Design System (2 days)
|
||||||
|
*Centralize hardcoded values*
|
||||||
|
|
||||||
|
- [x] Create `lib/theme/design_tokens.dart`:
|
||||||
|
- [x] Spacing constants (Spacing class with xxs-xxxl scale)
|
||||||
|
- [x] Border radius constants (Radii class)
|
||||||
|
- [x] Icon size constants (IconSizes class)
|
||||||
|
- [x] Dimension constants (Dimensions class)
|
||||||
|
- [x] Animation duration constants (Durations class)
|
||||||
|
- [x] Apply tokens to extracted widgets:
|
||||||
|
- [x] `empty_state.dart` - Spacing tokens
|
||||||
|
- [x] `disconnected_state.dart` - Spacing, IconSizes, Radii tokens
|
||||||
|
- [x] `queue_panel.dart` - Spacing, Radii, IconSizes tokens
|
||||||
|
- [x] `player_controls.dart` - Spacing tokens
|
||||||
|
- [x] `device_selector_bar.dart` - Spacing, IconSizes tokens
|
||||||
|
- [x] `debug_console.dart` - Spacing, Radii, IconSizes tokens
|
||||||
|
- [ ] Continue replacing remaining hardcoded values (~223 remaining)
|
||||||
|
- [ ] Create `lib/constants/strings.dart` (40+ strings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Polish (Backlog)
|
||||||
|
*Nice-to-have improvements*
|
||||||
|
|
||||||
|
- [ ] Add Semantics labels for accessibility
|
||||||
|
- [ ] Implement localization (i18n)
|
||||||
|
- [ ] Add test coverage
|
||||||
|
- [x] Implement WebSocket heartbeat (`music_assistant_api.dart` - ping every 30s)
|
||||||
|
- [ ] Add responsive grid layouts for tablets
|
||||||
|
- [x] Add completer guards to prevent double-completion (`music_assistant_api.dart`, `music_assistant_provider.dart`, `player_provider.dart`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed Summary
|
||||||
|
|
||||||
|
| Phase | Status | Completed Date |
|
||||||
|
|-------|--------|----------------|
|
||||||
|
| Phase 1 | **COMPLETED** | 2025-12-08 |
|
||||||
|
| Phase 2 | **COMPLETED** | 2025-12-08 |
|
||||||
|
| Phase 3 | **COMPLETED** | 2025-12-08 |
|
||||||
|
| Phase 4 | **PARTIAL** | 2025-12-08 |
|
||||||
|
| Phase 5 | **PARTIAL** | 2025-12-08 |
|
||||||
|
| Phase 6 | **PARTIAL** | 2025-12-08 |
|
||||||
|
| Phase 7 | **PARTIAL** | 2025-12-08 |
|
||||||
|
| Phase 8 | **PARTIAL** | 2025-12-08 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Original audit performed by 7 parallel sub-agents analyzing different aspects
|
||||||
|
- Total LOC: 17,454 across 57 Dart files
|
||||||
|
- Largest files: `music_assistant_provider.dart` (2,106), `music_assistant_api.dart` (1,948), `expandable_player.dart` (1,335)
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
class Timings {
|
class Timings {
|
||||||
Timings._();
|
Timings._();
|
||||||
|
|
||||||
|
/// WebSocket heartbeat/ping interval to keep connection alive
|
||||||
|
static const Duration heartbeatInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
/// Player state polling interval (for selected player updates)
|
/// Player state polling interval (for selected player updates)
|
||||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||||
|
|
||||||
|
|||||||
+97
-26
@@ -8,6 +8,7 @@ import 'screens/login_screen.dart';
|
|||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
import 'services/audio/massiv_audio_handler.dart';
|
import 'services/audio/massiv_audio_handler.dart';
|
||||||
import 'services/auth/auth_manager.dart';
|
import 'services/auth/auth_manager.dart';
|
||||||
|
import 'services/debug_logger.dart';
|
||||||
import 'theme/theme_provider.dart';
|
import 'theme/theme_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'theme/system_theme_helper.dart';
|
import 'theme/system_theme_helper.dart';
|
||||||
@@ -16,6 +17,9 @@ import 'widgets/global_player_overlay.dart';
|
|||||||
// Global audio handler instance
|
// Global audio handler instance
|
||||||
late MassivAudioHandler audioHandler;
|
late MassivAudioHandler audioHandler;
|
||||||
|
|
||||||
|
// Global debug logger instance
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ Future<void> main() async {
|
|||||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
print('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||||
|
|
||||||
// Set preferred orientations
|
// Set preferred orientations
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
@@ -86,12 +90,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
// App came back to foreground - check connection and reconnect if needed
|
// App came back to foreground - check connection and reconnect if needed
|
||||||
print('📱 App resumed - checking WebSocket connection...');
|
_logger.log('📱 App resumed - checking WebSocket connection...');
|
||||||
_musicProvider.checkAndReconnect();
|
_musicProvider.checkAndReconnect();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
print('📱 App paused (backgrounded)');
|
_logger.log('📱 App paused (backgrounded)');
|
||||||
} else if (state == AppLifecycleState.detached) {
|
} else if (state == AppLifecycleState.detached) {
|
||||||
print('📱 App detached (being destroyed)');
|
_logger.log('📱 App detached (being destroyed)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,23 +128,11 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
darkColorScheme = brandDarkColorScheme;
|
darkColorScheme = brandDarkColorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update system UI overlay style based on theme mode
|
return SystemUIWrapper(
|
||||||
final isDark = themeProvider.themeMode == ThemeMode.dark ||
|
themeMode: themeProvider.themeMode,
|
||||||
(themeProvider.themeMode == ThemeMode.system &&
|
lightColorScheme: lightColorScheme,
|
||||||
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
|
darkColorScheme: darkColorScheme,
|
||||||
|
child: MaterialApp(
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
||||||
systemNavigationBarColor: isDark
|
|
||||||
? darkColorScheme.background
|
|
||||||
: lightColorScheme.background,
|
|
||||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'Ensemble',
|
title: 'Ensemble',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
@@ -151,6 +143,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||||
},
|
},
|
||||||
home: const AppStartup(),
|
home: const AppStartup(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -160,6 +153,84 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widget that manages system UI overlay style updates efficiently
|
||||||
|
/// Only updates when theme properties actually change
|
||||||
|
class SystemUIWrapper extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final ThemeMode themeMode;
|
||||||
|
final ColorScheme lightColorScheme;
|
||||||
|
final ColorScheme darkColorScheme;
|
||||||
|
|
||||||
|
const SystemUIWrapper({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.themeMode,
|
||||||
|
required this.lightColorScheme,
|
||||||
|
required this.darkColorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SystemUIWrapper> createState() => _SystemUIWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||||
|
ThemeMode? _previousThemeMode;
|
||||||
|
Brightness? _previousPlatformBrightness;
|
||||||
|
ColorScheme? _previousLightColorScheme;
|
||||||
|
ColorScheme? _previousDarkColorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_updateSystemUIIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SystemUIWrapper oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_updateSystemUIIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSystemUIIfNeeded() {
|
||||||
|
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
|
|
||||||
|
// Check if any relevant properties have changed
|
||||||
|
final themeChanged = _previousThemeMode != widget.themeMode;
|
||||||
|
final platformBrightnessChanged = _previousPlatformBrightness != platformBrightness;
|
||||||
|
final lightColorSchemeChanged = _previousLightColorScheme != widget.lightColorScheme;
|
||||||
|
final darkColorSchemeChanged = _previousDarkColorScheme != widget.darkColorScheme;
|
||||||
|
|
||||||
|
if (themeChanged || platformBrightnessChanged || lightColorSchemeChanged || darkColorSchemeChanged) {
|
||||||
|
// Determine if we should use dark theme
|
||||||
|
final isDark = widget.themeMode == ThemeMode.dark ||
|
||||||
|
(widget.themeMode == ThemeMode.system && platformBrightness == Brightness.dark);
|
||||||
|
|
||||||
|
// Update system UI overlay style
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
|
SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
|
systemNavigationBarColor: isDark
|
||||||
|
? widget.darkColorScheme.background
|
||||||
|
: widget.lightColorScheme.background,
|
||||||
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update cached values
|
||||||
|
_previousThemeMode = widget.themeMode;
|
||||||
|
_previousPlatformBrightness = platformBrightness;
|
||||||
|
_previousLightColorScheme = widget.lightColorScheme;
|
||||||
|
_previousDarkColorScheme = widget.darkColorScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Startup widget that checks if user is logged in and auto-connects
|
/// Startup widget that checks if user is logged in and auto-connects
|
||||||
class AppStartup extends StatefulWidget {
|
class AppStartup extends StatefulWidget {
|
||||||
const AppStartup({super.key});
|
const AppStartup({super.key});
|
||||||
@@ -198,24 +269,24 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
|
|
||||||
// Only attempt connection if not already connected
|
// Only attempt connection if not already connected
|
||||||
if (!provider.isConnected) {
|
if (!provider.isConnected) {
|
||||||
print('🚀 AppStartup: Auto-connecting to saved server: $serverUrl');
|
_logger.log('🚀 AppStartup: Auto-connecting to saved server: $serverUrl');
|
||||||
try {
|
try {
|
||||||
// Restore auth credentials before connecting (critical for Authelia)
|
// Restore auth credentials before connecting (critical for Authelia)
|
||||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||||
if (savedCredentials != null) {
|
if (savedCredentials != null) {
|
||||||
print('🚀 AppStartup: Restoring saved auth credentials');
|
_logger.log('🚀 AppStartup: Restoring saved auth credentials');
|
||||||
provider.authManager.deserializeCredentials(savedCredentials);
|
provider.authManager.deserializeCredentials(savedCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
await provider.connectToServer(serverUrl);
|
await provider.connectToServer(serverUrl);
|
||||||
print('🚀 AppStartup: Auto-connection successful');
|
_logger.log('🚀 AppStartup: Auto-connection successful');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('🚀 AppStartup: Auto-connection failed: $e');
|
_logger.log('🚀 AppStartup: Auto-connection failed: $e');
|
||||||
// Connection failed, but still show home screen
|
// Connection failed, but still show home screen
|
||||||
// User can manually reconnect from there
|
// User can manually reconnect from there
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('🚀 AppStartup: Already connected');
|
_logger.log('🚀 AppStartup: Already connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
+14
-10
@@ -60,11 +60,12 @@ class Player {
|
|||||||
double? elapsedTime;
|
double? elapsedTime;
|
||||||
double? elapsedTimeLastUpdated;
|
double? elapsedTimeLastUpdated;
|
||||||
|
|
||||||
// IMPORTANT: Check top-level fields FIRST - they have the most current data
|
// Get top-level elapsed time values
|
||||||
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
final topLevelElapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
||||||
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
|
final topLevelLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||||
|
|
||||||
// Extract current_item_id from current_media
|
// Extract current_item_id and elapsed time from current_media
|
||||||
|
// current_media often has the more accurate position after seeks
|
||||||
if (json.containsKey('current_media')) {
|
if (json.containsKey('current_media')) {
|
||||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
@@ -73,23 +74,26 @@ class Player {
|
|||||||
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||||
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||||
|
|
||||||
// Use current_media elapsed_time if:
|
// Prefer current_media elapsed_time when available - it reflects actual playback position
|
||||||
// 1. We don't have top-level elapsed_time, OR
|
// after seek operations, while top-level elapsed_time may lag behind
|
||||||
// 2. Top-level is 0 but current_media has a real value
|
if (currentMediaElapsedTime != null) {
|
||||||
if (elapsedTime == null || (elapsedTime == 0 && currentMediaElapsedTime != null && currentMediaElapsedTime > 0)) {
|
|
||||||
elapsedTime = currentMediaElapsedTime;
|
elapsedTime = currentMediaElapsedTime;
|
||||||
elapsedTimeLastUpdated = currentMediaLastUpdated;
|
elapsedTimeLastUpdated = currentMediaLastUpdated ?? topLevelLastUpdated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to top-level values if current_media didn't have elapsed time
|
||||||
|
elapsedTime ??= topLevelElapsedTime;
|
||||||
|
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
||||||
|
|
||||||
return Player(
|
return Player(
|
||||||
playerId: json['player_id'] as String,
|
playerId: json['player_id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
provider: json['provider'] as String?,
|
provider: json['provider'] as String?,
|
||||||
available: json['available'] as bool? ?? false,
|
available: json['available'] as bool? ?? false,
|
||||||
powered: json['powered'] as bool? ?? false,
|
powered: json['powered'] as bool? ?? false,
|
||||||
state: json['state'] as String? ?? 'idle',
|
state: json['playback_state'] as String? ?? json['state'] as String? ?? 'idle',
|
||||||
currentItemId: currentItemId,
|
currentItemId: currentItemId,
|
||||||
volumeLevel: json['volume_level'] as int?,
|
volumeLevel: json['volume_level'] as int?,
|
||||||
volumeMuted: json['volume_muted'] as bool?,
|
volumeMuted: json['volume_muted'] as bool?,
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
|
||||||
|
/// Provider for managing connection state to Music Assistant server
|
||||||
|
/// Handles WebSocket connection, authentication, and reconnection
|
||||||
|
class ConnectionProvider with ChangeNotifier {
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
final AuthManager _authManager = AuthManager();
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
final CacheService _cacheService = CacheService();
|
||||||
|
|
||||||
|
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||||
|
String? _serverUrl;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// Callbacks for post-connection initialization
|
||||||
|
VoidCallback? onConnected;
|
||||||
|
VoidCallback? onAuthenticated;
|
||||||
|
VoidCallback? onDisconnected;
|
||||||
|
|
||||||
|
MAConnectionState get connectionState => _connectionState;
|
||||||
|
String? get serverUrl => _serverUrl;
|
||||||
|
String? get error => _error;
|
||||||
|
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||||
|
_connectionState == MAConnectionState.authenticated;
|
||||||
|
|
||||||
|
MusicAssistantAPI? get api => _api;
|
||||||
|
AuthManager get authManager => _authManager;
|
||||||
|
CacheService get cacheService => _cacheService;
|
||||||
|
|
||||||
|
ConnectionProvider() {
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
_serverUrl = await SettingsService.getServerUrl();
|
||||||
|
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||||
|
await _restoreAuthCredentials();
|
||||||
|
await connectToServer(_serverUrl!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore auth credentials from persistent storage
|
||||||
|
Future<void> _restoreAuthCredentials() async {
|
||||||
|
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||||
|
if (savedCredentials != null) {
|
||||||
|
_logger.log('🔐 Restoring saved auth credentials...');
|
||||||
|
_authManager.deserializeCredentials(savedCredentials);
|
||||||
|
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
||||||
|
} else {
|
||||||
|
_logger.log('🔐 No saved auth credentials found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Music Assistant native authentication after WebSocket connection
|
||||||
|
Future<bool> handleMaAuthentication() async {
|
||||||
|
if (_api == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, try stored MA token
|
||||||
|
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 _fetchAndSetUserProfileName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||||
|
await SettingsService.clearMaAuthToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid token - try stored credentials
|
||||||
|
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 _fetchAndSetUserProfileName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ MA authentication error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch user profile from MA and set owner name
|
||||||
|
Future<void> _fetchAndSetUserProfileName() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userInfo = await _api!.getCurrentUserInfo();
|
||||||
|
if (userInfo == null) {
|
||||||
|
_logger.log('⚠️ Could not fetch user profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayName = userInfo['display_name'] as String?;
|
||||||
|
final username = userInfo['username'] as String?;
|
||||||
|
|
||||||
|
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
||||||
|
|
||||||
|
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');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ No valid name in profile');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connectToServer(String serverUrl) async {
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
await SettingsService.setServerUrl(serverUrl);
|
||||||
|
|
||||||
|
// Disconnect existing connection
|
||||||
|
await _api?.disconnect();
|
||||||
|
|
||||||
|
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||||
|
|
||||||
|
// Listen to connection state changes
|
||||||
|
_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 handleMaAuthentication();
|
||||||
|
if (!authenticated) {
|
||||||
|
_logger.log('❌ MA authentication failed - stopping connection flow');
|
||||||
|
_error = 'Authentication required. Please log in again.';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth required - notify connected
|
||||||
|
onConnected?.call();
|
||||||
|
} else if (state == MAConnectionState.authenticated) {
|
||||||
|
_logger.log('✅ MA authentication successful');
|
||||||
|
onAuthenticated?.call();
|
||||||
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
|
_cacheService.clearAll();
|
||||||
|
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<void> disconnect() async {
|
||||||
|
await _api?.disconnect();
|
||||||
|
_connectionState = MAConnectionState.disconnected;
|
||||||
|
_cacheService.clearAll();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check connection and reconnect if needed (called when app resumes)
|
||||||
|
Future<void> checkAndReconnect() async {
|
||||||
|
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||||
|
|
||||||
|
if (_serverUrl == null) {
|
||||||
|
_logger.log('🔄 No server URL saved, skipping reconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connectionState != MAConnectionState.connected &&
|
||||||
|
_connectionState != MAConnectionState.authenticated) {
|
||||||
|
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||||
|
try {
|
||||||
|
await connectToServer(_serverUrl!);
|
||||||
|
_logger.log('🔄 Reconnection successful');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🔄 Reconnection failed: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.log('🔄 Already connected, connection verified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_api?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
|
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||||
|
class LibraryProvider with ChangeNotifier {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
final CacheService _cacheService;
|
||||||
|
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
|
||||||
|
List<Artist> _artists = [];
|
||||||
|
List<Album> _albums = [];
|
||||||
|
List<Track> _tracks = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// Search state persistence
|
||||||
|
String _lastSearchQuery = '';
|
||||||
|
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<Artist> get artists => _artists;
|
||||||
|
List<Album> get albums => _albums;
|
||||||
|
List<Track> get tracks => _tracks;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
|
LibraryProvider(this._cacheService);
|
||||||
|
|
||||||
|
/// Initialize with API reference after connection
|
||||||
|
void initialize(MusicAssistantAPI api) {
|
||||||
|
_api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||||
|
_lastSearchQuery = query;
|
||||||
|
_lastSearchResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSearchState() {
|
||||||
|
_lastSearchQuery = '';
|
||||||
|
_lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOME SCREEN DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get recently played albums with caching
|
||||||
|
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached recent albums');
|
||||||
|
return _cacheService.getCachedRecentAlbums()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh recent albums...');
|
||||||
|
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedRecentAlbums(albums);
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||||
|
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get discover artists (random) with caching
|
||||||
|
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached discover artists');
|
||||||
|
return _cacheService.getCachedDiscoverArtists()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh discover artists...');
|
||||||
|
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedDiscoverArtists(artists);
|
||||||
|
return artists;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||||
|
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get discover albums (random) with caching
|
||||||
|
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached discover albums');
|
||||||
|
return _cacheService.getCachedDiscoverAlbums()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh discover albums...');
|
||||||
|
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedDiscoverAlbums(albums);
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||||
|
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
|
void invalidateHomeCache() {
|
||||||
|
_cacheService.invalidateHomeCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LIBRARY DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> loadLibrary() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
]);
|
||||||
|
|
||||||
|
_artists = results[0] as List<Artist>;
|
||||||
|
_albums = results[1] as List<Album>;
|
||||||
|
_tracks = results[2] as List<Track>;
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_artists = await _api!.getArtists(
|
||||||
|
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||||
|
offset: offset,
|
||||||
|
search: search,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_albums = await _api!.getAlbums(
|
||||||
|
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||||
|
offset: offset,
|
||||||
|
search: search,
|
||||||
|
artistId: artistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DETAIL SCREEN DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get album tracks with caching
|
||||||
|
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = '${provider}_$itemId';
|
||||||
|
|
||||||
|
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached album tracks for $cacheKey');
|
||||||
|
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
||||||
|
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
||||||
|
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
||||||
|
return tracks;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch album tracks: $e');
|
||||||
|
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playlist tracks with caching
|
||||||
|
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = '${provider}_$itemId';
|
||||||
|
|
||||||
|
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
||||||
|
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
||||||
|
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
||||||
|
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
||||||
|
return tracks;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
||||||
|
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get artist albums with caching
|
||||||
|
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = artistName.toLowerCase();
|
||||||
|
|
||||||
|
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached artist albums for "$artistName"');
|
||||||
|
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||||
|
|
||||||
|
final libraryAlbums = await _api!.getAlbums();
|
||||||
|
final artistAlbums = libraryAlbums.where((album) {
|
||||||
|
final albumArtists = album.artists;
|
||||||
|
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||||
|
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final searchResults = await _api!.search(artistName);
|
||||||
|
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
||||||
|
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
||||||
|
final albumArtists = album.artists;
|
||||||
|
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||||
|
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final allAlbums = <Album>[];
|
||||||
|
final seenNames = <String>{};
|
||||||
|
|
||||||
|
for (final album in [...artistAlbums, ...providerAlbums]) {
|
||||||
|
final key = album.name.toLowerCase();
|
||||||
|
if (!seenNames.contains(key)) {
|
||||||
|
seenNames.add(key);
|
||||||
|
allAlbums.add(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||||
|
return allAlbums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch artist albums: $e');
|
||||||
|
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate album tracks cache
|
||||||
|
void invalidateAlbumTracksCache(String albumId) {
|
||||||
|
_cacheService.invalidateAlbumTracksCache(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate playlist tracks cache
|
||||||
|
void invalidatePlaylistTracksCache(String playlistId) {
|
||||||
|
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SEARCH
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Search with caching
|
||||||
|
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = query.toLowerCase().trim();
|
||||||
|
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
|
||||||
|
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached search results for "$query"');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Searching for "$query"...');
|
||||||
|
final results = await _api!.search(query);
|
||||||
|
|
||||||
|
final cachedResults = <String, List<MediaItem>>{
|
||||||
|
'artists': results['artists'] ?? [],
|
||||||
|
'albums': results['albums'] ?? [],
|
||||||
|
'tracks': results['tracks'] ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||||
|
return cachedResults;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Search failed: $e');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||||
|
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Search', e);
|
||||||
|
return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||||
|
if (_api == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _api!.getAlbumTracks(provider, itemId);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Get album tracks', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearState() {
|
||||||
|
_artists = [];
|
||||||
|
_albums = [];
|
||||||
|
_tracks = [];
|
||||||
|
_isLoading = false;
|
||||||
|
_error = null;
|
||||||
|
clearSearchState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/local_player_service.dart';
|
||||||
|
import '../services/auth/auth_manager.dart';
|
||||||
|
import '../services/device_id_service.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
import '../main.dart' show audioHandler;
|
||||||
|
|
||||||
|
/// Provider for managing player state, selection, and controls
|
||||||
|
class PlayerProvider with ChangeNotifier {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
late final LocalPlayerService _localPlayer;
|
||||||
|
final CacheService _cacheService;
|
||||||
|
|
||||||
|
// Dependencies injected from parent provider
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
AuthManager? _authManager;
|
||||||
|
String? _serverUrl;
|
||||||
|
|
||||||
|
// Player selection
|
||||||
|
Player? _selectedPlayer;
|
||||||
|
List<Player> _availablePlayers = [];
|
||||||
|
Track? _currentTrack;
|
||||||
|
Timer? _playerStateTimer;
|
||||||
|
Timer? _adjacentPlayerCacheTimer;
|
||||||
|
|
||||||
|
// Local Playback
|
||||||
|
bool _isLocalPlayerPowered = true;
|
||||||
|
StreamSubscription? _localPlayerEventSubscription;
|
||||||
|
StreamSubscription? _playerUpdatedEventSubscription;
|
||||||
|
Timer? _localPlayerStateReportTimer;
|
||||||
|
|
||||||
|
// Pending metadata from player_updated events
|
||||||
|
TrackMetadata? _pendingTrackMetadata;
|
||||||
|
TrackMetadata? _currentNotificationMetadata;
|
||||||
|
|
||||||
|
// Registration guard
|
||||||
|
Completer<void>? _registrationInProgress;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
Player? get selectedPlayer => _selectedPlayer;
|
||||||
|
List<Player> get availablePlayers => _availablePlayers;
|
||||||
|
Track? get currentTrack => _currentTrack;
|
||||||
|
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||||
|
|
||||||
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
|
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||||
|
|
||||||
|
PlayerProvider(this._cacheService, AuthManager authManager) {
|
||||||
|
_authManager = authManager;
|
||||||
|
_localPlayer = LocalPlayerService(authManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with API reference after connection
|
||||||
|
void initialize(MusicAssistantAPI api, String serverUrl) {
|
||||||
|
_api = api;
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up event listeners after connection
|
||||||
|
Future<void> setupEventListeners() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
// Listen to built-in player events
|
||||||
|
_localPlayerEventSubscription?.cancel();
|
||||||
|
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||||
|
_handleLocalPlayerEvent,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('Builtin player event stream error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen to player_updated events
|
||||||
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||||
|
_handlePlayerUpdatedEvent,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('Player updated event stream error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize local playback
|
||||||
|
Future<void> initializeLocalPlayback() async {
|
||||||
|
await _localPlayer.initialize();
|
||||||
|
_isLocalPlayerPowered = true;
|
||||||
|
|
||||||
|
// Wire up skip button callbacks
|
||||||
|
audioHandler.onSkipToNext = () {
|
||||||
|
_logger.log('🎵 Notification: Skip to next pressed');
|
||||||
|
nextTrackSelectedPlayer();
|
||||||
|
};
|
||||||
|
audioHandler.onSkipToPrevious = () {
|
||||||
|
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||||
|
previousTrackSelectedPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_api != null) {
|
||||||
|
await registerLocalPlayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to adopt an existing ghost player
|
||||||
|
Future<bool> tryAdoptGhostPlayer() async {
|
||||||
|
if (_api == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final isFresh = await DeviceIdService.isFreshInstallation();
|
||||||
|
if (!isFresh) {
|
||||||
|
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ownerName = await SettingsService.getOwnerName();
|
||||||
|
if (ownerName == null || ownerName.isEmpty) {
|
||||||
|
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
||||||
|
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
||||||
|
if (adoptableId == null) {
|
||||||
|
_logger.log('👻 No matching ghost player found - will generate new ID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
||||||
|
await DeviceIdService.adoptPlayerId(adoptableId);
|
||||||
|
|
||||||
|
_logger.log('✅ Successfully adopted ghost player');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register local player with MA server
|
||||||
|
Future<void> registerLocalPlayer() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
if (_registrationInProgress != null) {
|
||||||
|
_logger.log('⏳ Registration already in progress, waiting...');
|
||||||
|
return _registrationInProgress!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
_registrationInProgress = Completer<void>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||||
|
_logger.log('🆔 Using player ID: $playerId');
|
||||||
|
|
||||||
|
await SettingsService.setBuiltinPlayerId(playerId);
|
||||||
|
final name = await SettingsService.getLocalPlayerName();
|
||||||
|
|
||||||
|
final existingPlayers = await _api!.getPlayers();
|
||||||
|
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||||
|
|
||||||
|
if (existingPlayer != null && existingPlayer.available) {
|
||||||
|
_logger.log('✅ Player already registered and available: $playerId');
|
||||||
|
_startReportingLocalPlayerState();
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.complete();
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
return;
|
||||||
|
} else if (existingPlayer != null && !existingPlayer.available) {
|
||||||
|
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
||||||
|
} else {
|
||||||
|
_logger.log('🆔 Player not found in MA, registering as new');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
||||||
|
await _api!.registerBuiltinPlayer(playerId, name);
|
||||||
|
|
||||||
|
_logger.log('✅ Player registration complete');
|
||||||
|
_startReportingLocalPlayerState();
|
||||||
|
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.complete();
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.completeError(e);
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startReportingLocalPlayerState() {
|
||||||
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||||
|
try {
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error reporting local player state (will retry): $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reportLocalPlayerState() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
final isPlaying = _localPlayer.isPlaying;
|
||||||
|
final position = _localPlayer.position.inSeconds;
|
||||||
|
final volume = (_localPlayer.volume * 100).round();
|
||||||
|
final isPaused = !isPlaying && position > 0;
|
||||||
|
|
||||||
|
await _api!.updateBuiltinPlayerState(
|
||||||
|
playerId,
|
||||||
|
powered: _isLocalPlayerPowered,
|
||||||
|
playing: isPlaying,
|
||||||
|
paused: isPaused,
|
||||||
|
position: position,
|
||||||
|
volume: volume,
|
||||||
|
muted: _localPlayer.volume == 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
||||||
|
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final eventPlayerId = event['player_id'] as String?;
|
||||||
|
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
|
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
||||||
|
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'play_media':
|
||||||
|
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
||||||
|
|
||||||
|
if (urlPath != null && _serverUrl != null) {
|
||||||
|
String fullUrl;
|
||||||
|
if (urlPath.startsWith('http')) {
|
||||||
|
fullUrl = urlPath;
|
||||||
|
} else {
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||||
|
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
||||||
|
fullUrl = '$baseUrl$path';
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackMetadata metadata;
|
||||||
|
if (_pendingTrackMetadata != null) {
|
||||||
|
metadata = _pendingTrackMetadata!;
|
||||||
|
} else {
|
||||||
|
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
||||||
|
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
||||||
|
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
||||||
|
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
||||||
|
final durationSecs = event['duration'] as int?;
|
||||||
|
|
||||||
|
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||||
|
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = TrackMetadata(
|
||||||
|
title: trackName,
|
||||||
|
artist: artistName,
|
||||||
|
album: albumName,
|
||||||
|
artworkUrl: artworkUrl,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||||
|
_currentNotificationMetadata = metadata;
|
||||||
|
await _localPlayer.playUrl(fullUrl);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
await _localPlayer.stop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pause':
|
||||||
|
await _localPlayer.pause();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'play':
|
||||||
|
await _localPlayer.play();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'seek':
|
||||||
|
final position = event['position'] as int?;
|
||||||
|
if (position != null) {
|
||||||
|
await _localPlayer.seek(Duration(seconds: position));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'volume_set':
|
||||||
|
final volume = event['volume_level'] as int?;
|
||||||
|
if (volume != null) {
|
||||||
|
await _localPlayer.setVolume(volume / 100.0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'power_on':
|
||||||
|
case 'power_off':
|
||||||
|
case 'power':
|
||||||
|
bool? newPowerState;
|
||||||
|
if (command == 'power_on') {
|
||||||
|
newPowerState = true;
|
||||||
|
} else if (command == 'power_off') {
|
||||||
|
newPowerState = false;
|
||||||
|
} else {
|
||||||
|
newPowerState = event['powered'] as bool?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPowerState != null) {
|
||||||
|
_isLocalPlayerPowered = newPowerState;
|
||||||
|
if (!_isLocalPlayerPowered) {
|
||||||
|
await _localPlayer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error handling local player event: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||||
|
try {
|
||||||
|
final playerId = event['player_id'] as String?;
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||||
|
_updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||||
|
final playerName = event['name'] as String? ?? playerId;
|
||||||
|
|
||||||
|
if (currentMedia != null) {
|
||||||
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
if (mediaType != 'flow_stream') {
|
||||||
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
|
final albumName = currentMedia['album'] as String?;
|
||||||
|
final imageUrl = currentMedia['image_url'] as String?;
|
||||||
|
|
||||||
|
Map<String, dynamic>? metadata;
|
||||||
|
if (imageUrl != null && _serverUrl != null) {
|
||||||
|
var finalImageUrl = imageUrl;
|
||||||
|
try {
|
||||||
|
final imgUri = Uri.parse(imageUrl);
|
||||||
|
final queryString = imgUri.query;
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
if (baseUrl.endsWith('/')) {
|
||||||
|
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
||||||
|
} catch (e) {
|
||||||
|
// Use original URL
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackFromEvent = Track(
|
||||||
|
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||||
|
provider: 'library',
|
||||||
|
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||||
|
uri: currentMedia['uri'] as String?,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||||
|
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||||
|
|
||||||
|
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||||
|
_currentTrack = trackFromEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
||||||
|
|
||||||
|
if (imageUrl != null) {
|
||||||
|
final images = metadata?['images'] as List?;
|
||||||
|
if (images != null && images.isNotEmpty) {
|
||||||
|
final imagePath = images[0]['path'] as String?;
|
||||||
|
if (imagePath != null) {
|
||||||
|
_precacheImage(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle notification metadata for local player
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
||||||
|
|
||||||
|
if (currentMedia == null) return;
|
||||||
|
|
||||||
|
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||||
|
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
||||||
|
final album = currentMedia['album'] as String?;
|
||||||
|
var imageUrl = currentMedia['image_url'] as String?;
|
||||||
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
|
|
||||||
|
if (imageUrl != null && _serverUrl != null) {
|
||||||
|
try {
|
||||||
|
final imgUri = Uri.parse(imageUrl);
|
||||||
|
final queryString = imgUri.query;
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
if (baseUrl.endsWith('/')) {
|
||||||
|
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
imageUrl = '$baseUrl/imageproxy?$queryString';
|
||||||
|
} catch (e) {
|
||||||
|
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
||||||
|
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMetadata = TrackMetadata(
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
artworkUrl: imageUrl,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
if (mediaType == 'flow_stream') return;
|
||||||
|
|
||||||
|
_pendingTrackMetadata = newMetadata;
|
||||||
|
|
||||||
|
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
||||||
|
(_currentNotificationMetadata!.title != title ||
|
||||||
|
_currentNotificationMetadata!.artist != artist);
|
||||||
|
|
||||||
|
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
||||||
|
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
||||||
|
_currentNotificationMetadata = newMetadata;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error handling player_updated event: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER SELECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final allPlayers = await _api!.getPlayers();
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
|
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||||
|
|
||||||
|
int filteredCount = 0;
|
||||||
|
|
||||||
|
_availablePlayers = allPlayers.where((player) {
|
||||||
|
final nameLower = player.name.toLowerCase();
|
||||||
|
|
||||||
|
if (nameLower.contains('music assistant mobile')) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameLower == 'this device' || player.playerId.startsWith('ma_')) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.playerId.startsWith('ensemble_')) {
|
||||||
|
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.available) {
|
||||||
|
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
_cacheService.updatePlayersLastFetched();
|
||||||
|
|
||||||
|
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||||
|
|
||||||
|
if (_availablePlayers.isNotEmpty) {
|
||||||
|
Player? playerToSelect;
|
||||||
|
|
||||||
|
if (_selectedPlayer != null) {
|
||||||
|
final stillAvailable = _availablePlayers.any(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||||
|
);
|
||||||
|
if (stillAvailable) {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
|
||||||
|
if (lastSelectedPlayerId != null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.state == 'playing' && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.available,
|
||||||
|
orElse: () => _availablePlayers.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPlayer(playerToSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
_preloadAdjacentPlayers(preloadAll: true);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Load and select players', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||||
|
_selectedPlayer = player;
|
||||||
|
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||||
|
_startPlayerStatePolling();
|
||||||
|
_preloadAdjacentPlayers();
|
||||||
|
|
||||||
|
if (!skipNotify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
final players = _availablePlayers.where((p) => p.available).toList();
|
||||||
|
if (players.isEmpty) return;
|
||||||
|
|
||||||
|
if (preloadAll) {
|
||||||
|
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
||||||
|
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||||
|
_logger.log('🖼️ Preloading complete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedPlayer == null || players.length <= 1) return;
|
||||||
|
|
||||||
|
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
||||||
|
if (currentIndex == -1) return;
|
||||||
|
|
||||||
|
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||||
|
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
|
||||||
|
final playersToPreload = <Player>{};
|
||||||
|
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||||
|
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||||
|
|
||||||
|
for (final player in playersToPreload) {
|
||||||
|
_preloadPlayerTrack(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadPlayerTrack(Player player) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!player.available || !player.powered) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queue = await _api!.getQueue(player.playerId);
|
||||||
|
|
||||||
|
if (queue != null && queue.currentItem != null) {
|
||||||
|
final track = queue.currentItem!.track;
|
||||||
|
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||||
|
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||||
|
final newHasImage = track.metadata?['images'] != null;
|
||||||
|
|
||||||
|
if (!existingHasImage || newHasImage) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
||||||
|
if (artworkUrl512 != null) {
|
||||||
|
await _precacheImage(artworkUrl512);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||||
|
if (existingTrack?.metadata?['images'] == null) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error preloading player track for ${player.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImage(String url) async {
|
||||||
|
try {
|
||||||
|
final imageProvider = NetworkImage(url);
|
||||||
|
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||||
|
final completer = Completer<void>();
|
||||||
|
late ImageStreamListener listener;
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(ImageInfo info, bool synchronousCall) {
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
if (!completer.isCompleted) completer.completeError(exception);
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
imageStream.addListener(listener);
|
||||||
|
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Image precache failed for $url: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> preloadAllPlayerTracks() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
final players = _availablePlayers.where((p) => p.available).toList();
|
||||||
|
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPlayerStatePolling() {
|
||||||
|
_playerStateTimer?.cancel();
|
||||||
|
if (_selectedPlayer == null) return;
|
||||||
|
|
||||||
|
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||||
|
try {
|
||||||
|
await _updatePlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error updating player state (will retry): $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updatePlayerState() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
bool stateChanged = false;
|
||||||
|
|
||||||
|
final allPlayers = await _api!.getPlayers();
|
||||||
|
final updatedPlayer = allPlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
orElse: () => _selectedPlayer!,
|
||||||
|
);
|
||||||
|
|
||||||
|
_selectedPlayer = updatedPlayer;
|
||||||
|
stateChanged = true;
|
||||||
|
|
||||||
|
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||||
|
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||||
|
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||||
|
|
||||||
|
if (!shouldShowTrack) {
|
||||||
|
if (_currentTrack != null) {
|
||||||
|
_currentTrack = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
||||||
|
|
||||||
|
if (queue != null && queue.currentItem != null) {
|
||||||
|
if (_currentTrack == null ||
|
||||||
|
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||||
|
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||||
|
_currentTrack = queue.currentItem!.track;
|
||||||
|
stateChanged = true;
|
||||||
|
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
||||||
|
final track = _currentTrack!;
|
||||||
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
|
_localPlayer.updateNotification(
|
||||||
|
id: track.uri ?? track.itemId,
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artistsString,
|
||||||
|
album: track.album?.name,
|
||||||
|
artworkUrl: artworkUrl,
|
||||||
|
duration: track.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_currentTrack != null) {
|
||||||
|
_currentTrack = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error updating player state: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER CONTROLS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> playPauseSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
|
||||||
|
if (_selectedPlayer!.isPlaying) {
|
||||||
|
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
||||||
|
} else {
|
||||||
|
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> nextTrackSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
await _api!.nextTrack(_selectedPlayer!.playerId);
|
||||||
|
await Future.delayed(Timings.trackChangeDelay);
|
||||||
|
await _updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> previousTrackSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
await _api!.previousTrack(_selectedPlayer!.playerId);
|
||||||
|
await Future.delayed(Timings.trackChangeDelay);
|
||||||
|
await _updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshPlayers() async {
|
||||||
|
final previousState = _selectedPlayer?.state;
|
||||||
|
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||||
|
|
||||||
|
await loadAndSelectPlayers(forceRefresh: true);
|
||||||
|
|
||||||
|
bool stateChanged = false;
|
||||||
|
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final updatedPlayer = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedPlayer = updatedPlayer;
|
||||||
|
} catch (e) {
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> togglePower(String playerId) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
||||||
|
|
||||||
|
if (isLocalPlayer) {
|
||||||
|
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
||||||
|
if (!_isLocalPlayerPowered) {
|
||||||
|
await _localPlayer.stop();
|
||||||
|
}
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
await refreshPlayers();
|
||||||
|
} else {
|
||||||
|
final player = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == playerId,
|
||||||
|
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||||
|
? _selectedPlayer!
|
||||||
|
: throw Exception("Player not found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _api!.setPower(playerId, !player.powered);
|
||||||
|
await refreshPlayers();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Toggle power', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setVolume(playerId, volumeLevel);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set volume', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMute(String playerId, bool muted) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setMute(playerId, muted);
|
||||||
|
await refreshPlayers();
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set mute', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seek(String playerId, int position) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.seek(playerId, position);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Seek', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleShuffle(String queueId) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.toggleShuffle(queueId);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRepeatMode(String queueId, String mode) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setRepeatMode(queueId, mode);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set repeat mode', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||||
|
String nextMode;
|
||||||
|
switch (currentMode) {
|
||||||
|
case 'off':
|
||||||
|
case null:
|
||||||
|
nextMode = 'all';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
nextMode = 'one';
|
||||||
|
break;
|
||||||
|
case 'one':
|
||||||
|
nextMode = 'off';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nextMode = 'off';
|
||||||
|
}
|
||||||
|
await setRepeatMode(queueId, nextMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearState() {
|
||||||
|
_playerStateTimer?.cancel();
|
||||||
|
_playerStateTimer = null;
|
||||||
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_localPlayerEventSubscription?.cancel();
|
||||||
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_availablePlayers = [];
|
||||||
|
_selectedPlayer = null;
|
||||||
|
_currentTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
clearState();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -703,49 +703,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
_showPlayOnSheet(
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
context,
|
||||||
|
players,
|
||||||
showModalBottomSheet(
|
onPlayerSelected: (player) {
|
||||||
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: [
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Play on...',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (players.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(32.0),
|
|
||||||
child: Text('No players available'),
|
|
||||||
)
|
|
||||||
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: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
// Set this as the active player
|
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
// Play album from this track onwards
|
|
||||||
maProvider.playTracks(
|
maProvider.playTracks(
|
||||||
player.playerId,
|
player.playerId,
|
||||||
_tracks,
|
_tracks,
|
||||||
@@ -753,17 +715,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||||
@@ -771,85 +722,54 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
_showPlayOnSheet(
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
context,
|
||||||
|
players,
|
||||||
showModalBottomSheet(
|
onPlayerSelected: (player) {
|
||||||
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: [
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Play on...',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (players.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(32.0),
|
|
||||||
child: Text('No players available'),
|
|
||||||
)
|
|
||||||
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: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
// Set this as the active player
|
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
// Play radio based on this track
|
maProvider.playRadio(player.playerId, track);
|
||||||
maProvider.playRadio(
|
|
||||||
player.playerId,
|
|
||||||
track,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
|
_showPlayOnSheet(
|
||||||
|
context,
|
||||||
|
players,
|
||||||
|
onPlayerSelected: (player) {
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
maProvider.playTracks(player.playerId, _tracks);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
|
||||||
|
void _showPlayOnSheet(
|
||||||
|
BuildContext context,
|
||||||
|
List players, {
|
||||||
|
required void Function(dynamic player) onPlayerSelected,
|
||||||
|
}) {
|
||||||
// Slide mini player down out of the way
|
// Slide mini player down out of the way
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
|
height: sheetHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -857,15 +777,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (players.isEmpty)
|
Expanded(
|
||||||
const Padding(
|
child: players.isEmpty
|
||||||
padding: EdgeInsets.all(32.0),
|
? const Center(
|
||||||
child: Text('No players available'),
|
child: Text('No players available'),
|
||||||
)
|
)
|
||||||
else
|
: ListView.builder(
|
||||||
Flexible(
|
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: players.length,
|
itemCount: players.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final player = players[index];
|
final player = players[index];
|
||||||
@@ -877,16 +795,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
title: Text(player.name),
|
title: Text(player.name),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
// Set this as the active player
|
onPlayerSelected(player);
|
||||||
maProvider.selectPlayer(player);
|
|
||||||
// Play on this specific player
|
|
||||||
maProvider.playTracks(player.playerId, _tracks);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
|
||||||
class DebugLogScreen extends StatefulWidget {
|
class DebugLogScreen extends StatefulWidget {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/album_card.dart';
|
import '../widgets/album_card.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class LibraryAlbumsScreen extends StatelessWidget {
|
class LibraryAlbumsScreen extends StatelessWidget {
|
||||||
const LibraryAlbumsScreen({super.key});
|
const LibraryAlbumsScreen({super.key});
|
||||||
@@ -52,37 +53,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return Center(
|
return EmptyState.albums(
|
||||||
child: Column(
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.album_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<MusicAssistantProvider>().loadLibrary();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
class LibraryArtistsScreen extends StatelessWidget {
|
class LibraryArtistsScreen extends StatelessWidget {
|
||||||
@@ -54,37 +55,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
return Center(
|
return EmptyState.artists(
|
||||||
child: Column(
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.person_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<MusicAssistantProvider>().loadLibrary();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||||
@@ -80,36 +81,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_playlists.isEmpty) {
|
if (_playlists.isEmpty) {
|
||||||
return Center(
|
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.playlist_play_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No playlists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _loadPlaylists,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
|||||||
@@ -1,456 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import '../providers/music_assistant_provider.dart';
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import 'album_details_screen.dart';
|
|
||||||
import 'settings_screen.dart';
|
|
||||||
|
|
||||||
class LibraryScreen extends StatefulWidget {
|
|
||||||
const LibraryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LibraryScreen> createState() => _LibraryScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LibraryScreenState extends State<LibraryScreen>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
|
||||||
final _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabController.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colorScheme.background,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
|
||||||
'Library',
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bottom: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
indicatorColor: colorScheme.primary,
|
|
||||||
labelColor: colorScheme.primary,
|
|
||||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'Artists'),
|
|
||||||
Tab(text: 'Albums'),
|
|
||||||
Tab(text: 'Tracks'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: !provider.isConnected
|
|
||||||
? _buildDisconnectedView(provider)
|
|
||||||
: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
_buildArtistsList(provider),
|
|
||||||
_buildAlbumsList(provider),
|
|
||||||
_buildTracksList(provider),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.artists.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.person_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: provider.artists.length,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = provider.artists[index];
|
|
||||||
return _buildArtistTile(artist, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
radius: 24,
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
artist.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
// Load albums for this artist
|
|
||||||
await provider.loadAlbums(artistId: '${artist.provider}/${artist.itemId}');
|
|
||||||
if (context.mounted) {
|
|
||||||
_tabController.animateTo(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.albums.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.album_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GridView.builder(
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
childAspectRatio: 0.75,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
itemCount: provider.albums.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = provider.albums[index];
|
|
||||||
return _buildAlbumCard(album, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
image: imageUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: imageUrl == null
|
|
||||||
? Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
album.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
album.artistsString,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTracksList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.tracks.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.music_note_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No tracks found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: provider.tracks.length,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final track = provider.tracks[index];
|
|
||||||
return _buildTrackTile(track, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTrackTile(Track track, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = track.album != null
|
|
||||||
? provider.getImageUrl(track.album!, size: 128)
|
|
||||||
: null;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
image: imageUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
track.name,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
track.artistsString,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: track.duration != null
|
|
||||||
? Text(
|
|
||||||
_formatDuration(track.duration!),
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
// TODO: Play track
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
|
||||||
final minutes = duration.inMinutes;
|
|
||||||
final seconds = duration.inSeconds % 60;
|
|
||||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class LibraryTracksScreen extends StatelessWidget {
|
class LibraryTracksScreen extends StatelessWidget {
|
||||||
const LibraryTracksScreen({super.key});
|
const LibraryTracksScreen({super.key});
|
||||||
@@ -47,36 +48,7 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.tracks.isEmpty) {
|
if (provider.tracks.isEmpty) {
|
||||||
return Center(
|
return EmptyState.tracks(onRefresh: provider.loadLibrary);
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.music_note_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No tracks found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/auth/auth_strategy.dart';
|
import '../services/auth/auth_strategy.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/debug/debug_console.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -767,141 +767,16 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Debug toggle button
|
// Debug toggle button
|
||||||
TextButton.icon(
|
DebugToggleButton(
|
||||||
onPressed: () {
|
isVisible: _showDebug,
|
||||||
setState(() {
|
onToggle: () => setState(() => _showDebug = !_showDebug),
|
||||||
_showDebug = !_showDebug;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_showDebug ? Icons.bug_report : Icons.bug_report_outlined,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
label: Text(
|
|
||||||
_showDebug ? 'Hide Debug' : 'Show Debug',
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Debug panel
|
// Debug panel
|
||||||
if (_showDebug) ...[
|
if (_showDebug) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
DebugConsole(
|
||||||
padding: const EdgeInsets.all(12),
|
onClear: () => setState(() => _debugLogs.clear()),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withOpacity(0.8),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.terminal, color: Colors.green, size: 16),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Debug Console',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
final logs = DebugLogger().getAllLogs();
|
|
||||||
if (logs.isNotEmpty) {
|
|
||||||
Clipboard.setData(ClipboardData(text: logs));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Logs copied to clipboard'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('No logs to copy'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
minimumSize: const Size(50, 20),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.copy, size: 10, color: Colors.green),
|
|
||||||
label: const Text(
|
|
||||||
'Copy',
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.green),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
DebugLogger().clear();
|
|
||||||
setState(() {
|
|
||||||
_debugLogs.clear();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
minimumSize: const Size(40, 20),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Clear',
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.green),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.green, height: 8),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
reverse: true,
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
// Get all logs from DebugLogger singleton
|
|
||||||
final allLogs = DebugLogger().logs;
|
|
||||||
final recentLogs = allLogs.length > 50
|
|
||||||
? allLogs.sublist(allLogs.length - 50)
|
|
||||||
: allLogs;
|
|
||||||
|
|
||||||
return recentLogs.isEmpty
|
|
||||||
? const Text(
|
|
||||||
'No debug logs yet. Try detecting auth.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 10,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: recentLogs
|
|
||||||
.map((log) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
|
||||||
child: Text(
|
|
||||||
log,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 10,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../widgets/global_player_overlay.dart';
|
|||||||
import '../widgets/player_selector.dart';
|
import '../widgets/player_selector.dart';
|
||||||
import '../widgets/album_row.dart';
|
import '../widgets/album_row.dart';
|
||||||
import '../widgets/artist_row.dart';
|
import '../widgets/artist_row.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
|
|
||||||
@@ -74,7 +75,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
builder: (context, isConnected, child) {
|
builder: (context, isConnected, child) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
return !isConnected
|
return !isConnected
|
||||||
? _buildDisconnectedView(context, maProvider)
|
? DisconnectedState.full(
|
||||||
|
onSettings: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
@@ -87,68 +93,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(
|
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 80,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'Not Connected',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Connect to your Music Assistant server to start listening',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 32,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildConnectedView(
|
Widget _buildConnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../widgets/album_card.dart';
|
|||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
@@ -108,7 +110,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: const [PlayerSelector()],
|
actions: const [PlayerSelector()],
|
||||||
),
|
),
|
||||||
body: _buildDisconnectedView(context),
|
body: DisconnectedState.withSettingsAction(
|
||||||
|
onSettings: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,52 +161,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ ARTISTS TAB ============
|
// ============ ARTISTS TAB ============
|
||||||
Widget _buildArtistsTab(BuildContext context) {
|
Widget _buildArtistsTab(BuildContext context) {
|
||||||
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||||
@@ -214,10 +175,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
return _buildEmptyState(
|
return EmptyState.artists(
|
||||||
context,
|
|
||||||
icon: Icons.person_outline_rounded,
|
|
||||||
message: 'No artists found',
|
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,10 +274,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return _buildEmptyState(
|
return EmptyState.albums(
|
||||||
context,
|
|
||||||
icon: Icons.album_outlined,
|
|
||||||
message: 'No albums found',
|
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,12 +317,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_playlists.isEmpty) {
|
if (_playlists.isEmpty) {
|
||||||
return _buildEmptyState(
|
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
||||||
context,
|
|
||||||
icon: Icons.playlist_play_outlined,
|
|
||||||
message: 'No playlists found',
|
|
||||||
onRefresh: _loadPlaylists,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -443,37 +393,4 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ SHARED WIDGETS ============
|
|
||||||
Widget _buildEmptyState(
|
|
||||||
BuildContext context, {
|
|
||||||
required IconData icon,
|
|
||||||
required String message,
|
|
||||||
required VoidCallback onRefresh,
|
|
||||||
}) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 64, color: colorScheme.onSurface.withOpacity(0.54)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: onRefresh,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class QueueScreen extends StatefulWidget {
|
class QueueScreen extends StatefulWidget {
|
||||||
const QueueScreen({super.key});
|
const QueueScreen({super.key});
|
||||||
@@ -139,19 +140,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_queue == null || _queue!.items.isEmpty) {
|
if (_queue == null || _queue!.items.isEmpty) {
|
||||||
return Center(
|
return EmptyState.queue();
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Queue is empty',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to show only current and upcoming items (not history)
|
// Filter to show only current and upcoming items (not history)
|
||||||
@@ -159,18 +148,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
final upcomingItems = _queue!.items.sublist(currentIndex);
|
final upcomingItems = _queue!.items.sublist(currentIndex);
|
||||||
|
|
||||||
if (upcomingItems.isEmpty) {
|
if (upcomingItems.isEmpty) {
|
||||||
return Center(
|
return const EmptyState(
|
||||||
child: Column(
|
icon: Icons.queue_music,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
message: 'No upcoming tracks',
|
||||||
children: [
|
|
||||||
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No upcoming tracks',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +211,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
),
|
),
|
||||||
onDismissed: (direction) {
|
onDismissed: (direction) {
|
||||||
// TODO: Implement queue item removal via API
|
// TODO: Implement queue item removal via API
|
||||||
|
// Currently only removes locally from UI state, not from actual Music Assistant queue
|
||||||
|
// Need to investigate if Music Assistant API supports player_queues/remove_item or similar endpoint
|
||||||
|
// Requires: API method like removeQueueItem(queueId, queueItemId) that calls player_queues/remove_item
|
||||||
setState(() {
|
setState(() {
|
||||||
_queue!.items.removeAt(index);
|
_queue!.items.removeAt(index);
|
||||||
});
|
});
|
||||||
|
|||||||
+103
-69
@@ -7,6 +7,8 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
@@ -17,6 +19,41 @@ class SearchScreen extends StatefulWidget {
|
|||||||
State<SearchScreen> createState() => SearchScreenState();
|
State<SearchScreen> createState() => SearchScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper enum and class for ListView.builder item types
|
||||||
|
enum _ListItemType { header, artist, album, track, spacer }
|
||||||
|
|
||||||
|
class _ListItem {
|
||||||
|
final _ListItemType type;
|
||||||
|
final MediaItem? mediaItem;
|
||||||
|
final String? headerTitle;
|
||||||
|
final int? headerCount;
|
||||||
|
|
||||||
|
_ListItem.header(this.headerTitle, this.headerCount)
|
||||||
|
: type = _ListItemType.header,
|
||||||
|
mediaItem = null;
|
||||||
|
|
||||||
|
_ListItem.artist(this.mediaItem)
|
||||||
|
: type = _ListItemType.artist,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.album(this.mediaItem)
|
||||||
|
: type = _ListItemType.album,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.track(this.mediaItem)
|
||||||
|
: type = _ListItemType.track,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.spacer()
|
||||||
|
: type = _ListItemType.spacer,
|
||||||
|
mediaItem = null,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
}
|
||||||
|
|
||||||
class SearchScreenState extends State<SearchScreen> {
|
class SearchScreenState extends State<SearchScreen> {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
@@ -108,7 +145,6 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
@@ -135,47 +171,17 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {});
|
|
||||||
_onSearchChanged(value);
|
_onSearchChanged(value);
|
||||||
},
|
},
|
||||||
onSubmitted: _performSearch,
|
onSubmitted: _performSearch,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: !maProvider.isConnected
|
body: !maProvider.isConnected
|
||||||
? _buildDisconnectedView()
|
? DisconnectedState.simple()
|
||||||
: _buildSearchContent(),
|
: _buildSearchContent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView() {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSearchContent() {
|
Widget _buildSearchContent() {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -244,26 +250,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
||||||
|
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return Center(
|
return EmptyState.search();
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.search_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No results found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.5),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -293,30 +280,77 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
// Results
|
// Results
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final listItems = _buildListItems(artists, albums, tracks);
|
||||||
|
return ListView.builder(
|
||||||
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
||||||
children: [
|
itemCount: listItems.length,
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) ...[
|
itemBuilder: (context, index) {
|
||||||
if (_activeFilter == 'all') _buildSectionHeader('Artists', artists.length),
|
final item = listItems[index];
|
||||||
...artists.map((item) => _buildArtistTile(item as Artist)),
|
switch (item.type) {
|
||||||
const SizedBox(height: 24),
|
case _ListItemType.header:
|
||||||
],
|
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) ...[
|
case _ListItemType.artist:
|
||||||
if (_activeFilter == 'all') _buildSectionHeader('Albums', albums.length),
|
return _buildArtistTile(item.mediaItem! as Artist);
|
||||||
...albums.map((item) => _buildAlbumTile(item as Album)),
|
case _ListItemType.album:
|
||||||
const SizedBox(height: 24),
|
return _buildAlbumTile(item.mediaItem! as Album);
|
||||||
],
|
case _ListItemType.track:
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) ...[
|
return _buildTrackTile(item.mediaItem! as Track);
|
||||||
if (_activeFilter == 'all') _buildSectionHeader('Tracks', tracks.length),
|
case _ListItemType.spacer:
|
||||||
...tracks.map((item) => _buildTrackTile(item as Track)),
|
return const SizedBox(height: 24);
|
||||||
],
|
}
|
||||||
],
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<_ListItem> _buildListItems(
|
||||||
|
List<MediaItem> artists,
|
||||||
|
List<MediaItem> albums,
|
||||||
|
List<MediaItem> tracks,
|
||||||
|
) {
|
||||||
|
final items = <_ListItem>[];
|
||||||
|
|
||||||
|
// Add artists section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Artists', artists.length));
|
||||||
|
}
|
||||||
|
for (final artist in artists) {
|
||||||
|
items.add(_ListItem.artist(artist));
|
||||||
|
}
|
||||||
|
items.add(_ListItem.spacer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add albums section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Albums', albums.length));
|
||||||
|
}
|
||||||
|
for (final album in albums) {
|
||||||
|
items.add(_ListItem.album(album));
|
||||||
|
}
|
||||||
|
items.add(_ListItem.spacer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracks section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Tracks', tracks.length));
|
||||||
|
}
|
||||||
|
for (final track in tracks) {
|
||||||
|
items.add(_ListItem.track(track));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFilterChip(String label, String value) {
|
Widget _buildFilterChip(String label, String value) {
|
||||||
final isSelected = _activeFilter == value;
|
final isSelected = _activeFilter == value;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
|
/// Centralized cache service for all app data caching
|
||||||
|
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||||
|
class CacheService {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Home screen row caching
|
||||||
|
List<Album>? _cachedRecentAlbums;
|
||||||
|
List<Artist>? _cachedDiscoverArtists;
|
||||||
|
List<Album>? _cachedDiscoverAlbums;
|
||||||
|
DateTime? _recentAlbumsLastFetched;
|
||||||
|
DateTime? _discoverArtistsLastFetched;
|
||||||
|
DateTime? _discoverAlbumsLastFetched;
|
||||||
|
|
||||||
|
// Detail screen caching
|
||||||
|
final Map<String, List<Track>> _albumTracksCache = {};
|
||||||
|
final Map<String, DateTime> _albumTracksCacheTime = {};
|
||||||
|
final Map<String, List<Track>> _playlistTracksCache = {};
|
||||||
|
final Map<String, DateTime> _playlistTracksCacheTime = {};
|
||||||
|
final Map<String, List<Album>> _artistAlbumsCache = {};
|
||||||
|
final Map<String, DateTime> _artistAlbumsCacheTime = {};
|
||||||
|
|
||||||
|
// Search results caching
|
||||||
|
final Map<String, Map<String, List<MediaItem>>> _searchCache = {};
|
||||||
|
final Map<String, DateTime> _searchCacheTime = {};
|
||||||
|
|
||||||
|
// Player list caching
|
||||||
|
DateTime? _playersLastFetched;
|
||||||
|
|
||||||
|
// Player track cache (for smooth swipe transitions)
|
||||||
|
final Map<String, Track?> _playerTrackCache = {};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOME SCREEN ROW CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if recent albums cache is valid
|
||||||
|
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedRecentAlbums != null &&
|
||||||
|
_recentAlbumsLastFetched != null &&
|
||||||
|
now.difference(_recentAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached recent albums
|
||||||
|
List<Album>? getCachedRecentAlbums() => _cachedRecentAlbums;
|
||||||
|
|
||||||
|
/// Set cached recent albums
|
||||||
|
void setCachedRecentAlbums(List<Album> albums) {
|
||||||
|
_cachedRecentAlbums = albums;
|
||||||
|
_recentAlbumsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover artists cache is valid
|
||||||
|
bool isDiscoverArtistsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverArtists != null &&
|
||||||
|
_discoverArtistsLastFetched != null &&
|
||||||
|
now.difference(_discoverArtistsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover artists
|
||||||
|
List<Artist>? getCachedDiscoverArtists() => _cachedDiscoverArtists;
|
||||||
|
|
||||||
|
/// Set cached discover artists
|
||||||
|
void setCachedDiscoverArtists(List<Artist> artists) {
|
||||||
|
_cachedDiscoverArtists = artists;
|
||||||
|
_discoverArtistsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover albums cache is valid
|
||||||
|
bool isDiscoverAlbumsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverAlbums != null &&
|
||||||
|
_discoverAlbumsLastFetched != null &&
|
||||||
|
now.difference(_discoverAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover albums
|
||||||
|
List<Album>? getCachedDiscoverAlbums() => _cachedDiscoverAlbums;
|
||||||
|
|
||||||
|
/// Set cached discover albums
|
||||||
|
void setCachedDiscoverAlbums(List<Album> albums) {
|
||||||
|
_cachedDiscoverAlbums = albums;
|
||||||
|
_discoverAlbumsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
|
void invalidateHomeCache() {
|
||||||
|
_recentAlbumsLastFetched = null;
|
||||||
|
_discoverArtistsLastFetched = null;
|
||||||
|
_discoverAlbumsLastFetched = null;
|
||||||
|
_logger.log('🗑️ Home screen cache invalidated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DETAIL SCREEN CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if album tracks cache is valid
|
||||||
|
bool isAlbumTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _albumTracksCacheTime[cacheKey];
|
||||||
|
return _albumTracksCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached album tracks
|
||||||
|
List<Track>? getCachedAlbumTracks(String cacheKey) => _albumTracksCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached album tracks
|
||||||
|
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
||||||
|
_albumTracksCache[cacheKey] = tracks;
|
||||||
|
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate album tracks cache
|
||||||
|
void invalidateAlbumTracksCache(String albumId) {
|
||||||
|
_albumTracksCache.remove(albumId);
|
||||||
|
_albumTracksCacheTime.remove(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if playlist tracks cache is valid
|
||||||
|
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _playlistTracksCacheTime[cacheKey];
|
||||||
|
return _playlistTracksCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached playlist tracks
|
||||||
|
List<Track>? getCachedPlaylistTracks(String cacheKey) => _playlistTracksCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached playlist tracks
|
||||||
|
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
||||||
|
_playlistTracksCache[cacheKey] = tracks;
|
||||||
|
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate playlist tracks cache
|
||||||
|
void invalidatePlaylistTracksCache(String playlistId) {
|
||||||
|
_playlistTracksCache.remove(playlistId);
|
||||||
|
_playlistTracksCacheTime.remove(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if artist albums cache is valid
|
||||||
|
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _artistAlbumsCacheTime[cacheKey];
|
||||||
|
return _artistAlbumsCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached artist albums
|
||||||
|
List<Album>? getCachedArtistAlbums(String cacheKey) => _artistAlbumsCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached artist albums
|
||||||
|
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
||||||
|
_artistAlbumsCache[cacheKey] = albums;
|
||||||
|
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SEARCH CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if search cache is valid
|
||||||
|
bool isSearchCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _searchCacheTime[cacheKey];
|
||||||
|
return _searchCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached search results
|
||||||
|
Map<String, List<MediaItem>>? getCachedSearchResults(String cacheKey) => _searchCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached search results
|
||||||
|
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
||||||
|
_searchCache[cacheKey] = results;
|
||||||
|
_searchCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached search results for "$cacheKey"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if players cache is valid
|
||||||
|
bool isPlayersCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _playersLastFetched != null &&
|
||||||
|
now.difference(_playersLastFetched!) < Timings.playersCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update players cache timestamp
|
||||||
|
void updatePlayersLastFetched() {
|
||||||
|
_playersLastFetched = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
|
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
||||||
|
|
||||||
|
/// Set cached track for a player
|
||||||
|
void setCachedTrackForPlayer(String playerId, Track? track) {
|
||||||
|
_playerTrackCache[playerId] = track;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLEAR ALL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Clear all detail caches (call on disconnect/reconnect)
|
||||||
|
void clearAllDetailCaches() {
|
||||||
|
_albumTracksCache.clear();
|
||||||
|
_albumTracksCacheTime.clear();
|
||||||
|
_playlistTracksCache.clear();
|
||||||
|
_playlistTracksCacheTime.clear();
|
||||||
|
_artistAlbumsCache.clear();
|
||||||
|
_artistAlbumsCacheTime.clear();
|
||||||
|
_searchCache.clear();
|
||||||
|
_searchCacheTime.clear();
|
||||||
|
_playerTrackCache.clear();
|
||||||
|
_logger.log('🗑️ All detail caches cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all caches
|
||||||
|
void clearAll() {
|
||||||
|
invalidateHomeCache();
|
||||||
|
clearAllDetailCaches();
|
||||||
|
_playersLastFetched = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import '../constants/timings.dart' show LibraryConstants;
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import 'music_assistant_api.dart';
|
|
||||||
import 'debug_logger.dart';
|
|
||||||
import 'error_handler.dart';
|
|
||||||
|
|
||||||
/// Service responsible for library data management.
|
|
||||||
/// Handles loading and caching of artists, albums, tracks, and search.
|
|
||||||
class LibraryService {
|
|
||||||
final DebugLogger _logger = DebugLogger();
|
|
||||||
|
|
||||||
MusicAssistantAPI? _api;
|
|
||||||
|
|
||||||
List<Artist> _artists = [];
|
|
||||||
List<Album> _albums = [];
|
|
||||||
List<Track> _tracks = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
// Search state persistence
|
|
||||||
String _lastSearchQuery = '';
|
|
||||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
|
||||||
'artists': [],
|
|
||||||
'albums': [],
|
|
||||||
'tracks': [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
List<Artist> get artists => _artists;
|
|
||||||
List<Album> get albums => _albums;
|
|
||||||
List<Track> get tracks => _tracks;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
String? get error => _error;
|
|
||||||
String get lastSearchQuery => _lastSearchQuery;
|
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
|
||||||
|
|
||||||
/// Set the API instance (called when connection established)
|
|
||||||
void setApi(MusicAssistantAPI? api) {
|
|
||||||
_api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all library data (called on disconnect)
|
|
||||||
void clear() {
|
|
||||||
_artists = [];
|
|
||||||
_albums = [];
|
|
||||||
_tracks = [];
|
|
||||||
_error = null;
|
|
||||||
_isLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save search state for persistence across screen changes
|
|
||||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
|
||||||
_lastSearchQuery = query;
|
|
||||||
_lastSearchResults = results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear search state
|
|
||||||
void clearSearchState() {
|
|
||||||
_lastSearchQuery = '';
|
|
||||||
_lastSearchResults = {
|
|
||||||
'artists': [],
|
|
||||||
'albums': [],
|
|
||||||
'tracks': [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all library data (artists, albums, tracks) in parallel
|
|
||||||
/// Returns true if successful
|
|
||||||
Future<bool> loadLibrary() async {
|
|
||||||
if (_api == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
|
|
||||||
final results = await Future.wait([
|
|
||||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
|
||||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
|
||||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
|
||||||
]);
|
|
||||||
|
|
||||||
_artists = results[0] as List<Artist>;
|
|
||||||
_albums = results[1] as List<Album>;
|
|
||||||
_tracks = results[2] as List<Track>;
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load artists with optional pagination and search
|
|
||||||
Future<bool> loadArtists({int? limit, int? offset, String? search}) async {
|
|
||||||
if (_api == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
|
|
||||||
_artists = await _api!.getArtists(
|
|
||||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
|
||||||
offset: offset,
|
|
||||||
search: search,
|
|
||||||
);
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load albums with optional pagination, search, and artist filter
|
|
||||||
Future<bool> loadAlbums({
|
|
||||||
int? limit,
|
|
||||||
int? offset,
|
|
||||||
String? search,
|
|
||||||
String? artistId,
|
|
||||||
}) async {
|
|
||||||
if (_api == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
|
|
||||||
_albums = await _api!.getAlbums(
|
|
||||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
|
||||||
offset: offset,
|
|
||||||
search: search,
|
|
||||||
artistId: artistId,
|
|
||||||
);
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
|
||||||
_error = errorInfo.userMessage;
|
|
||||||
_isLoading = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get tracks for a specific album
|
|
||||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
|
||||||
if (_api == null) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.getAlbumTracks(provider, itemId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Get album tracks', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search for artists, albums, and tracks
|
|
||||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
|
||||||
if (_api == null) {
|
|
||||||
return {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Search', e);
|
|
||||||
return {'artists': [], 'albums': [], 'tracks': []};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get recent albums
|
|
||||||
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
|
||||||
if (_api == null) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.getRecentAlbums(limit: limit);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Get recent albums', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get random albums for discovery
|
|
||||||
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
|
|
||||||
if (_api == null) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.getRandomAlbums(limit: limit);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Get random albums', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle favorite status for a media item
|
|
||||||
Future<void> toggleFavorite(String itemId, String mediaType, bool favorite) async {
|
|
||||||
if (_api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _api!.setFavorite(itemId, mediaType, favorite);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Toggle favorite', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get image URL for a media item
|
|
||||||
String getImageUrl(dynamic item, {int? size}) {
|
|
||||||
return _api?.getImageUrl(item, size: size) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get stream URL for a track
|
|
||||||
String getStreamUrl(String provider, String itemId, {String? uri, List<ProviderMapping>? providerMappings}) {
|
|
||||||
return _api?.getStreamUrl(provider, itemId, uri: uri, providerMappings: providerMappings) ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,9 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
Completer<void>? _connectionCompleter;
|
Completer<void>? _connectionCompleter;
|
||||||
|
|
||||||
|
// Heartbeat timer to keep WebSocket connection alive
|
||||||
|
Timer? _heartbeatTimer;
|
||||||
|
|
||||||
// Cached custom port setting
|
// Cached custom port setting
|
||||||
int? _cachedCustomPort;
|
int? _cachedCustomPort;
|
||||||
|
|
||||||
@@ -195,7 +198,9 @@ class MusicAssistantAPI {
|
|||||||
onDone: () {
|
onDone: () {
|
||||||
_logger.log('WebSocket connection closed');
|
_logger.log('WebSocket connection closed');
|
||||||
_updateConnectionState(MAConnectionState.disconnected);
|
_updateConnectionState(MAConnectionState.disconnected);
|
||||||
_connectionCompleter?.completeError(Exception('Connection closed'));
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||||
|
_connectionCompleter!.completeError(Exception('Connection closed'));
|
||||||
|
}
|
||||||
_reconnect();
|
_reconnect();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -209,12 +214,16 @@ class MusicAssistantAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_logger.log('Connection: Connected to server');
|
_logger.log('Connection: Connected to server');
|
||||||
_connectionInProgress?.complete();
|
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||||
|
_connectionInProgress!.complete();
|
||||||
|
}
|
||||||
_connectionInProgress = null;
|
_connectionInProgress = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Connection: Failed - $e');
|
_logger.log('Connection: Failed - $e');
|
||||||
_updateConnectionState(MAConnectionState.error);
|
_updateConnectionState(MAConnectionState.error);
|
||||||
_connectionInProgress?.completeError(e);
|
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||||
|
_connectionInProgress!.completeError(e);
|
||||||
|
}
|
||||||
_connectionInProgress = null;
|
_connectionInProgress = null;
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -247,7 +256,10 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_updateConnectionState(MAConnectionState.connected);
|
_updateConnectionState(MAConnectionState.connected);
|
||||||
_connectionCompleter?.complete();
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||||
|
_connectionCompleter!.complete();
|
||||||
|
}
|
||||||
|
_startHeartbeat();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1667,6 +1679,11 @@ class MusicAssistantAPI {
|
|||||||
final imagePath = selectedImage['path'] as String?;
|
final imagePath = selectedImage['path'] as String?;
|
||||||
if (imagePath == null) return null;
|
if (imagePath == null) return null;
|
||||||
|
|
||||||
|
// If path is already a full URL (from player_updated event), return it directly
|
||||||
|
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
// Always use imageproxy endpoint to ensure images route through our server
|
// Always use imageproxy endpoint to ensure images route through our server
|
||||||
// This fixes images not loading when connecting via external domain
|
// This fixes images not loading when connecting via external domain
|
||||||
// (direct URLs may contain internal IPs not reachable from outside the network)
|
// (direct URLs may contain internal IPs not reachable from outside the network)
|
||||||
@@ -1914,6 +1931,45 @@ class MusicAssistantAPI {
|
|||||||
_connectionStateController.add(state);
|
_connectionStateController.add(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start heartbeat timer to keep WebSocket connection alive
|
||||||
|
void _startHeartbeat() {
|
||||||
|
_stopHeartbeat();
|
||||||
|
_heartbeatTimer = Timer.periodic(Timings.heartbeatInterval, (_) {
|
||||||
|
_sendHeartbeat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop heartbeat timer
|
||||||
|
void _stopHeartbeat() {
|
||||||
|
_heartbeatTimer?.cancel();
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a ping message to keep the connection alive
|
||||||
|
Future<void> _sendHeartbeat() async {
|
||||||
|
if (_currentState != MAConnectionState.connected &&
|
||||||
|
_currentState != MAConnectionState.authenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send a lightweight command to check connection is alive
|
||||||
|
// Using 'info' as it's a valid MA command
|
||||||
|
await _sendCommand('info').timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
_logger.log('Heartbeat timeout - connection may be stale');
|
||||||
|
return <String, dynamic>{};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Heartbeat failed: $e');
|
||||||
|
// Connection may be dead, trigger reconnect
|
||||||
|
_updateConnectionState(MAConnectionState.error);
|
||||||
|
_reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reconnect() async {
|
Future<void> _reconnect() async {
|
||||||
await Future.delayed(Timings.reconnectDelay);
|
await Future.delayed(Timings.reconnectDelay);
|
||||||
if (_currentState != MAConnectionState.connected) {
|
if (_currentState != MAConnectionState.connected) {
|
||||||
@@ -1926,6 +1982,7 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
|
_stopHeartbeat();
|
||||||
_updateConnectionState(MAConnectionState.disconnected);
|
_updateConnectionState(MAConnectionState.disconnected);
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
_channel = null;
|
_channel = null;
|
||||||
@@ -1933,6 +1990,7 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stopHeartbeat();
|
||||||
disconnect();
|
disconnect();
|
||||||
_connectionStateController.close();
|
_connectionStateController.close();
|
||||||
for (final stream in _eventStreams.values) {
|
for (final stream in _eventStreams.values) {
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import '../constants/timings.dart' show Timings;
|
|
||||||
import '../models/player.dart';
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import 'music_assistant_api.dart';
|
|
||||||
import 'settings_service.dart';
|
|
||||||
import 'debug_logger.dart';
|
|
||||||
import 'error_handler.dart';
|
|
||||||
|
|
||||||
/// Callback type for notifying state changes
|
|
||||||
typedef StateChangeCallback = void Function();
|
|
||||||
|
|
||||||
/// Service responsible for player state management.
|
|
||||||
/// Handles player selection, playback controls, and state polling.
|
|
||||||
class PlayerStateService {
|
|
||||||
final DebugLogger _logger = DebugLogger();
|
|
||||||
final StateChangeCallback? onStateChanged;
|
|
||||||
|
|
||||||
MusicAssistantAPI? _api;
|
|
||||||
|
|
||||||
// Player selection
|
|
||||||
Player? _selectedPlayer;
|
|
||||||
List<Player> _availablePlayers = [];
|
|
||||||
Track? _currentTrack;
|
|
||||||
Timer? _playerStateTimer;
|
|
||||||
|
|
||||||
// Player list caching
|
|
||||||
DateTime? _playersLastFetched;
|
|
||||||
|
|
||||||
PlayerStateService({this.onStateChanged});
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
Player? get selectedPlayer => _selectedPlayer;
|
|
||||||
List<Player> get availablePlayers => _availablePlayers;
|
|
||||||
Track? get currentTrack => _currentTrack;
|
|
||||||
MusicAssistantAPI? get api => _api;
|
|
||||||
|
|
||||||
/// Set the API instance (called when connection established)
|
|
||||||
void setApi(MusicAssistantAPI? api) {
|
|
||||||
_api = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all player state (called on disconnect)
|
|
||||||
void clear() {
|
|
||||||
_playerStateTimer?.cancel();
|
|
||||||
_playerStateTimer = null;
|
|
||||||
_selectedPlayer = null;
|
|
||||||
_availablePlayers = [];
|
|
||||||
_currentTrack = null;
|
|
||||||
_playersLastFetched = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all players from the API
|
|
||||||
Future<List<Player>> getPlayers() async {
|
|
||||||
if (_api == null) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _api!.getPlayers();
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Get players', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load available players and auto-select one
|
|
||||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
|
||||||
try {
|
|
||||||
// Check cache first
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (!forceRefresh &&
|
|
||||||
_playersLastFetched != null &&
|
|
||||||
_availablePlayers.isNotEmpty &&
|
|
||||||
now.difference(_playersLastFetched!) < Timings.playersCacheDuration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final allPlayers = await getPlayers();
|
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
||||||
|
|
||||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
|
||||||
|
|
||||||
// Filter out unavailable players and legacy ghosts
|
|
||||||
int filteredCount = 0;
|
|
||||||
|
|
||||||
_availablePlayers = allPlayers.where((player) {
|
|
||||||
final nameLower = player.name.toLowerCase();
|
|
||||||
|
|
||||||
// Filter out legacy "Music Assistant Mobile" ghosts
|
|
||||||
if (nameLower.contains('music assistant mobile')) {
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out unavailable players (ghost players from old installations)
|
|
||||||
// Exception: Keep our own player even if temporarily unavailable
|
|
||||||
if (!player.available) {
|
|
||||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
filteredCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
_playersLastFetched = DateTime.now();
|
|
||||||
|
|
||||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
|
||||||
|
|
||||||
if (_availablePlayers.isNotEmpty) {
|
|
||||||
Player? playerToSelect;
|
|
||||||
|
|
||||||
// Keep current selection if still valid
|
|
||||||
if (_selectedPlayer != null) {
|
|
||||||
final stillAvailable = _availablePlayers.any(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
|
||||||
);
|
|
||||||
if (stillAvailable) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only auto-select if NO player is currently selected
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
// Priority 1: Local player (this device)
|
|
||||||
if (builtinPlayerId != null) {
|
|
||||||
try {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
|
||||||
);
|
|
||||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
|
||||||
} catch (e) {
|
|
||||||
// Local player not found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: A currently playing player
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
try {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.state == 'playing' && p.available,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// No playing player
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: First available player
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.available,
|
|
||||||
orElse: () => _availablePlayers.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectPlayer(playerToSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
onStateChanged?.call();
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Load players', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select a player and start polling its state
|
|
||||||
void selectPlayer(Player player) {
|
|
||||||
_selectedPlayer = player;
|
|
||||||
_startPlayerPolling();
|
|
||||||
onStateChanged?.call();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start polling player state at configured interval
|
|
||||||
void _startPlayerPolling() {
|
|
||||||
_playerStateTimer?.cancel();
|
|
||||||
|
|
||||||
if (_selectedPlayer == null) return;
|
|
||||||
|
|
||||||
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
|
||||||
try {
|
|
||||||
await updatePlayerState();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error updating player state (will retry): $e');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also update immediately
|
|
||||||
updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the selected player's state from the server
|
|
||||||
Future<void> updatePlayerState() async {
|
|
||||||
if (_selectedPlayer == null || _api == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final updatedPlayer = await _api!.getPlayer(_selectedPlayer!.playerId);
|
|
||||||
if (updatedPlayer != null) {
|
|
||||||
_selectedPlayer = updatedPlayer;
|
|
||||||
|
|
||||||
// Get current track info if playing
|
|
||||||
if (updatedPlayer.currentMedia != null) {
|
|
||||||
_currentTrack = updatedPlayer.currentMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStateChanged?.call();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error updating player state: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the list of available players
|
|
||||||
Future<void> refreshPlayers() async {
|
|
||||||
final previousState = _selectedPlayer?.state;
|
|
||||||
|
|
||||||
await loadAndSelectPlayers(forceRefresh: true);
|
|
||||||
|
|
||||||
// If selected player changed state, notify listeners
|
|
||||||
if (_selectedPlayer?.state != previousState) {
|
|
||||||
onStateChanged?.call();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PLAYBACK CONTROLS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
Future<void> pausePlayer(String playerId) async {
|
|
||||||
try {
|
|
||||||
await _api?.pausePlayer(playerId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Pause player', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> resumePlayer(String playerId) async {
|
|
||||||
try {
|
|
||||||
await _api?.playPlayer(playerId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Resume player', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stopPlayer(String playerId) async {
|
|
||||||
try {
|
|
||||||
await _api?.stopPlayer(playerId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Stop player', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> nextTrack(String playerId) async {
|
|
||||||
try {
|
|
||||||
await _api?.nextTrack(playerId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Next track', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> previousTrack(String playerId) async {
|
|
||||||
try {
|
|
||||||
await _api?.previousTrack(playerId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Previous track', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> togglePower(String playerId) async {
|
|
||||||
try {
|
|
||||||
final player = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == playerId,
|
|
||||||
orElse: () => throw Exception('Player not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (player.powered) {
|
|
||||||
await _api?.powerOffPlayer(playerId);
|
|
||||||
} else {
|
|
||||||
await _api?.powerOnPlayer(playerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshPlayers();
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('ERROR in togglePower: $e');
|
|
||||||
ErrorHandler.logError('Toggle power', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
|
||||||
try {
|
|
||||||
await _api?.setVolume(playerId, volumeLevel);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set volume', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setMute(String playerId, bool muted) async {
|
|
||||||
try {
|
|
||||||
await _api?.setMute(playerId, muted);
|
|
||||||
await refreshPlayers();
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set mute', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> seek(String playerId, int position) async {
|
|
||||||
try {
|
|
||||||
await _api?.seek(playerId, position);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Seek', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
|
||||||
try {
|
|
||||||
await _api?.toggleShuffle(queueId);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Toggle shuffle', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setRepeatMode(String queueId, String mode) async {
|
|
||||||
try {
|
|
||||||
await _api?.setRepeatMode(queueId, mode);
|
|
||||||
} catch (e) {
|
|
||||||
ErrorHandler.logError('Set repeat mode', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cycle through repeat modes: off -> all -> one -> off
|
|
||||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
|
||||||
String nextMode;
|
|
||||||
switch (currentMode) {
|
|
||||||
case 'off':
|
|
||||||
case null:
|
|
||||||
nextMode = 'all';
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
nextMode = 'one';
|
|
||||||
break;
|
|
||||||
case 'one':
|
|
||||||
nextMode = 'off';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nextMode = 'off';
|
|
||||||
}
|
|
||||||
await setRepeatMode(queueId, nextMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONVENIENCE METHODS (for selected player)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
Future<void> playPauseSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null) return;
|
|
||||||
|
|
||||||
if (_selectedPlayer!.state == 'playing') {
|
|
||||||
await pausePlayer(_selectedPlayer!.playerId);
|
|
||||||
} else {
|
|
||||||
await resumePlayer(_selectedPlayer!.playerId);
|
|
||||||
}
|
|
||||||
await Future.delayed(Timings.trackChangeDelay);
|
|
||||||
await updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> nextTrackSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null) return;
|
|
||||||
await nextTrack(_selectedPlayer!.playerId);
|
|
||||||
await Future.delayed(Timings.trackChangeDelay);
|
|
||||||
await updatePlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> previousTrackSelectedPlayer() async {
|
|
||||||
if (_selectedPlayer == null) return;
|
|
||||||
await previousTrack(_selectedPlayer!.playerId);
|
|
||||||
await Future.delayed(Timings.trackChangeDelay);
|
|
||||||
await updatePlayerState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ class SettingsService {
|
|||||||
static const String _keyEnableLocalPlayback = 'enable_local_playback';
|
static const String _keyEnableLocalPlayback = 'enable_local_playback';
|
||||||
static const String _keyLocalPlayerName = 'local_player_name';
|
static const String _keyLocalPlayerName = 'local_player_name';
|
||||||
static const String _keyOwnerName = 'owner_name';
|
static const String _keyOwnerName = 'owner_name';
|
||||||
|
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -283,6 +284,21 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyOwnerName, name.trim());
|
await prefs.setString(_keyOwnerName, name.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last selected player ID - persists user's player selection across sessions
|
||||||
|
static Future<String?> getLastSelectedPlayerId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLastSelectedPlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLastSelectedPlayerId(String? playerId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (playerId == null || playerId.isEmpty) {
|
||||||
|
await prefs.remove(_keyLastSelectedPlayerId);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyLastSelectedPlayerId, playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Design tokens for consistent spacing, dimensions, and styling throughout the app.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// Padding(padding: Spacing.paddingAll16)
|
||||||
|
/// SizedBox(height: Spacing.md)
|
||||||
|
/// BorderRadius.circular(Radii.md)
|
||||||
|
/// ```
|
||||||
|
class Spacing {
|
||||||
|
Spacing._();
|
||||||
|
|
||||||
|
// Base spacing scale (follows 4px grid)
|
||||||
|
static const double xxs = 2.0;
|
||||||
|
static const double xs = 4.0;
|
||||||
|
static const double sm = 8.0;
|
||||||
|
static const double md = 12.0;
|
||||||
|
static const double lg = 16.0;
|
||||||
|
static const double xl = 24.0;
|
||||||
|
static const double xxl = 32.0;
|
||||||
|
static const double xxxl = 48.0;
|
||||||
|
|
||||||
|
// Pre-built EdgeInsets for common padding patterns
|
||||||
|
static const EdgeInsets paddingAll4 = EdgeInsets.all(xs);
|
||||||
|
static const EdgeInsets paddingAll8 = EdgeInsets.all(sm);
|
||||||
|
static const EdgeInsets paddingAll12 = EdgeInsets.all(md);
|
||||||
|
static const EdgeInsets paddingAll16 = EdgeInsets.all(lg);
|
||||||
|
static const EdgeInsets paddingAll24 = EdgeInsets.all(xl);
|
||||||
|
static const EdgeInsets paddingAll32 = EdgeInsets.all(xxl);
|
||||||
|
|
||||||
|
// Horizontal padding
|
||||||
|
static const EdgeInsets paddingH8 = EdgeInsets.symmetric(horizontal: sm);
|
||||||
|
static const EdgeInsets paddingH12 = EdgeInsets.symmetric(horizontal: md);
|
||||||
|
static const EdgeInsets paddingH16 = EdgeInsets.symmetric(horizontal: lg);
|
||||||
|
static const EdgeInsets paddingH24 = EdgeInsets.symmetric(horizontal: xl);
|
||||||
|
|
||||||
|
// Vertical padding
|
||||||
|
static const EdgeInsets paddingV8 = EdgeInsets.symmetric(vertical: sm);
|
||||||
|
static const EdgeInsets paddingV12 = EdgeInsets.symmetric(vertical: md);
|
||||||
|
static const EdgeInsets paddingV16 = EdgeInsets.symmetric(vertical: lg);
|
||||||
|
static const EdgeInsets paddingV24 = EdgeInsets.symmetric(vertical: xl);
|
||||||
|
|
||||||
|
// Pre-built SizedBox widgets for gaps
|
||||||
|
static const SizedBox vGap4 = SizedBox(height: xs);
|
||||||
|
static const SizedBox vGap8 = SizedBox(height: sm);
|
||||||
|
static const SizedBox vGap12 = SizedBox(height: md);
|
||||||
|
static const SizedBox vGap16 = SizedBox(height: lg);
|
||||||
|
static const SizedBox vGap24 = SizedBox(height: xl);
|
||||||
|
static const SizedBox vGap32 = SizedBox(height: xxl);
|
||||||
|
|
||||||
|
static const SizedBox hGap4 = SizedBox(width: xs);
|
||||||
|
static const SizedBox hGap8 = SizedBox(width: sm);
|
||||||
|
static const SizedBox hGap12 = SizedBox(width: md);
|
||||||
|
static const SizedBox hGap16 = SizedBox(width: lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border radius tokens
|
||||||
|
class Radii {
|
||||||
|
Radii._();
|
||||||
|
|
||||||
|
static const double xs = 2.0;
|
||||||
|
static const double sm = 4.0;
|
||||||
|
static const double md = 8.0;
|
||||||
|
static const double lg = 12.0;
|
||||||
|
static const double xl = 16.0;
|
||||||
|
static const double xxl = 24.0;
|
||||||
|
static const double pill = 999.0;
|
||||||
|
|
||||||
|
// Pre-built BorderRadius for common patterns
|
||||||
|
static final BorderRadius borderRadiusSm = BorderRadius.circular(sm);
|
||||||
|
static final BorderRadius borderRadiusMd = BorderRadius.circular(md);
|
||||||
|
static final BorderRadius borderRadiusLg = BorderRadius.circular(lg);
|
||||||
|
static final BorderRadius borderRadiusXl = BorderRadius.circular(xl);
|
||||||
|
static final BorderRadius borderRadiusXxl = BorderRadius.circular(xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon size tokens
|
||||||
|
class IconSizes {
|
||||||
|
IconSizes._();
|
||||||
|
|
||||||
|
static const double xs = 16.0;
|
||||||
|
static const double sm = 20.0;
|
||||||
|
static const double md = 24.0;
|
||||||
|
static const double lg = 32.0;
|
||||||
|
static const double xl = 48.0;
|
||||||
|
static const double xxl = 64.0;
|
||||||
|
static const double xxxl = 80.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common dimension tokens
|
||||||
|
class Dimensions {
|
||||||
|
Dimensions._();
|
||||||
|
|
||||||
|
// Album art / thumbnail sizes
|
||||||
|
static const double thumbnailSm = 48.0;
|
||||||
|
static const double thumbnailMd = 80.0;
|
||||||
|
static const double thumbnailLg = 128.0;
|
||||||
|
static const double thumbnailXl = 200.0;
|
||||||
|
|
||||||
|
// Avatar sizes
|
||||||
|
static const double avatarSm = 32.0;
|
||||||
|
static const double avatarMd = 48.0;
|
||||||
|
static const double avatarLg = 64.0;
|
||||||
|
|
||||||
|
// List tile heights
|
||||||
|
static const double listTileHeight = 56.0;
|
||||||
|
static const double listTileHeightDense = 48.0;
|
||||||
|
|
||||||
|
// Mini player height
|
||||||
|
static const double miniPlayerHeight = 64.0;
|
||||||
|
|
||||||
|
// App bar heights
|
||||||
|
static const double appBarHeight = 56.0;
|
||||||
|
static const double expandedAppBarHeight = 200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration tokens for animations
|
||||||
|
class Durations {
|
||||||
|
Durations._();
|
||||||
|
|
||||||
|
static const Duration instant = Duration(milliseconds: 100);
|
||||||
|
static const Duration fast = Duration(milliseconds: 200);
|
||||||
|
static const Duration normal = Duration(milliseconds: 300);
|
||||||
|
static const Duration slow = Duration(milliseconds: 500);
|
||||||
|
}
|
||||||
@@ -56,10 +56,21 @@ class PaletteHelper {
|
|||||||
const Color(0xFF604CEC);
|
const Color(0xFF604CEC);
|
||||||
|
|
||||||
// Ensure primary has enough saturation to be visually distinct
|
// Ensure primary has enough saturation to be visually distinct
|
||||||
|
// AND enough lightness for white text to be readable on it
|
||||||
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
||||||
final Color adjustedPrimary = hslPrimary.saturation < 0.3
|
Color adjustedPrimary = primary;
|
||||||
? hslPrimary.withSaturation(0.5).toColor()
|
|
||||||
: primary;
|
// Adjust saturation if too low
|
||||||
|
if (hslPrimary.saturation < 0.3) {
|
||||||
|
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum lightness for contrast with white text
|
||||||
|
// If primary is too dark (luminance < 0.15), lighten it
|
||||||
|
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||||
|
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||||
|
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
// Dark mode: use dark muted colors for background
|
// Dark mode: use dark muted colors for background
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A reusable widget for displaying disconnected/offline state.
|
||||||
|
class DisconnectedState extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final IconData? actionIcon;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const DisconnectedState({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.actionIcon,
|
||||||
|
this.iconSize = 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Simple disconnected state without action button
|
||||||
|
factory DisconnectedState.simple() => const DisconnectedState(
|
||||||
|
title: 'Not connected to Music Assistant',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Disconnected state with settings button for navigation
|
||||||
|
factory DisconnectedState.withSettingsAction({
|
||||||
|
required VoidCallback onSettings,
|
||||||
|
}) =>
|
||||||
|
DisconnectedState(
|
||||||
|
title: 'Not connected to Music Assistant',
|
||||||
|
actionLabel: 'Configure Server',
|
||||||
|
actionIcon: Icons.settings_rounded,
|
||||||
|
onAction: onSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Full disconnected state with title, subtitle, and action (for home screen)
|
||||||
|
factory DisconnectedState.full({
|
||||||
|
required VoidCallback onSettings,
|
||||||
|
}) =>
|
||||||
|
DisconnectedState(
|
||||||
|
title: 'Not Connected',
|
||||||
|
subtitle: 'Connect to your Music Assistant server to start listening',
|
||||||
|
actionLabel: 'Configure Server',
|
||||||
|
actionIcon: Icons.settings_rounded,
|
||||||
|
onAction: onSettings,
|
||||||
|
iconSize: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingAll32,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_rounded,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
SizedBox(height: iconSize > IconSizes.xxl ? Spacing.xl : Spacing.lg),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(subtitle != null ? 1.0 : 0.7),
|
||||||
|
fontSize: subtitle != null ? 24 : 16,
|
||||||
|
fontWeight: subtitle != null ? FontWeight.w300 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
Spacing.vGap12,
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
SizedBox(height: subtitle != null ? Spacing.xxl : Spacing.xl),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: actionIcon != null ? Icon(actionIcon) : const SizedBox.shrink(),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: subtitle != null ? Spacing.xxl : Spacing.xl,
|
||||||
|
vertical: subtitle != null ? Spacing.lg : Spacing.md,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A reusable empty state widget for displaying when no data is available.
|
||||||
|
///
|
||||||
|
/// Shows an icon, message, and optional action button.
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String message;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const EmptyState({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.message,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.iconSize = 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates an empty state for artists
|
||||||
|
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.person_outline_rounded,
|
||||||
|
message: 'No artists found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for albums
|
||||||
|
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.album_outlined,
|
||||||
|
message: 'No albums found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for tracks
|
||||||
|
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.music_note_outlined,
|
||||||
|
message: 'No tracks found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for playlists
|
||||||
|
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.playlist_play_rounded,
|
||||||
|
message: 'No playlists found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for queue
|
||||||
|
factory EmptyState.queue() => const EmptyState(
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
message: 'Queue is empty',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for search results
|
||||||
|
factory EmptyState.search() => const EmptyState(
|
||||||
|
icon: Icons.search_off_rounded,
|
||||||
|
message: 'No results found',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingAll24,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.38),
|
||||||
|
),
|
||||||
|
Spacing.vGap16,
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
Spacing.vGap24,
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../services/debug_logger.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A collapsible debug console for viewing application logs
|
||||||
|
class DebugConsole extends StatelessWidget {
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final int maxLogs;
|
||||||
|
|
||||||
|
const DebugConsole({
|
||||||
|
super.key,
|
||||||
|
required this.onClear,
|
||||||
|
this.maxLogs = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(Radii.md),
|
||||||
|
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const Divider(color: Colors.green, height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildLogsList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.terminal, color: Colors.green, size: IconSizes.xs),
|
||||||
|
Spacing.hGap8,
|
||||||
|
const Text(
|
||||||
|
'Debug Console',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _copyLogs(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size(50, 20),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.copy, size: 10, color: Colors.green),
|
||||||
|
label: const Text(
|
||||||
|
'Copy',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
DebugLogger().clear();
|
||||||
|
onClear();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size(40, 20),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Clear',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyLogs(BuildContext context) {
|
||||||
|
final logs = DebugLogger().getAllLogs();
|
||||||
|
if (logs.isNotEmpty) {
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Logs copied to clipboard'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('No logs to copy'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogsList() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
reverse: true,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final allLogs = DebugLogger().logs;
|
||||||
|
final recentLogs = allLogs.length > maxLogs
|
||||||
|
? allLogs.sublist(allLogs.length - maxLogs)
|
||||||
|
: allLogs;
|
||||||
|
|
||||||
|
if (recentLogs.isEmpty) {
|
||||||
|
return const Text(
|
||||||
|
'No debug logs yet. Try detecting auth.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: recentLogs
|
||||||
|
.map((log) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: Spacing.xxs),
|
||||||
|
child: Text(
|
||||||
|
log,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toggle button for showing/hiding the debug console
|
||||||
|
class DebugToggleButton extends StatelessWidget {
|
||||||
|
final bool isVisible;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
const DebugToggleButton({
|
||||||
|
super.key,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
isVisible ? Icons.bug_report : Icons.bug_report_outlined,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
isVisible ? 'Hide Debug' : 'Show Debug',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
-184
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
@@ -12,6 +13,7 @@ import '../theme/theme_provider.dart';
|
|||||||
import 'animated_icon_button.dart';
|
import 'animated_icon_button.dart';
|
||||||
import 'global_player_overlay.dart';
|
import 'global_player_overlay.dart';
|
||||||
import 'volume_control.dart';
|
import 'volume_control.dart';
|
||||||
|
import 'player/player_widgets.dart';
|
||||||
|
|
||||||
/// A unified player widget that seamlessly expands from mini to full-screen.
|
/// A unified player widget that seamlessly expands from mini to full-screen.
|
||||||
///
|
///
|
||||||
@@ -52,6 +54,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Progress timer for elapsed time updates
|
// Progress timer for elapsed time updates
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
double? _seekPosition;
|
double? _seekPosition;
|
||||||
|
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
||||||
|
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions
|
||||||
static const double _collapsedHeight = 64.0;
|
static const double _collapsedHeight = 64.0;
|
||||||
@@ -64,6 +68,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Track horizontal drag start position
|
// Track horizontal drag start position
|
||||||
double? _horizontalDragStartX;
|
double? _horizontalDragStartX;
|
||||||
|
|
||||||
|
// Slide animation for device switching
|
||||||
|
late AnimationController _slideController;
|
||||||
|
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
|
||||||
|
bool _isSliding = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -91,6 +100,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Slide animation for device switching
|
||||||
|
_slideController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 75),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
_controller.addStatusListener((status) {
|
_controller.addStatusListener((status) {
|
||||||
if (status == AnimationStatus.forward) {
|
if (status == AnimationStatus.forward) {
|
||||||
_loadQueue();
|
_loadQueue();
|
||||||
@@ -133,8 +148,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_queuePanelController.dispose();
|
_queuePanelController.dispose();
|
||||||
|
_slideController.dispose();
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_queueRefreshTimer?.cancel();
|
_queueRefreshTimer?.cancel();
|
||||||
|
_progressNotifier.dispose();
|
||||||
|
_seekPositionNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +184,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
void _startProgressTimer() {
|
void _startProgressTimer() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
// Use local player report interval for progress updates (1 second)
|
// Use local player report interval for progress updates (1 second)
|
||||||
|
// Update only the progress notifier to avoid rebuilding the entire widget tree
|
||||||
_progressTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) {
|
_progressTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) {
|
||||||
if (mounted && isExpanded) {
|
if (mounted && isExpanded) {
|
||||||
setState(() {});
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
if (selectedPlayer != null) {
|
||||||
|
_progressNotifier.value = selectedPlayer.currentElapsedTime.toInt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -252,6 +275,103 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
||||||
|
|
||||||
|
/// Get available players sorted alphabetically (consistent with device selector)
|
||||||
|
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
||||||
|
// Use the provider's already-sorted list, filter for available only
|
||||||
|
return maProvider.availablePlayers.where((p) => p.available).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle to the next available player (for swipe gesture)
|
||||||
|
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
|
||||||
|
final players = _getAvailablePlayersSorted(maProvider);
|
||||||
|
if (players.length <= 1) return;
|
||||||
|
|
||||||
|
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
|
||||||
|
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
|
||||||
|
|
||||||
|
// If current player not found in list, start from beginning
|
||||||
|
if (currentIndex == -1) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
maProvider.selectPlayer(players[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextIndex;
|
||||||
|
if (reverse) {
|
||||||
|
nextIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||||
|
} else {
|
||||||
|
nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haptic feedback on device switch
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// Animate slide transition
|
||||||
|
_animateSlide(reverse ? 1 : -1, () {
|
||||||
|
maProvider.selectPlayer(players[nextIndex]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate the slide transition when switching devices
|
||||||
|
/// direction: -1 = swipe left (content exits left, new enters from right)
|
||||||
|
/// 1 = swipe right (content exits right, new enters from left)
|
||||||
|
void _animateSlide(int direction, VoidCallback onComplete) {
|
||||||
|
if (_isSliding) return;
|
||||||
|
_isSliding = true;
|
||||||
|
|
||||||
|
final slideOutTarget = direction.toDouble(); // Where current content goes
|
||||||
|
final slideInStart = -direction.toDouble(); // Where new content comes from
|
||||||
|
|
||||||
|
// Use easing curves for smoother feel
|
||||||
|
const outCurve = Curves.easeInCubic; // Accelerate out
|
||||||
|
const inCurve = Curves.easeOutCubic; // Decelerate in
|
||||||
|
|
||||||
|
// Phase 1: Slide out current content
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
_slideController.reset();
|
||||||
|
|
||||||
|
void animateOut() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final curvedValue = outCurve.transform(_slideController.value);
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = slideOutTarget * curvedValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void animateIn() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final curvedValue = inCurve.transform(_slideController.value);
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = slideInStart * (1.0 - curvedValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_slideController.addListener(animateOut);
|
||||||
|
|
||||||
|
_slideController.forward().then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_slideController.removeListener(animateOut);
|
||||||
|
|
||||||
|
// Switch the data
|
||||||
|
onComplete();
|
||||||
|
|
||||||
|
// Phase 2: Slide in new content
|
||||||
|
_slideOffset = slideInStart;
|
||||||
|
_slideController.reset();
|
||||||
|
_slideController.addListener(animateIn);
|
||||||
|
|
||||||
|
_slideController.forward().then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_slideController.removeListener(animateIn);
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
_isSliding = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
@@ -261,12 +381,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final selectedPlayer = maProvider.selectedPlayer;
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
final currentTrack = maProvider.currentTrack;
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
// Don't show if no track or player
|
// Don't show if no player selected
|
||||||
if (currentTrack == null || selectedPlayer == null) {
|
if (selectedPlayer == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
final imageUrl = currentTrack != null
|
||||||
|
? maProvider.getImageUrl(currentTrack, size: 512)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Extract colors for adaptive theme
|
// Extract colors for adaptive theme
|
||||||
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
||||||
@@ -276,6 +398,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
// If no track is playing, show a compact device selector bar
|
||||||
|
if (currentTrack == null) {
|
||||||
|
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
||||||
|
}
|
||||||
return _buildMorphingPlayer(
|
return _buildMorphingPlayer(
|
||||||
context,
|
context,
|
||||||
maProvider,
|
maProvider,
|
||||||
@@ -290,6 +416,67 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a compact device selector bar when no track is playing
|
||||||
|
Widget _buildDeviceSelectorBar(
|
||||||
|
BuildContext context,
|
||||||
|
MusicAssistantProvider maProvider,
|
||||||
|
dynamic selectedPlayer,
|
||||||
|
ThemeProvider themeProvider,
|
||||||
|
) {
|
||||||
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Get adaptive colors if available
|
||||||
|
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||||
|
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final backgroundColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||||
|
? adaptiveScheme.primaryContainer
|
||||||
|
: colorScheme.primaryContainer;
|
||||||
|
final textColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||||
|
? adaptiveScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onPrimaryContainer;
|
||||||
|
|
||||||
|
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
||||||
|
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
||||||
|
final width = screenSize.width - (_collapsedMargin * 2);
|
||||||
|
|
||||||
|
// Apply slide offset for hiding
|
||||||
|
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
|
||||||
|
final adjustedBottomOffset = bottomOffset - slideDownAmount;
|
||||||
|
|
||||||
|
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
||||||
|
final hasMultiplePlayers = availablePlayers.length > 1;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: _collapsedMargin,
|
||||||
|
right: _collapsedMargin,
|
||||||
|
bottom: adjustedBottomOffset,
|
||||||
|
child: DeviceSelectorBar(
|
||||||
|
selectedPlayer: selectedPlayer,
|
||||||
|
hasMultiplePlayers: hasMultiplePlayers,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
textColor: textColor,
|
||||||
|
width: width,
|
||||||
|
height: _collapsedHeight,
|
||||||
|
borderRadius: _collapsedBorderRadius,
|
||||||
|
slideOffset: _slideOffset,
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (details.primaryVelocity != null) {
|
||||||
|
if (details.primaryVelocity! < -300) {
|
||||||
|
_cycleToNextPlayer(maProvider);
|
||||||
|
} else if (details.primaryVelocity! > 300) {
|
||||||
|
_cycleToNextPlayer(maProvider, reverse: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMorphingPlayer(
|
Widget _buildMorphingPlayer(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MusicAssistantProvider maProvider,
|
MusicAssistantProvider maProvider,
|
||||||
@@ -446,6 +633,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Queue panel slide amount (0 = hidden, 1 = fully visible)
|
// Queue panel slide amount (0 = hidden, 1 = fully visible)
|
||||||
final queueT = _queuePanelAnimation.value;
|
final queueT = _queuePanelAnimation.value;
|
||||||
|
|
||||||
|
// Check if we have multiple players for swipe gesture
|
||||||
|
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
||||||
|
final hasMultiplePlayers = availablePlayers.length > 1;
|
||||||
|
final selectedPlayerId = selectedPlayer.playerId;
|
||||||
|
|
||||||
|
// Calculate slide offset for mini player content (only when collapsed)
|
||||||
|
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: horizontalMargin,
|
left: horizontalMargin,
|
||||||
right: horizontalMargin,
|
right: horizontalMargin,
|
||||||
@@ -463,10 +658,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
collapse();
|
collapse();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDragStart: isExpanded ? (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_horizontalDragStartX = details.globalPosition.dx;
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
} : null,
|
},
|
||||||
onHorizontalDragEnd: isExpanded ? (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
// Ignore swipes that started near the right edge (Android back gesture zone)
|
// Ignore swipes that started near the right edge (Android back gesture zone)
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final startedInDeadZone = _horizontalDragStartX != null &&
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
@@ -475,15 +670,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
if (startedInDeadZone) return;
|
if (startedInDeadZone) return;
|
||||||
|
|
||||||
// Swipe left to open queue, swipe right to close
|
|
||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
|
if (isExpanded) {
|
||||||
|
// Expanded mode: swipe to open/close queue
|
||||||
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
|
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
|
||||||
_toggleQueuePanel();
|
_toggleQueuePanel();
|
||||||
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
|
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
|
||||||
_toggleQueuePanel();
|
_toggleQueuePanel();
|
||||||
}
|
}
|
||||||
|
} else if (hasMultiplePlayers) {
|
||||||
|
// Collapsed mode: swipe to switch devices
|
||||||
|
if (details.primaryVelocity! < -300) {
|
||||||
|
_cycleToNextPlayer(maProvider);
|
||||||
|
} else if (details.primaryVelocity! > 300) {
|
||||||
|
_cycleToNextPlayer(maProvider, reverse: true);
|
||||||
}
|
}
|
||||||
} : null,
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Material(
|
child: Material(
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
@@ -496,9 +700,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
// Album art
|
// Album art - with slide animation when collapsed
|
||||||
Positioned(
|
Positioned(
|
||||||
left: artLeft,
|
left: artLeft + miniPlayerSlideOffset,
|
||||||
top: artTop,
|
top: artTop,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: artSize,
|
width: artSize,
|
||||||
@@ -518,21 +722,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheWidth: t > 0.5 ? 1024 : 128,
|
memCacheWidth: t > 0.5 ? 1024 : 256,
|
||||||
cacheHeight: t > 0.5 ? 1024 : 128,
|
memCacheHeight: t > 0.5 ? 1024 : 256,
|
||||||
errorBuilder: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholderFadeInDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||||
)
|
)
|
||||||
: _buildPlaceholderArt(colorScheme, t),
|
: _buildPlaceholderArt(colorScheme, t),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Track title
|
// Track title - with slide animation when collapsed
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: titleTop,
|
top: titleTop,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
@@ -552,9 +760,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Artist name
|
// Artist name - with slide animation when collapsed
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: artistTop,
|
top: artistTop,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
@@ -602,7 +810,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
top: expandedProgressTop,
|
top: expandedProgressTop,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: expandedElementsOpacity,
|
opacity: expandedElementsOpacity,
|
||||||
child: Column(
|
child: ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _progressNotifier,
|
||||||
|
builder: (context, elapsedTime, child) {
|
||||||
|
return ValueListenableBuilder<double?>(
|
||||||
|
valueListenable: _seekPositionNotifier,
|
||||||
|
builder: (context, seekPosition, child) {
|
||||||
|
final currentProgress = seekPosition ?? elapsedTime.toDouble();
|
||||||
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
SliderTheme(
|
SliderTheme(
|
||||||
data: SliderThemeData(
|
data: SliderThemeData(
|
||||||
@@ -612,11 +827,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
trackShape: const RoundedRectSliderTrackShape(),
|
trackShape: const RoundedRectSliderTrackShape(),
|
||||||
),
|
),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: (_seekPosition ?? selectedPlayer.currentElapsedTime)
|
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
|
||||||
.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
|
||||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||||
onChanged: (value) => setState(() => _seekPosition = value),
|
onChanged: (value) => _seekPositionNotifier.value = value,
|
||||||
onChangeStart: (value) => setState(() => _seekPosition = value),
|
onChangeStart: (value) => _seekPositionNotifier.value = value,
|
||||||
onChangeEnd: (value) async {
|
onChangeEnd: (value) async {
|
||||||
try {
|
try {
|
||||||
await maProvider.seek(selectedPlayer.playerId, value.round());
|
await maProvider.seek(selectedPlayer.playerId, value.round());
|
||||||
@@ -628,7 +842,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _seekPosition = null);
|
if (mounted) {
|
||||||
|
_seekPositionNotifier.value = null;
|
||||||
|
_seekPosition = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activeColor: primaryColor,
|
activeColor: primaryColor,
|
||||||
@@ -641,7 +858,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
_formatDuration(currentProgress.toInt()),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(0.5),
|
color: textColor.withOpacity(0.5),
|
||||||
fontSize: 13, // Increased from 11 to 13
|
fontSize: 13, // Increased from 11 to 13
|
||||||
@@ -662,14 +879,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Playback controls
|
// Playback controls - with slide animation when collapsed
|
||||||
Positioned(
|
Positioned(
|
||||||
left: t > 0.5 ? 0 : null,
|
left: t > 0.5 ? 0 : null,
|
||||||
right: t > 0.5 ? 0 : collapsedControlsRight,
|
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
||||||
top: controlsTop,
|
top: controlsTop,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -813,13 +1034,16 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
begin: const Offset(1, 0),
|
begin: const Offset(1, 0),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(_queuePanelAnimation),
|
).animate(_queuePanelAnimation),
|
||||||
child: _buildQueuePanel(
|
child: QueuePanel(
|
||||||
maProvider,
|
maProvider: maProvider,
|
||||||
selectedPlayer,
|
queue: _queue,
|
||||||
textColor,
|
isLoading: _isLoadingQueue,
|
||||||
primaryColor,
|
textColor: textColor,
|
||||||
topPadding,
|
primaryColor: primaryColor,
|
||||||
expandedBg,
|
backgroundColor: expandedBg,
|
||||||
|
topPadding: topPadding,
|
||||||
|
onClose: _toggleQueuePanel,
|
||||||
|
onRefresh: _loadQueue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -832,154 +1056,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQueuePanel(
|
|
||||||
MusicAssistantProvider maProvider,
|
|
||||||
dynamic selectedPlayer,
|
|
||||||
Color textColor,
|
|
||||||
Color primaryColor,
|
|
||||||
double topPadding,
|
|
||||||
Color backgroundColor,
|
|
||||||
) {
|
|
||||||
return Container(
|
|
||||||
color: backgroundColor,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: 24),
|
|
||||||
onPressed: _toggleQueuePanel,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'Queue',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: 22),
|
|
||||||
onPressed: _loadQueue,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Queue content
|
|
||||||
Expanded(
|
|
||||||
child: _isLoadingQueue
|
|
||||||
? Center(child: CircularProgressIndicator(color: primaryColor))
|
|
||||||
: _queue == null || _queue!.items.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.queue_music, size: 64, color: textColor.withOpacity(0.3)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Queue is empty',
|
|
||||||
style: TextStyle(color: textColor.withOpacity(0.5), fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: _buildQueueList(maProvider, textColor, primaryColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQueueList(
|
|
||||||
MusicAssistantProvider maProvider,
|
|
||||||
Color textColor,
|
|
||||||
Color primaryColor,
|
|
||||||
) {
|
|
||||||
final currentIndex = _queue!.currentIndex ?? 0;
|
|
||||||
final items = _queue!.items;
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
cacheExtent: 500, // Pre-render items off-screen for smoother scrolling
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
final isCurrentItem = index == currentIndex;
|
|
||||||
final isPastItem = index < currentIndex;
|
|
||||||
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
|
||||||
|
|
||||||
return Opacity(
|
|
||||||
opacity: isPastItem ? 0.5 : 1.0,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
child: imageUrl != null
|
|
||||||
? Image.network(
|
|
||||||
imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) => Container(
|
|
||||||
color: textColor.withOpacity(0.1),
|
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: textColor.withOpacity(0.1),
|
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.track.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isCurrentItem ? primaryColor : textColor,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
|
||||||
? Text(
|
|
||||||
item.track.artists!.first.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
trailing: isCurrentItem
|
|
||||||
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
// TODO: Jump to this track in queue
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
||||||
return Container(
|
return Container(
|
||||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
||||||
|
|||||||
@@ -239,9 +239,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Consumer<MusicAssistantProvider>(
|
return Consumer<MusicAssistantProvider>(
|
||||||
builder: (context, maProvider, _) {
|
builder: (context, maProvider, _) {
|
||||||
// Only show player if connected and has a track
|
// Only show player if connected and has a selected player
|
||||||
|
// Mini player is now permanent - shows device selector even without a track
|
||||||
if (!maProvider.isConnected ||
|
if (!maProvider.isConnected ||
|
||||||
maProvider.currentTrack == null ||
|
|
||||||
maProvider.selectedPlayer == null) {
|
maProvider.selectedPlayer == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A compact device selector bar shown when no track is playing
|
||||||
|
class DeviceSelectorBar extends StatelessWidget {
|
||||||
|
final dynamic selectedPlayer;
|
||||||
|
final bool hasMultiplePlayers;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color textColor;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double borderRadius;
|
||||||
|
final double slideOffset;
|
||||||
|
final GestureDragEndCallback? onHorizontalDragEnd;
|
||||||
|
|
||||||
|
const DeviceSelectorBar({
|
||||||
|
super.key,
|
||||||
|
required this.selectedPlayer,
|
||||||
|
required this.hasMultiplePlayers,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
required this.borderRadius,
|
||||||
|
required this.slideOffset,
|
||||||
|
this.onHorizontalDragEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
|
||||||
|
child: Material(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Colors.black.withOpacity(0.3),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: ClipRect(
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(slideOffset * width, 0),
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingH16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getPlayerIcon(selectedPlayer.name),
|
||||||
|
color: textColor,
|
||||||
|
size: IconSizes.md,
|
||||||
|
),
|
||||||
|
Spacing.hGap12,
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
selectedPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (hasMultiplePlayers)
|
||||||
|
Text(
|
||||||
|
'Swipe to switch device',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get appropriate icon for player based on name
|
||||||
|
IconData _getPlayerIcon(String playerName) {
|
||||||
|
final nameLower = playerName.toLowerCase();
|
||||||
|
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
|
||||||
|
return Icons.phone_android_rounded;
|
||||||
|
} else if (nameLower.contains('group')) {
|
||||||
|
return Icons.speaker_group_rounded;
|
||||||
|
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
||||||
|
return Icons.tv_rounded;
|
||||||
|
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
||||||
|
return Icons.cast_rounded;
|
||||||
|
} else {
|
||||||
|
return Icons.speaker_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../animated_icon_button.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// Playback control buttons (shuffle, previous, play/pause, next, repeat)
|
||||||
|
class PlayerControls extends StatelessWidget {
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool? shuffle;
|
||||||
|
final String? repeatMode;
|
||||||
|
final Color textColor;
|
||||||
|
final Color primaryColor;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double skipButtonSize;
|
||||||
|
final double playButtonSize;
|
||||||
|
final double playButtonContainerSize;
|
||||||
|
final double progress; // Animation progress 0-1
|
||||||
|
final double expandedElementsOpacity;
|
||||||
|
final bool isLoadingQueue;
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onPlayPause;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final VoidCallback? onStop;
|
||||||
|
final VoidCallback? onToggleShuffle;
|
||||||
|
final VoidCallback? onCycleRepeat;
|
||||||
|
|
||||||
|
const PlayerControls({
|
||||||
|
super.key,
|
||||||
|
required this.isPlaying,
|
||||||
|
this.shuffle,
|
||||||
|
this.repeatMode,
|
||||||
|
required this.textColor,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.skipButtonSize,
|
||||||
|
required this.playButtonSize,
|
||||||
|
required this.playButtonContainerSize,
|
||||||
|
required this.progress,
|
||||||
|
required this.expandedElementsOpacity,
|
||||||
|
required this.isLoadingQueue,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onPlayPause,
|
||||||
|
required this.onNext,
|
||||||
|
this.onStop,
|
||||||
|
this.onToggleShuffle,
|
||||||
|
this.onCycleRepeat,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = progress;
|
||||||
|
final isExpanded = t > 0.5;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
// Shuffle (expanded only)
|
||||||
|
if (isExpanded)
|
||||||
|
Opacity(
|
||||||
|
opacity: expandedElementsOpacity,
|
||||||
|
child: _buildSecondaryButton(
|
||||||
|
icon: Icons.shuffle_rounded,
|
||||||
|
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
|
||||||
|
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
|
// Previous
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: onPrevious,
|
||||||
|
useAnimation: isExpanded,
|
||||||
|
),
|
||||||
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
|
// Play/Pause
|
||||||
|
_buildPlayButton(),
|
||||||
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
|
// Next
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: onNext,
|
||||||
|
useAnimation: isExpanded,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Repeat (expanded only)
|
||||||
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
if (isExpanded)
|
||||||
|
Opacity(
|
||||||
|
opacity: expandedElementsOpacity,
|
||||||
|
child: _buildSecondaryButton(
|
||||||
|
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||||
|
color: repeatMode != null && repeatMode != 'off'
|
||||||
|
? primaryColor
|
||||||
|
: textColor.withOpacity(0.5),
|
||||||
|
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlButton({
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
required double size,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
required bool useAnimation,
|
||||||
|
}) {
|
||||||
|
if (useAnimation) {
|
||||||
|
return AnimatedIconButton(
|
||||||
|
icon: icon,
|
||||||
|
color: color,
|
||||||
|
iconSize: size,
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(icon),
|
||||||
|
color: color,
|
||||||
|
iconSize: size,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: Spacing.paddingAll4,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSecondaryButton({
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
VoidCallback? onPressed,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icon),
|
||||||
|
color: color,
|
||||||
|
iconSize: 22,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlayButton() {
|
||||||
|
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
||||||
|
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: onStop,
|
||||||
|
child: Container(
|
||||||
|
width: playButtonContainerSize,
|
||||||
|
height: playButtonContainerSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
||||||
|
color: iconColor,
|
||||||
|
iconSize: playButtonSize,
|
||||||
|
onPressed: onPlayPause,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _lerpDouble(double a, double b, double t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// Player widget components extracted from expandable_player.dart
|
||||||
|
library player_widgets;
|
||||||
|
|
||||||
|
export 'device_selector_bar.dart';
|
||||||
|
export 'player_controls.dart';
|
||||||
|
export 'queue_panel.dart';
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../models/player.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../common/empty_state.dart';
|
||||||
|
|
||||||
|
/// Panel that displays the current playback queue
|
||||||
|
class QueuePanel extends StatelessWidget {
|
||||||
|
final MusicAssistantProvider maProvider;
|
||||||
|
final PlayerQueue? queue;
|
||||||
|
final bool isLoading;
|
||||||
|
final Color textColor;
|
||||||
|
final Color primaryColor;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double topPadding;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
const QueuePanel({
|
||||||
|
super.key,
|
||||||
|
required this.maProvider,
|
||||||
|
required this.queue,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.textColor,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.topPadding,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: backgroundColor,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
|
||||||
|
onPressed: onClose,
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'Queue',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||||
|
onPressed: onRefresh,
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Queue content
|
||||||
|
Expanded(
|
||||||
|
child: isLoading
|
||||||
|
? Center(child: CircularProgressIndicator(color: primaryColor))
|
||||||
|
: queue == null || queue!.items.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildQueueList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return EmptyState.queue();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQueueList() {
|
||||||
|
final currentIndex = queue!.currentIndex ?? 0;
|
||||||
|
final items = queue!.items;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: Spacing.paddingH8,
|
||||||
|
cacheExtent: 500,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final isCurrentItem = index == currentIndex;
|
||||||
|
final isPastItem = index < currentIndex;
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: isPastItem ? 0.5 : 1.0,
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(Radii.md),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(Radii.sm),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
color: textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.track.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentItem ? primaryColor : textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
item.track.artists!.first.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: isCurrentItem
|
||||||
|
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Jump to this track in queue
|
||||||
|
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
|
||||||
|
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
|
||||||
|
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,10 +98,15 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Preload all player track info for display
|
||||||
|
context.read<MusicAssistantProvider>().preloadAllPlayerTracks();
|
||||||
|
|
||||||
// Auto-refresh at configured polling interval
|
// Auto-refresh at configured polling interval
|
||||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.read<MusicAssistantProvider>().refreshPlayers();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
provider.refreshPlayers();
|
||||||
|
provider.preloadAllPlayerTracks();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,17 +126,18 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final currentTrack = maProvider.currentTrack;
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final sheetHeight = screenHeight * 0.7; // Fixed 70% height
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.pop(context), // Tap outside to dismiss
|
onTap: () => Navigator.pop(context), // Tap outside to dismiss
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: DraggableScrollableSheet(
|
child: Align(
|
||||||
initialChildSize: 0.6,
|
alignment: Alignment.bottomCenter,
|
||||||
minChildSize: 0.3,
|
child: GestureDetector(
|
||||||
maxChildSize: 0.85,
|
|
||||||
builder: (context, scrollController) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {}, // Prevent taps on sheet from dismissing
|
onTap: () {}, // Prevent taps on sheet from dismissing
|
||||||
child: Container(
|
child: Container(
|
||||||
|
height: sheetHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
@@ -181,7 +187,6 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: scrollController,
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 8, 12, BottomSpacing.navBarOnly),
|
padding: EdgeInsets.fromLTRB(12, 8, 12, BottomSpacing.navBarOnly),
|
||||||
itemCount: currentPlayers.length,
|
itemCount: currentPlayers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -190,12 +195,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
final isOn = player.available && player.powered;
|
final isOn = player.available && player.powered;
|
||||||
final isPlaying = player.state == 'playing';
|
final isPlaying = player.state == 'playing';
|
||||||
final isPaused = player.state == 'paused';
|
final isPaused = player.state == 'paused';
|
||||||
|
// MA uses 'idle' for players that were paused (especially cast-based)
|
||||||
|
// but still have track info - treat as "has content" for display
|
||||||
|
final isIdle = player.state == 'idle';
|
||||||
|
|
||||||
// Get album art for selected player if playing
|
// Get track info - use current track for selected player, cache for others
|
||||||
|
final playerTrack = isSelected
|
||||||
|
? currentTrack
|
||||||
|
: maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
|
||||||
|
// Player has content if playing, paused, or idle with cached track
|
||||||
|
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
|
||||||
|
|
||||||
|
// Get album art for any player with content
|
||||||
String? albumArtUrl;
|
String? albumArtUrl;
|
||||||
if (isSelected && currentTrack != null && isOn) {
|
if (playerTrack != null && isOn && hasContent) {
|
||||||
albumArtUrl = maProvider.getImageUrl(
|
albumArtUrl = maProvider.getImageUrl(
|
||||||
currentTrack.album ?? currentTrack,
|
playerTrack.album ?? playerTrack,
|
||||||
size: 128,
|
size: 128,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,21 +295,20 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
? colorScheme.onSurface.withOpacity(0.3)
|
? colorScheme.onSurface.withOpacity(0.3)
|
||||||
: isPlaying
|
: isPlaying
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: isPaused
|
: hasContent
|
||||||
? Colors.orange
|
? Colors.orange // paused or idle with content
|
||||||
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
!player.available
|
!player.available
|
||||||
? 'Unavailable'
|
? 'Unavailable'
|
||||||
: !isOn
|
: !isOn
|
||||||
? 'Off'
|
? 'Off'
|
||||||
: isPlaying
|
: hasContent && playerTrack != null
|
||||||
? 'Playing'
|
? playerTrack.name
|
||||||
: isPaused
|
|
||||||
? 'Paused'
|
|
||||||
: 'Idle',
|
: 'Idle',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: player.available
|
color: player.available
|
||||||
@@ -301,6 +316,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
: colorScheme.onSurface.withOpacity(0.24),
|
: colorScheme.onSurface.withOpacity(0.24),
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isSelected) ...[
|
if (isSelected) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -366,8 +384,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
|
||||||
/// Volume control widget for Music Assistant players
|
/// Volume control widget for Music Assistant players
|
||||||
|
/// For local device: controls system media volume
|
||||||
|
/// For MA players: controls player volume via API
|
||||||
class VolumeControl extends StatefulWidget {
|
class VolumeControl extends StatefulWidget {
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
|
||||||
@@ -15,7 +20,42 @@ class VolumeControl extends StatefulWidget {
|
|||||||
|
|
||||||
class _VolumeControlState extends State<VolumeControl> {
|
class _VolumeControlState extends State<VolumeControl> {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
double? _pendingVolume; // Track pending volume changes
|
double? _pendingVolume;
|
||||||
|
double? _systemVolume;
|
||||||
|
StreamSubscription? _volumeListener;
|
||||||
|
String? _localPlayerId;
|
||||||
|
bool _isLocalPlayer = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initLocalPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initLocalPlayer() async {
|
||||||
|
_localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
|
final volume = await FlutterVolumeController.getVolume();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_systemVolume = volume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_volumeListener = FlutterVolumeController.addListener((volume) {
|
||||||
|
if (mounted && _isLocalPlayer) {
|
||||||
|
setState(() {
|
||||||
|
_systemVolume = volume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_volumeListener?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -26,11 +66,14 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentVolume = (_pendingVolume ?? player.volume.toDouble()) / 100.0;
|
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
|
||||||
final isMuted = player.isMuted;
|
|
||||||
|
final currentVolume = _isLocalPlayer
|
||||||
|
? (_pendingVolume ?? (_systemVolume ?? 0.5))
|
||||||
|
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
|
||||||
|
final isMuted = _isLocalPlayer ? false : player.isMuted;
|
||||||
|
|
||||||
if (widget.compact) {
|
if (widget.compact) {
|
||||||
// Compact version - just mute button
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isMuted ? Icons.volume_off : Icons.volume_up,
|
isMuted ? Icons.volume_off : Icons.volume_up,
|
||||||
@@ -46,10 +89,8 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full volume control with slider
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Mute button
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isMuted
|
isMuted
|
||||||
@@ -67,17 +108,12 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Volume slider
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SliderTheme(
|
child: SliderTheme(
|
||||||
data: SliderThemeData(
|
data: SliderThemeData(
|
||||||
trackHeight: 2,
|
trackHeight: 2,
|
||||||
thumbShape: const RoundSliderThumbShape(
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
enabledThumbRadius: 6,
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||||
),
|
|
||||||
overlayShape: const RoundSliderOverlayShape(
|
|
||||||
overlayRadius: 14,
|
|
||||||
),
|
|
||||||
activeTrackColor: Colors.white,
|
activeTrackColor: Colors.white,
|
||||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||||
thumbColor: Colors.white,
|
thumbColor: Colors.white,
|
||||||
@@ -87,31 +123,34 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
value: currentVolume.clamp(0.0, 1.0),
|
value: currentVolume.clamp(0.0, 1.0),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingVolume = value * 100;
|
_pendingVolume = _isLocalPlayer ? value : value * 100;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) async {
|
onChangeEnd: (value) async {
|
||||||
|
if (_isLocalPlayer) {
|
||||||
|
_logger.log('Volume: Setting system volume to ${(value * 100).round()}%');
|
||||||
|
try {
|
||||||
|
await FlutterVolumeController.setVolume(value);
|
||||||
|
_systemVolume = value;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Volume: Error setting system volume - $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_pendingVolume = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
final volumeLevel = (value * 100).round();
|
final volumeLevel = (value * 100).round();
|
||||||
_logger.log('Volume: Setting to $volumeLevel%');
|
_logger.log('Volume: Setting to $volumeLevel%');
|
||||||
try {
|
try {
|
||||||
// Unmute if changing volume while muted
|
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
_logger.log('Volume: Unmuting player first');
|
_logger.log('Volume: Unmuting player first');
|
||||||
await maProvider.setMute(player.playerId, false);
|
await maProvider.setMute(player.playerId, false);
|
||||||
}
|
}
|
||||||
await maProvider.setVolume(player.playerId, volumeLevel);
|
await maProvider.setVolume(player.playerId, volumeLevel);
|
||||||
|
_logger.log('Volume: Set to $volumeLevel%');
|
||||||
// Wait for the player state to update by polling
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
if (mounted) {
|
|
||||||
final updatedPlayer = maProvider.selectedPlayer;
|
|
||||||
if (updatedPlayer != null && (updatedPlayer.volume - volumeLevel).abs() <= 2) {
|
|
||||||
_logger.log('Volume: Updated to $volumeLevel%');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Volume: Error - $e');
|
_logger.log('Volume: Error - $e');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -121,11 +160,11 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Volume percentage
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,6 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
cupertino_icons: ^1.0.6
|
|
||||||
material_design_icons_flutter: ^7.0.7296
|
material_design_icons_flutter: ^7.0.7296
|
||||||
dynamic_color: 1.6.8
|
dynamic_color: 1.6.8
|
||||||
palette_generator: ^0.3.3+3
|
palette_generator: ^0.3.3+3
|
||||||
@@ -34,6 +33,7 @@ dependencies:
|
|||||||
just_audio: ^0.9.36
|
just_audio: ^0.9.36
|
||||||
audio_service: ^0.18.12
|
audio_service: ^0.18.12
|
||||||
audio_session: ^0.1.18
|
audio_session: ^0.1.18
|
||||||
|
flutter_volume_controller: ^1.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user