Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0568e917b | ||
|
|
2e3f3c1e25 | ||
|
|
e1b18c7f9e | ||
|
|
2e0cb175cb | ||
|
|
d7194d0f2f | ||
|
|
4a94a0b596 | ||
|
|
d28219df98 | ||
|
|
27b2df7e98 | ||
|
|
9682e877fb | ||
|
|
daf624c8bc | ||
|
|
2c2c5e7939 | ||
|
|
a6c35c6263 | ||
|
|
25b074c1e8 | ||
|
|
6c97420f58 | ||
|
|
526e0d06b6 | ||
|
|
7092c2c08e | ||
|
|
c728815f8b | ||
|
|
19055d1474 | ||
|
|
cac78f7a11 | ||
|
|
1ad5ab3f22 | ||
|
|
4f7fe9f4a4 | ||
|
|
081f13d59f | ||
|
|
ea4407f509 | ||
|
|
6ffc98c680 | ||
|
|
0fe53c9115 | ||
|
|
d0a1e3a85b | ||
|
|
fa8c5bd26e | ||
|
|
62386b65d3 | ||
|
|
82beb0db2c | ||
|
|
2fe78556e5 | ||
|
|
82d0bb5c2d | ||
|
|
add137c56c | ||
|
|
3df1b9c756 | ||
|
|
3852e83a3c | ||
|
|
aa48c9e6d7 | ||
|
|
c3a197ff93 | ||
|
|
1edb5a73c9 | ||
|
|
498ed53106 | ||
|
|
6fcd34752f | ||
|
|
710c772f1a | ||
|
|
4bfe450e8b | ||
|
|
d4b910fae0 | ||
|
|
c2fdccc702 | ||
|
|
43f50dcd02 | ||
|
|
9a0c8c2b7e | ||
|
|
d55419514d | ||
|
|
a7f41827cb | ||
|
|
1dbd589ed7 | ||
|
|
3b824174a4 | ||
|
|
8eff3eb376 | ||
|
|
2cec4f22ad | ||
|
|
fc642cd1d6 | ||
|
|
e13873456d |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
|
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,9 +28,18 @@ I am not a developer. This application was created using **Claude Code** and **G
|
|||||||
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
||||||
- **Queue Management** - View and manage the playback queue
|
- **Queue Management** - View and manage the playback queue
|
||||||
|
|
||||||
|
### Home Screen
|
||||||
|
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
|
||||||
|
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
|
||||||
|
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
|
||||||
|
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
||||||
|
|
||||||
### Library Browsing
|
### Library Browsing
|
||||||
- **Browse Your Collection** - Artists, albums, and tracks from all your music sources
|
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
|
||||||
|
- **Favorites Filter** - Toggle to show only your favorite items
|
||||||
- **Album Details** - View track listings with artwork
|
- **Album Details** - View track listings with artwork
|
||||||
|
- **Artist Details** - View artist albums and top tracks
|
||||||
|
- **Playlist Support** - Browse and play your playlists
|
||||||
- **Search** - Find music across your entire library
|
- **Search** - Find music across your entire library
|
||||||
|
|
||||||
### Smart Features
|
### Smart Features
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name="com.ryanheise.audioservice.AudioServiceActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
|
|||||||
@@ -1,6 +1,87 @@
|
|||||||
package com.musicassistant.music_assistant
|
package com.musicassistant.music_assistant
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import android.util.Log
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||||
|
// while also intercepting volume button events
|
||||||
|
class MainActivity: AudioServiceActivity() {
|
||||||
|
private val TAG = "EnsembleVolume"
|
||||||
|
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var isListening = false
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||||
|
|
||||||
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
|
Log.d(TAG, "Received method call: ${call.method}")
|
||||||
|
when (call.method) {
|
||||||
|
"startListening" -> {
|
||||||
|
isListening = true
|
||||||
|
Log.d(TAG, "Volume listening ENABLED")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopListening" -> {
|
||||||
|
isListening = false
|
||||||
|
Log.d(TAG, "Volume listening DISABLED")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Unknown method: ${call.method}")
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||||
|
// and may consume events before they reach onKeyDown
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||||
|
if (event == null) {
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyCode = event.keyCode
|
||||||
|
val action = event.action
|
||||||
|
|
||||||
|
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||||
|
|
||||||
|
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||||
|
if (action != KeyEvent.ACTION_DOWN) {
|
||||||
|
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||||
|
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||||
|
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isListening) {
|
||||||
|
Log.d(TAG, "Not listening, passing to super")
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||||
|
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||||
|
methodChannel?.invokeMethod("volumeUp", null)
|
||||||
|
true // Consume the event to prevent system volume change
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||||
|
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||||
|
methodChannel?.invokeMethod("volumeDown", null)
|
||||||
|
true // Consume the event to prevent system volume change
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Other key, passing to super")
|
||||||
|
super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 903 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 686 KiB After Width: | Height: | Size: 498 KiB |
@@ -1,14 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'providers/music_assistant_provider.dart';
|
import 'providers/music_assistant_provider.dart';
|
||||||
|
import 'providers/navigation_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
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 'services/debug_logger.dart';
|
||||||
|
import 'services/hardware_volume_service.dart';
|
||||||
import 'services/music_assistant_api.dart' show MAConnectionState;
|
import 'services/music_assistant_api.dart' show MAConnectionState;
|
||||||
import 'theme/theme_provider.dart';
|
import 'theme/theme_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
@@ -70,6 +73,14 @@ class MusicAssistantApp extends StatefulWidget {
|
|||||||
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
||||||
late MusicAssistantProvider _musicProvider;
|
late MusicAssistantProvider _musicProvider;
|
||||||
late ThemeProvider _themeProvider;
|
late ThemeProvider _themeProvider;
|
||||||
|
final _hardwareVolumeService = HardwareVolumeService();
|
||||||
|
StreamSubscription? _volumeUpSub;
|
||||||
|
StreamSubscription? _volumeDownSub;
|
||||||
|
String? _lastSelectedPlayerId;
|
||||||
|
String? _builtinPlayerId;
|
||||||
|
|
||||||
|
// Volume step size (percentage points per button press)
|
||||||
|
static const int _volumeStep = 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -77,14 +88,108 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
_musicProvider = MusicAssistantProvider();
|
_musicProvider = MusicAssistantProvider();
|
||||||
_themeProvider = ThemeProvider();
|
_themeProvider = ThemeProvider();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_initHardwareVolumeControl();
|
||||||
|
// Listen to player selection changes to toggle volume interception
|
||||||
|
_musicProvider.addListener(_onProviderChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initHardwareVolumeControl() async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔊 [1/5] Starting hardware volume control initialization...');
|
||||||
|
|
||||||
|
// Get builtin player ID for detecting when to disable interception
|
||||||
|
_builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
_logger.log('🔊 [2/5] Builtin player ID: $_builtinPlayerId');
|
||||||
|
|
||||||
|
_logger.log('🔊 [3/5] Calling HardwareVolumeService.init()...');
|
||||||
|
await _hardwareVolumeService.init();
|
||||||
|
_logger.log('🔊 [4/5] HardwareVolumeService.init() completed, isListening=${_hardwareVolumeService.isListening}');
|
||||||
|
|
||||||
|
_volumeUpSub = _hardwareVolumeService.onVolumeUp.listen((_) {
|
||||||
|
_logger.log('🔊 Volume UP event received in main.dart');
|
||||||
|
_adjustVolume(_volumeStep);
|
||||||
|
});
|
||||||
|
|
||||||
|
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
|
||||||
|
_logger.log('🔊 Volume DOWN event received in main.dart');
|
||||||
|
_adjustVolume(-_volumeStep);
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.log('🔊 [5/5] Hardware volume control fully initialized');
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.error('Hardware volume control initialization FAILED', context: 'VolumeInit', error: e, stackTrace: stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when provider state changes - check if selected player changed
|
||||||
|
void _onProviderChanged() {
|
||||||
|
final currentPlayerId = _musicProvider.selectedPlayer?.playerId;
|
||||||
|
if (currentPlayerId != _lastSelectedPlayerId) {
|
||||||
|
_lastSelectedPlayerId = currentPlayerId;
|
||||||
|
_updateVolumeInterception();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable volume button interception based on selected player.
|
||||||
|
/// For builtin player: disable interception so native volume works
|
||||||
|
/// For MA players: enable interception to control player via API
|
||||||
|
Future<void> _updateVolumeInterception() async {
|
||||||
|
final isBuiltinPlayer = _builtinPlayerId != null &&
|
||||||
|
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
|
||||||
|
|
||||||
|
if (isBuiltinPlayer) {
|
||||||
|
_logger.log('🔊 Builtin player selected - disabling volume interception');
|
||||||
|
await _hardwareVolumeService.setIntercepting(false);
|
||||||
|
} else {
|
||||||
|
_logger.log('🔊 MA player selected - enabling volume interception');
|
||||||
|
await _hardwareVolumeService.setIntercepting(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _adjustVolume(int delta) async {
|
||||||
|
final player = _musicProvider.selectedPlayer;
|
||||||
|
if (player == null) {
|
||||||
|
_logger.log('🔊 Hardware volume button pressed but no player selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔊 Hardware volume: player=${player.name}, current=${player.volume}, delta=$delta');
|
||||||
|
|
||||||
|
final newVolume = (player.volume + delta).clamp(0, 100);
|
||||||
|
if (newVolume != player.volume) {
|
||||||
|
try {
|
||||||
|
await _musicProvider.setVolume(player.playerId, newVolume);
|
||||||
|
_logger.log('🔊 Hardware volume adjusted: ${player.volume} -> $newVolume for ${player.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Hardware volume adjustment failed', context: 'VolumeControl', error: e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.log('🔊 Hardware volume: already at limit ($newVolume)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_musicProvider.removeListener(_onProviderChanged);
|
||||||
|
_volumeUpSub?.cancel();
|
||||||
|
_volumeDownSub?.cancel();
|
||||||
|
_hardwareVolumeService.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> didPopRoute() async {
|
||||||
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||||
|
// If player is expanded, collapse it and consume the back gesture
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
return true; // We handled it, don't let Navigator process it
|
||||||
|
}
|
||||||
|
// Let Navigator handle the back gesture normally
|
||||||
|
return super.didPopRoute();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
super.didChangeAppLifecycleState(state);
|
super.didChangeAppLifecycleState(state);
|
||||||
@@ -134,6 +239,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
lightColorScheme: lightColorScheme,
|
lightColorScheme: lightColorScheme,
|
||||||
darkColorScheme: darkColorScheme,
|
darkColorScheme: darkColorScheme,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
navigatorKey: navigationProvider.navigatorKey,
|
||||||
title: 'Ensemble',
|
title: 'Ensemble',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
@@ -50,6 +51,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Local player state
|
// Local player state
|
||||||
bool _isLocalPlayerPowered = true;
|
bool _isLocalPlayerPowered = true;
|
||||||
|
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||||
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
||||||
StreamSubscription? _localPlayerEventSubscription;
|
StreamSubscription? _localPlayerEventSubscription;
|
||||||
StreamSubscription? _playerUpdatedEventSubscription;
|
StreamSubscription? _playerUpdatedEventSubscription;
|
||||||
@@ -491,7 +493,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final isPlaying = _localPlayer.isPlaying;
|
final isPlaying = _localPlayer.isPlaying;
|
||||||
final position = _localPlayer.position.inSeconds;
|
final position = _localPlayer.position.inSeconds;
|
||||||
final volume = (_localPlayer.volume * 100).round();
|
// Use tracked MA volume instead of just_audio player volume
|
||||||
|
// just_audio volume is for local playback, MA volume is for server sync
|
||||||
|
final volume = _localPlayerVolume;
|
||||||
final isPaused = !isPlaying && position > 0;
|
final isPaused = !isPlaying && position > 0;
|
||||||
|
|
||||||
await _api!.updateBuiltinPlayerState(
|
await _api!.updateBuiltinPlayerState(
|
||||||
@@ -501,7 +505,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
paused: isPaused,
|
paused: isPaused,
|
||||||
position: position,
|
position: position,
|
||||||
volume: volume,
|
volume: volume,
|
||||||
muted: _localPlayer.volume == 0.0,
|
muted: _localPlayerVolume == 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,9 +594,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'volume_set':
|
case 'volume_set':
|
||||||
final volume = event['volume_level'] as int?;
|
case 'set_volume':
|
||||||
|
// MA sends 'set_volume' in event type but 'volume' in data
|
||||||
|
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
|
||||||
if (volume != null) {
|
if (volume != null) {
|
||||||
await _localPlayer.setVolume(volume / 100.0);
|
_localPlayerVolume = volume; // Track MA volume
|
||||||
|
// Use device media volume (FlutterVolumeController), NOT just_audio software volume
|
||||||
|
await FlutterVolumeController.setVolume(volume / 100.0);
|
||||||
|
_logger.log('🔊 Builtin player DEVICE volume set to ${(volume / 100.0).toStringAsFixed(2)} from server event');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -834,6 +843,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_cacheService.invalidateHomeCache();
|
_cacheService.invalidateHomeCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FAVORITES FOR HOME SCREEN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get favorite albums from the library
|
||||||
|
Future<List<Album>> getFavoriteAlbums() async {
|
||||||
|
// Filter from loaded library - favorites are already loaded
|
||||||
|
return _albums.where((a) => a.favorite == true).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get favorite artists from the library
|
||||||
|
Future<List<Artist>> getFavoriteArtists() async {
|
||||||
|
// Filter from loaded library - favorites are already loaded
|
||||||
|
return _artists.where((a) => a.favorite == true).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get favorite tracks from the library
|
||||||
|
Future<List<Track>> getFavoriteTracks() async {
|
||||||
|
// Filter from loaded library - favorites are already loaded
|
||||||
|
return _tracks.where((t) => t.favorite == true).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DETAIL SCREEN CACHING
|
// DETAIL SCREEN CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1569,6 +1600,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> playArtistRadio(String playerId, Artist artist) async {
|
||||||
|
try {
|
||||||
|
await _api?.playArtistRadio(playerId, artist);
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
ErrorHandler.logError('Play artist radio', e);
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getCurrentStreamUrl(String playerId) async {
|
Future<String?> getCurrentStreamUrl(String playerId) async {
|
||||||
return await _api?.getCurrentStreamUrl(playerId);
|
return await _api?.getCurrentStreamUrl(playerId);
|
||||||
}
|
}
|
||||||
@@ -1666,7 +1709,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||||
try {
|
try {
|
||||||
await _api?.setVolume(playerId, volumeLevel);
|
// Check if this is the builtin player
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
||||||
|
// For builtin player, control device media volume directly
|
||||||
|
_localPlayerVolume = volumeLevel;
|
||||||
|
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||||
|
_logger.log('🔊 Builtin player DEVICE volume set to $volumeLevel%');
|
||||||
|
// Also notify MA server about the volume change
|
||||||
|
await _api?.setVolume(playerId, volumeLevel);
|
||||||
|
} else {
|
||||||
|
// For MA players, just send to MA server
|
||||||
|
await _api?.setVolume(playerId, volumeLevel);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Set volume', e);
|
ErrorHandler.logError('Set volume', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -43,9 +43,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_extractColors();
|
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
_refreshFavoriteStatus();
|
_refreshFavoriteStatus();
|
||||||
|
|
||||||
|
// CRITICAL FIX: Defer color extraction until after the transition completes
|
||||||
|
// This prevents expensive palette extraction during hero animation
|
||||||
|
// Wait for the first frame to be painted, then extract colors
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// Add small delay to ensure transition is complete
|
||||||
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
|
if (mounted) {
|
||||||
|
_extractColors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshFavoriteStatus() async {
|
Future<void> _refreshFavoriteStatus() async {
|
||||||
@@ -178,6 +189,92 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleTrackFavorite(int trackIndex) async {
|
||||||
|
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
|
||||||
|
|
||||||
|
final track = _tracks[trackIndex];
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
|
final currentFavorite = track.favorite ?? false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentFavorite) {
|
||||||
|
// Remove from favorites - need library_item_id
|
||||||
|
int? libraryItemId;
|
||||||
|
if (track.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(track.itemId);
|
||||||
|
} else if (track.providerMappings != null) {
|
||||||
|
final libraryMapping = track.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => track.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId != null) {
|
||||||
|
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
String actualProvider = track.provider;
|
||||||
|
String actualItemId = track.itemId;
|
||||||
|
|
||||||
|
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||||
|
final mapping = track.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => track.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available,
|
||||||
|
orElse: () => track.providerMappings!.first,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state - create new track with toggled favorite
|
||||||
|
setState(() {
|
||||||
|
_tracks[trackIndex] = Track(
|
||||||
|
itemId: track.itemId,
|
||||||
|
provider: track.provider,
|
||||||
|
name: track.name,
|
||||||
|
uri: track.uri,
|
||||||
|
favorite: !currentFavorite,
|
||||||
|
artists: track.artists,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration,
|
||||||
|
providerMappings: track.providerMappings,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling track favorite: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update favorite: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadTracks() async {
|
Future<void> _loadTracks() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = await provider.getAlbumTracksWithCache(
|
final tracks = await provider.getAlbumTracksWithCache(
|
||||||
@@ -403,10 +500,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
void _navigateToArtist() {
|
void _navigateToArtist() {
|
||||||
// Navigate to the first artist if available
|
// Navigate to the first artist if available
|
||||||
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||||
|
final artist = widget.album.artists!.first;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first),
|
builder: (context) => ArtistDetailsScreen(
|
||||||
|
artist: artist,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,18 +547,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
// Only rebuild when specific properties change, not on every provider update
|
||||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
final imageUrl = context.select<MusicAssistantProvider, String?>(
|
||||||
|
(provider) => provider.getImageUrl(widget.album, size: 512),
|
||||||
|
);
|
||||||
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
|
(provider) => provider.adaptiveTheme,
|
||||||
|
);
|
||||||
|
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveLightScheme,
|
||||||
|
);
|
||||||
|
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveDarkScheme,
|
||||||
|
);
|
||||||
|
|
||||||
// Determine if we should use adaptive theme colors
|
// Determine if we should use adaptive theme colors
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Get the color scheme to use
|
// Get the color scheme to use - prefer local state over provider
|
||||||
|
// Local state (_darkColorScheme/_lightColorScheme) is set by _extractColors()
|
||||||
ColorScheme? adaptiveScheme;
|
ColorScheme? adaptiveScheme;
|
||||||
if (useAdaptiveTheme) {
|
if (adaptiveTheme) {
|
||||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
// Use local state first (from _extractColors), fallback to provider
|
||||||
|
adaptiveScheme = isDark
|
||||||
|
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||||
|
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use adaptive scheme if available, otherwise use global theme
|
// Use adaptive scheme if available, otherwise use global theme
|
||||||
@@ -775,39 +892,60 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
child: isExpanded
|
child: isExpanded
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// Radio button
|
||||||
child: SizedBox(
|
SizedBox(
|
||||||
height: 48,
|
height: 44,
|
||||||
child: FilledButton.tonal(
|
width: 44,
|
||||||
onPressed: () => _showPlayRadioMenu(context, index),
|
child: FilledButton.tonal(
|
||||||
style: FilledButton.styleFrom(
|
onPressed: () => _showPlayRadioMenu(context, index),
|
||||||
backgroundColor: colorScheme.tertiaryContainer,
|
style: FilledButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onTertiaryContainer,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Start radio'),
|
|
||||||
),
|
),
|
||||||
|
child: const Icon(Icons.radio, size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
// Add to queue button
|
||||||
child: SizedBox(
|
SizedBox(
|
||||||
height: 48,
|
height: 44,
|
||||||
child: FilledButton.tonal(
|
width: 44,
|
||||||
onPressed: () => _addTrackToQueue(context, index),
|
child: FilledButton.tonal(
|
||||||
style: FilledButton.styleFrom(
|
onPressed: () => _addTrackToQueue(context, index),
|
||||||
backgroundColor: colorScheme.secondaryContainer,
|
style: FilledButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onSecondaryContainer,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Add to queue'),
|
),
|
||||||
|
child: const Icon(Icons.playlist_add, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Favorite button
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
width: 44,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _toggleTrackFavorite(index),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
track.favorite == true ? Icons.favorite : Icons.favorite_border,
|
||||||
|
size: 20,
|
||||||
|
color: track.favorite == true
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import '../utils/page_transitions.dart';
|
|||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
const ArtistDetailsScreen({
|
const ArtistDetailsScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,6 +33,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
List<Album> _providerAlbums = [];
|
List<Album> _providerAlbums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isFavorite = false;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
@@ -42,9 +45,267 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_isFavorite = widget.artist.favorite ?? false;
|
||||||
|
// Use initial image URL immediately for smooth hero animation
|
||||||
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
_loadArtistAlbums();
|
_loadArtistAlbums();
|
||||||
_loadArtistImage();
|
|
||||||
_loadArtistDescription();
|
_loadArtistDescription();
|
||||||
|
_refreshFavoriteStatus();
|
||||||
|
|
||||||
|
// Defer higher-res image loading and color extraction until after transition
|
||||||
|
// But hero animation now works because we have initialImageUrl
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
|
if (mounted) {
|
||||||
|
_loadArtistImage();
|
||||||
|
// Note: _extractColors is called by _loadArtistImage after image loads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshFavoriteStatus() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
|
final artistUri = widget.artist.uri;
|
||||||
|
if (artistUri == null || artistUri.isEmpty) {
|
||||||
|
_logger.log('Cannot refresh favorite status: artist has no URI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
||||||
|
if (freshArtist != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isFavorite = freshArtist.favorite ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error refreshing artist favorite status: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newState = !_isFavorite;
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
String actualProvider = widget.artist.provider;
|
||||||
|
String actualItemId = widget.artist.itemId;
|
||||||
|
|
||||||
|
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
||||||
|
final mapping = widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available,
|
||||||
|
orElse: () => widget.artist.providerMappings!.first,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
|
||||||
|
} else {
|
||||||
|
int? libraryItemId;
|
||||||
|
|
||||||
|
if (widget.artist.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.artist.itemId);
|
||||||
|
} else if (widget.artist.providerMappings != null) {
|
||||||
|
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.artist.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Error: Could not determine library_item_id for removal');
|
||||||
|
throw Exception('Could not determine library ID for this artist');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||||
|
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling artist favorite: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update favorite: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startArtistRadio() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Starting ${widget.artist.name} radio'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error starting artist radio: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to start radio: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Start ${widget.artist.name} radio 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: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title: Text(player.name),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error starting artist radio on player: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to start radio: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addArtistRadioToQueue() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
||||||
|
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Added ${widget.artist.name} radio to queue'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding artist radio to queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to add to queue: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadArtistImage() async {
|
Future<void> _loadArtistImage() async {
|
||||||
@@ -113,16 +374,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
// Only rebuild when specific properties change, not on every provider update
|
||||||
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
|
(provider) => provider.adaptiveTheme,
|
||||||
|
);
|
||||||
|
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveLightScheme,
|
||||||
|
);
|
||||||
|
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveDarkScheme,
|
||||||
|
);
|
||||||
|
|
||||||
// Use the loaded image URL (with fallback) instead of directly from MA
|
// Use the loaded image URL (with fallback) instead of directly from MA
|
||||||
final imageUrl = _artistImageUrl;
|
final imageUrl = _artistImageUrl;
|
||||||
|
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Get the color scheme to use - prefer local state over provider
|
||||||
ColorScheme? adaptiveScheme;
|
ColorScheme? adaptiveScheme;
|
||||||
if (useAdaptiveTheme) {
|
if (adaptiveTheme) {
|
||||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
// Use local state first (from _extractColors), fallback to provider
|
||||||
|
adaptiveScheme = isDark
|
||||||
|
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||||
|
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||||
}
|
}
|
||||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
@@ -226,6 +501,89 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
// Action Buttons Row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Main Radio Button
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _startArtistRadio,
|
||||||
|
icon: const Icon(Icons.radio),
|
||||||
|
label: const Text('Radio'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// "Play on..." Button (Square)
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _showPlayOnMenu(context),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.speaker_group_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// "Add to Queue" Button (Square)
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _addArtistRadioToQueue,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.playlist_add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Favorite Button
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _toggleFavorite,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _isFavorite
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -275,7 +633,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final album = _albums[index];
|
final album = _albums[index];
|
||||||
return _buildAlbumCard(album, maProvider);
|
return _buildAlbumCard(album);
|
||||||
},
|
},
|
||||||
childCount: _albums.length,
|
childCount: _albums.length,
|
||||||
),
|
),
|
||||||
@@ -309,7 +667,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final album = _providerAlbums[index];
|
final album = _providerAlbums[index];
|
||||||
return _buildAlbumCard(album, maProvider);
|
return _buildAlbumCard(album);
|
||||||
},
|
},
|
||||||
childCount: _providerAlbums.length,
|
childCount: _providerAlbums.length,
|
||||||
),
|
),
|
||||||
@@ -324,8 +682,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
Widget _buildAlbumCard(Album album) {
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
// Use read instead of passing provider to avoid rebuild dependencies
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(album, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
const String heroSuffix = 'artist_albums';
|
const String heroSuffix = 'artist_albums';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
|
|||||||
class _DebugLogScreenState extends State<DebugLogScreen> {
|
class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
LogLevel _filterLevel = LogLevel.debug; // Show all by default
|
||||||
|
bool _isGeneratingReport = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get _filteredEntries =>
|
||||||
|
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||||
|
|
||||||
void _copyLogs() {
|
void _copyLogs() {
|
||||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Logs copied to clipboard!'),
|
content: Text('Logs copied to clipboard'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _shareBugReport() async {
|
||||||
|
setState(() => _isGeneratingReport = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final report = await _logger.generateBugReport();
|
||||||
|
|
||||||
|
await Share.share(
|
||||||
|
report,
|
||||||
|
subject: 'Ensemble Bug Report',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isGeneratingReport = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _clearLogs() {
|
void _clearLogs() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_logger.clear();
|
_logger.clear();
|
||||||
@@ -216,16 +239,39 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
|
// Share bug report button
|
||||||
|
_isGeneratingReport
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.share_rounded),
|
||||||
|
onPressed: _shareBugReport,
|
||||||
|
color: Colors.white,
|
||||||
|
tooltip: 'Share bug report',
|
||||||
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
tooltip: 'Player tools',
|
tooltip: 'More options',
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'show_players':
|
case 'show_players':
|
||||||
_showAllPlayers();
|
_showAllPlayers();
|
||||||
break;
|
break;
|
||||||
// NOTE: Ghost cleanup and repair options removed - MA APIs don't work
|
case 'copy_logs':
|
||||||
// reliably and caused corrupt entries. See PLAYER_LIFECYCLE_GUIDE.md
|
_copyLogs();
|
||||||
|
break;
|
||||||
|
case 'clear_logs':
|
||||||
|
_clearLogs();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
@@ -237,49 +283,61 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'copy_logs',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.copy_rounded),
|
||||||
|
title: Text('Copy Logs'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear_logs',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_rounded),
|
||||||
|
title: Text('Clear Logs'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.copy_rounded),
|
|
||||||
onPressed: _copyLogs,
|
|
||||||
color: Colors.white,
|
|
||||||
tooltip: 'Copy logs',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_rounded),
|
|
||||||
onPressed: _clearLogs,
|
|
||||||
color: Colors.white,
|
|
||||||
tooltip: 'Clear logs',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Filter chips and stats
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
color: Colors.white.withOpacity(0.05),
|
color: Colors.white.withOpacity(0.05),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
// Level filter chips
|
||||||
Icons.info_outline_rounded,
|
SingleChildScrollView(
|
||||||
color: Colors.white70,
|
scrollDirection: Axis.horizontal,
|
||||||
size: 16,
|
child: Row(
|
||||||
),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_buildFilterChip('All', LogLevel.debug),
|
||||||
Expanded(
|
const SizedBox(width: 8),
|
||||||
child: Text(
|
_buildFilterChip('Info+', LogLevel.info),
|
||||||
'Last ${_logger.logs.length} log entries',
|
const SizedBox(width: 8),
|
||||||
style: const TextStyle(
|
_buildFilterChip('Warnings', LogLevel.warning),
|
||||||
color: Colors.white70,
|
const SizedBox(width: 8),
|
||||||
fontSize: 12,
|
_buildFilterChip('Errors', LogLevel.error),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Stats
|
||||||
|
Text(
|
||||||
|
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _logger.logs.isEmpty
|
child: _filteredEntries.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No logs yet',
|
'No logs yet',
|
||||||
@@ -291,43 +349,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56), // Extra space for FAB
|
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
|
||||||
itemCount: _logger.logs.length,
|
itemCount: _filteredEntries.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final log = _logger.logs[index];
|
final entry = _filteredEntries[index];
|
||||||
final isError = log.contains('Error') ||
|
return _buildLogEntry(entry);
|
||||||
log.contains('error') ||
|
|
||||||
log.contains('ERROR') ||
|
|
||||||
log.contains('failed') ||
|
|
||||||
log.contains('Failed');
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isError
|
|
||||||
? Colors.red.withOpacity(0.1)
|
|
||||||
: Colors.white.withOpacity(0.03),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(
|
|
||||||
color: isError
|
|
||||||
? Colors.red.withOpacity(0.3)
|
|
||||||
: Colors.white.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
log,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isError ? Colors.red[300] : Colors.white70,
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -353,4 +379,70 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChip(String label, LogLevel level) {
|
||||||
|
final isSelected = _filterLevel == level;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: (_) {
|
||||||
|
setState(() {
|
||||||
|
_filterLevel = level;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.1),
|
||||||
|
selectedColor: _getLevelColor(level).withOpacity(0.3),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? _getLevelColor(level) : Colors.white70,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected ? _getLevelColor(level) : Colors.transparent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogEntry(LogEntry entry) {
|
||||||
|
final color = _getLevelColor(entry.level);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.formatted,
|
||||||
|
style: TextStyle(
|
||||||
|
color: entry.level == LogLevel.error
|
||||||
|
? Colors.red[300]
|
||||||
|
: entry.level == LogLevel.warning
|
||||||
|
? Colors.orange[300]
|
||||||
|
: Colors.white70,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLevelColor(LogLevel level) {
|
||||||
|
switch (level) {
|
||||||
|
case LogLevel.perf:
|
||||||
|
return Colors.purple;
|
||||||
|
case LogLevel.debug:
|
||||||
|
return Colors.grey;
|
||||||
|
case LogLevel.info:
|
||||||
|
return Colors.blue;
|
||||||
|
case LogLevel.warning:
|
||||||
|
return Colors.orange;
|
||||||
|
case LogLevel.error:
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
key: const PageStorageKey<String>('library_albums_full_grid'),
|
key: const PageStorageKey<String>('library_albums_full_grid'),
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Cards have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Cards have RepaintBoundary
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
key: const PageStorageKey<String>('library_artists_full_list'),
|
key: const PageStorageKey<String>('library_artists_full_list'),
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Tiles have RepaintBoundary
|
||||||
itemCount: artists.length,
|
itemCount: artists.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -90,6 +92,9 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Get image URL for hero animation
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
// RepaintBoundary isolates repaints to individual tiles
|
// RepaintBoundary isolates repaints to individual tiles
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
@@ -116,6 +121,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
child: ArtistDetailsScreen(
|
child: ArtistDetailsScreen(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
heroTagSuffix: 'library',
|
heroTagSuffix: 'library',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _playlists.length,
|
itemCount: _playlists.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final playlist = _playlists[index];
|
final playlist = _playlists[index];
|
||||||
return _buildPlaylistTile(context, playlist, provider);
|
return _buildPlaylistTile(context, playlist, provider);
|
||||||
@@ -104,8 +107,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Builder(
|
return RepaintBoundary(
|
||||||
builder: (context) => ListTile(
|
child: ListTile(
|
||||||
|
key: ValueKey(playlist.itemId),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: provider.tracks.length,
|
itemCount: provider.tracks.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final track = provider.tracks[index];
|
final track = provider.tracks[index];
|
||||||
return _buildTrackTile(context, track, provider, index);
|
return _buildTrackTile(context, track, provider, index);
|
||||||
@@ -73,43 +76,46 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
width: 48,
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
height: 48,
|
leading: Container(
|
||||||
decoration: BoxDecoration(
|
width: 48,
|
||||||
color: colorScheme.surfaceVariant,
|
height: 48,
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
image: imageUrl != null
|
color: colorScheme.surfaceVariant,
|
||||||
? DecorationImage(
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
image: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? DecorationImage(
|
||||||
)
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
title: Text(
|
||||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
track.name,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: track.duration != null
|
||||||
|
? Text(
|
||||||
|
_formatDuration(track.duration!),
|
||||||
|
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
|
onTap: () => _playTrack(context, maProvider, index),
|
||||||
),
|
),
|
||||||
title: Text(
|
|
||||||
track.name,
|
|
||||||
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
track.artistsString,
|
|
||||||
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: track.duration != null
|
|
||||||
? Text(
|
|
||||||
_formatDuration(track.duration!),
|
|
||||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () => _playTrack(context, maProvider, index),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.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/debug_logger.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
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/track_row.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -16,17 +20,73 @@ class NewHomeScreen extends StatefulWidget {
|
|||||||
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
|
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
Key _refreshKey = UniqueKey();
|
Key _refreshKey = UniqueKey();
|
||||||
|
// Main rows (default on)
|
||||||
|
bool _showRecentAlbums = true;
|
||||||
|
bool _showDiscoverArtists = true;
|
||||||
|
bool _showDiscoverAlbums = true;
|
||||||
|
// Favorites rows (default off)
|
||||||
|
bool _showFavoriteAlbums = false;
|
||||||
|
bool _showFavoriteArtists = false;
|
||||||
|
bool _showFavoriteTracks = false;
|
||||||
|
// Random order for favorites (generated once per session)
|
||||||
|
late List<int> _favoritesOrder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// Generate random order for favorites rows (0, 1, 2 shuffled)
|
||||||
|
_favoritesOrder = [0, 1, 2]..shuffle();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// Reload settings when app resumes (coming back from settings)
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||||
|
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||||
|
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||||
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showRecentAlbums = showRecent;
|
||||||
|
_showDiscoverArtists = showDiscArtists;
|
||||||
|
_showDiscoverAlbums = showDiscAlbums;
|
||||||
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
|
_showFavoriteArtists = showFavArtists;
|
||||||
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onRefresh() async {
|
Future<void> _onRefresh() async {
|
||||||
// Invalidate cache to force fresh data on pull-to-refresh
|
// Invalidate cache to force fresh data on pull-to-refresh
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
provider.invalidateHomeCache();
|
provider.invalidateHomeCache();
|
||||||
|
|
||||||
|
// Reload settings in case they changed
|
||||||
|
await _loadSettings();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_refreshKey = UniqueKey();
|
_refreshKey = UniqueKey();
|
||||||
@@ -37,6 +97,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
// Settings are loaded in initState and didChangeAppLifecycleState - no need to reload on every build
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -96,44 +157,126 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
|
|
||||||
Widget _buildConnectedView(
|
Widget _buildConnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
// Use Android 12+ stretch overscroll effect
|
// Use LayoutBuilder to get available screen height
|
||||||
return ScrollConfiguration(
|
return LayoutBuilder(
|
||||||
behavior: const _StretchScrollBehavior(),
|
builder: (context, constraints) {
|
||||||
child: SingleChildScrollView(
|
// Simple calculation: available height divided by 3 rows
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
// Each row includes its title, artwork, and info
|
||||||
child: Column(
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
key: _refreshKey,
|
final rowHeight = availableHeight / 3;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Recently played albums (with caching)
|
|
||||||
// Stable keys prevent widget recreation on parent rebuilds
|
|
||||||
AlbumRow(
|
|
||||||
key: const ValueKey('recent-albums'),
|
|
||||||
title: 'Recently Played',
|
|
||||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 21),
|
|
||||||
|
|
||||||
// Discover Artists (with caching)
|
// Build favorites rows in random order
|
||||||
ArtistRow(
|
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
|
||||||
key: const ValueKey('discover-artists'),
|
|
||||||
title: 'Discover Artists',
|
|
||||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
|
|
||||||
// Discover Albums (with caching)
|
// Use Android 12+ stretch overscroll effect
|
||||||
AlbumRow(
|
return NotificationListener<ScrollNotification>(
|
||||||
key: const ValueKey('discover-albums'),
|
onNotification: (notification) {
|
||||||
title: 'Discover Albums',
|
if (notification is ScrollStartNotification) {
|
||||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
_logger.resetFrameStats();
|
||||||
|
_logger.perf('SCROLL START', context: 'HomeScreen');
|
||||||
|
} else if (notification is ScrollUpdateNotification) {
|
||||||
|
_logger.startFrame();
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_logger.endFrame();
|
||||||
|
});
|
||||||
|
} else if (notification is ScrollEndNotification) {
|
||||||
|
_logger.perf('SCROLL END', context: 'HomeScreen');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
|
||||||
|
child: Column(
|
||||||
|
key: _refreshKey,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Recently played albums (optional)
|
||||||
|
if (_showRecentAlbums)
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('recent-albums'),
|
||||||
|
title: 'Recently Played',
|
||||||
|
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Discover Artists (optional)
|
||||||
|
if (_showDiscoverArtists)
|
||||||
|
ArtistRow(
|
||||||
|
key: const ValueKey('discover-artists'),
|
||||||
|
title: 'Discover Artists',
|
||||||
|
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Discover Albums (optional)
|
||||||
|
if (_showDiscoverAlbums)
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('discover-albums'),
|
||||||
|
title: 'Discover Albums',
|
||||||
|
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Favorites rows in random order
|
||||||
|
...favoritesWidgets,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: BottomSpacing.withMiniPlayer), // Space for bottom nav + mini player
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build favorites rows in random order (order set once per session)
|
||||||
|
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
|
||||||
|
// Create list of enabled favorites with their order index
|
||||||
|
final enabledFavorites = <MapEntry<int, Widget>>[];
|
||||||
|
|
||||||
|
if (_showFavoriteAlbums) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[0],
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('favorite-albums'),
|
||||||
|
title: 'Favorite Albums',
|
||||||
|
loadAlbums: () => provider.getFavoriteAlbums(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_showFavoriteArtists) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[1],
|
||||||
|
ArtistRow(
|
||||||
|
key: const ValueKey('favorite-artists'),
|
||||||
|
title: 'Favorite Artists',
|
||||||
|
loadArtists: () => provider.getFavoriteArtists(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_showFavoriteTracks) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[2],
|
||||||
|
TrackRow(
|
||||||
|
key: const ValueKey('favorite-tracks'),
|
||||||
|
title: 'Favorite Tracks',
|
||||||
|
loadTracks: () => provider.getFavoriteTracks(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by the random order index
|
||||||
|
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return enabledFavorites.map((e) => e.value).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
with SingleTickerProviderStateMixin, RestorationMixin {
|
with SingleTickerProviderStateMixin, RestorationMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
List<Playlist> _playlists = [];
|
List<Playlist> _playlists = [];
|
||||||
|
List<Track> _favoriteTracks = [];
|
||||||
bool _isLoadingPlaylists = true;
|
bool _isLoadingPlaylists = true;
|
||||||
|
bool _isLoadingTracks = false;
|
||||||
|
bool _showFavoritesOnly = false;
|
||||||
|
|
||||||
// Restoration: Remember selected tab across app restarts
|
// Restoration: Remember selected tab across app restarts
|
||||||
final RestorableInt _selectedTabIndex = RestorableInt(0);
|
final RestorableInt _selectedTabIndex = RestorableInt(0);
|
||||||
|
|
||||||
|
int get _tabCount => _showFavoritesOnly ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get restorationId => 'new_library_screen';
|
String? get restorationId => 'new_library_screen';
|
||||||
|
|
||||||
@@ -39,7 +44,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
|
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
|
||||||
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
|
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
|
||||||
// Apply restored tab index after TabController is created
|
// Apply restored tab index after TabController is created
|
||||||
if (_tabController.index != _selectedTabIndex.value) {
|
if (_tabController.index != _selectedTabIndex.value &&
|
||||||
|
_selectedTabIndex.value < _tabController.length) {
|
||||||
_tabController.index = _selectedTabIndex.value;
|
_tabController.index = _selectedTabIndex.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,12 +53,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: _tabCount, vsync: this);
|
||||||
// Listen to tab changes to persist selection
|
// Listen to tab changes to persist selection
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
_loadPlaylists();
|
_loadPlaylists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _recreateTabController() {
|
||||||
|
final oldIndex = _tabController.index;
|
||||||
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
_tabController.dispose();
|
||||||
|
_tabController = TabController(length: _tabCount, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
|
// Restore to previous index if valid, otherwise default to 0
|
||||||
|
if (oldIndex < _tabCount) {
|
||||||
|
_tabController.index = oldIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
if (!_tabController.indexIsChanging) {
|
if (!_tabController.indexIsChanging) {
|
||||||
_selectedTabIndex.value = _tabController.index;
|
_selectedTabIndex.value = _tabController.index;
|
||||||
@@ -67,10 +85,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadPlaylists() async {
|
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api != null) {
|
if (maProvider.api != null) {
|
||||||
final playlists = await maProvider.api!.getPlaylists(limit: 100);
|
final playlists = await maProvider.api!.getPlaylists(
|
||||||
|
limit: 100,
|
||||||
|
favoriteOnly: favoriteOnly,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_playlists = playlists;
|
_playlists = playlists;
|
||||||
@@ -86,6 +107,47 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFavoriteTracks() async {
|
||||||
|
if (_isLoadingTracks) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingTracks = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api != null) {
|
||||||
|
final tracks = await maProvider.api!.getTracks(
|
||||||
|
limit: 500,
|
||||||
|
favoriteOnly: true,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_favoriteTracks = tracks;
|
||||||
|
_isLoadingTracks = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingTracks = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavoritesMode(bool value) {
|
||||||
|
setState(() {
|
||||||
|
_showFavoritesOnly = value;
|
||||||
|
_recreateTabController();
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
_loadPlaylists(favoriteOnly: true);
|
||||||
|
_loadFavoriteTracks();
|
||||||
|
} else {
|
||||||
|
_loadPlaylists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Use Selector for targeted rebuilds - only rebuild when connection state changes
|
// Use Selector for targeted rebuilds - only rebuild when connection state changes
|
||||||
@@ -125,15 +187,40 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(
|
titleSpacing: 16,
|
||||||
'Library',
|
title: Row(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
children: [
|
||||||
color: colorScheme.onBackground,
|
// Favorites toggle with label
|
||||||
fontWeight: FontWeight.w300,
|
GestureDetector(
|
||||||
),
|
onTap: () => _toggleFavoritesMode(!_showFavoritesOnly),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Switch(
|
||||||
|
value: _showFavoritesOnly,
|
||||||
|
onChanged: _toggleFavoritesMode,
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Favourites',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: _showFavoritesOnly
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontWeight: _showFavoritesOnly ? FontWeight.w500 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: false,
|
||||||
actions: const [PlayerSelector()],
|
actions: const [
|
||||||
|
PlayerSelector(),
|
||||||
|
],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
labelColor: colorScheme.primary,
|
labelColor: colorScheme.primary,
|
||||||
@@ -142,10 +229,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
indicatorWeight: 3,
|
indicatorWeight: 3,
|
||||||
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||||
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
|
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
|
||||||
tabs: const [
|
tabs: [
|
||||||
Tab(text: 'Artists'),
|
const Tab(text: 'Artists'),
|
||||||
Tab(text: 'Albums'),
|
const Tab(text: 'Albums'),
|
||||||
Tab(text: 'Playlists'),
|
if (_showFavoritesOnly) const Tab(text: 'Tracks'),
|
||||||
|
const Tab(text: 'Playlists'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -154,6 +242,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
children: [
|
children: [
|
||||||
_buildArtistsTab(context),
|
_buildArtistsTab(context),
|
||||||
_buildAlbumsTab(context),
|
_buildAlbumsTab(context),
|
||||||
|
if (_showFavoritesOnly) _buildTracksTab(context),
|
||||||
_buildPlaylistsTab(context),
|
_buildPlaylistsTab(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -168,14 +257,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||||
builder: (context, data, _) {
|
builder: (context, data, _) {
|
||||||
final (artists, isLoading) = data;
|
final (allArtists, isLoading) = data;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by favorites if enabled
|
||||||
|
final artists = _showFavoritesOnly
|
||||||
|
? allArtists.where((a) => a.favorite == true).toList()
|
||||||
|
: allArtists;
|
||||||
|
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite artists',
|
||||||
|
subtitle: 'Tap the heart on an artist to add them to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
return EmptyState.artists(
|
return EmptyState.artists(
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
@@ -186,8 +287,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
key: const PageStorageKey<String>('library_artists_list'),
|
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Tiles have RepaintBoundary
|
||||||
itemCount: artists.length,
|
itemCount: artists.length,
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -211,7 +314,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final suffix = '_library';
|
final suffix = '_library';
|
||||||
|
// Get image URL for hero animation
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -221,9 +327,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
radius: 24,
|
radius: 24,
|
||||||
imageSize: 128,
|
imageSize: 128,
|
||||||
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||||
onImageLoaded: (imageUrl) {
|
|
||||||
// Store for adaptive colors on tap
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
title: Hero(
|
title: Hero(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||||
@@ -247,6 +350,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
child: ArtistDetailsScreen(
|
child: ArtistDetailsScreen(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
heroTagSuffix: 'library',
|
heroTagSuffix: 'library',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -261,14 +365,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||||
selector: (_, provider) => (provider.albums, provider.isLoading),
|
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||||
builder: (context, data, _) {
|
builder: (context, data, _) {
|
||||||
final (albums, isLoading) = data;
|
final (allAlbums, isLoading) = data;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by favorites if enabled
|
||||||
|
final albums = _showFavoritesOnly
|
||||||
|
? allAlbums.where((a) => a.favorite == true).toList()
|
||||||
|
: allAlbums;
|
||||||
|
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite albums',
|
||||||
|
subtitle: 'Tap the heart on an album to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
return EmptyState.albums(
|
return EmptyState.albums(
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
@@ -279,8 +395,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
key: const PageStorageKey<String>('library_albums_grid'),
|
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Cards have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Cards have RepaintBoundary
|
||||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
@@ -312,16 +430,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_playlists.isEmpty) {
|
if (_playlists.isEmpty) {
|
||||||
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite playlists',
|
||||||
|
subtitle: 'Tap the heart on a playlist to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyState.playlists(onRefresh: () => _loadPlaylists());
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: _loadPlaylists,
|
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
key: const PageStorageKey<String>('library_playlists_list'),
|
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemCount: _playlists.length,
|
itemCount: _playlists.length,
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -338,8 +465,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
|
key: ValueKey(playlist.itemId),
|
||||||
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -370,22 +499,140 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: playlist.favorite == true
|
trailing: playlist.favorite == true
|
||||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => PlaylistDetailsScreen(
|
builder: (context) => PlaylistDetailsScreen(
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
provider: playlist.provider,
|
provider: playlist.provider,
|
||||||
itemId: playlist.itemId,
|
itemId: playlist.itemId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ TRACKS TAB (favorites only) ============
|
||||||
|
Widget _buildTracksTab(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (_isLoadingTracks) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_favoriteTracks.isEmpty) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite tracks',
|
||||||
|
subtitle: 'Long-press a track and tap the heart to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort tracks by artist name, then track name
|
||||||
|
final sortedTracks = List<Track>.from(_favoriteTracks)
|
||||||
|
..sort((a, b) {
|
||||||
|
final artistCompare = a.artistsString.compareTo(b.artistsString);
|
||||||
|
if (artistCompare != 0) return artistCompare;
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
onRefresh: _loadFavoriteTracks,
|
||||||
|
child: ListView.builder(
|
||||||
|
key: const PageStorageKey<String>('library_tracks_list'),
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
|
itemCount: sortedTracks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final track = sortedTracks[index];
|
||||||
|
return _buildTrackTile(context, track);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackTile(BuildContext context, Track track) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Get image URL from track itself
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
leading: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
image: imageUrl != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.name,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
if (player == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start radio from this track
|
||||||
|
await maProvider.api?.playRadio(player.playerId, track);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to start radio: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _playlistLogger = DebugLogger();
|
||||||
|
|
||||||
class PlaylistDetailsScreen extends StatefulWidget {
|
class PlaylistDetailsScreen extends StatefulWidget {
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
@@ -61,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}');
|
_playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
|
||||||
|
|
||||||
// Queue all tracks via Music Assistant
|
// Queue all tracks via Music Assistant
|
||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||||
print('✓ Playlist queued on ${player.name}');
|
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -76,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error playing playlist: $e');
|
_playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
|
||||||
_showError('Error playing playlist: $e');
|
_showError('Error playing playlist: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
void _onSearchChanged(String query) {
|
void _onSearchChanged(String query) {
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_debounceTimer = Timer(Timings.searchDebounce, () {
|
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||||
_performSearch(query);
|
_performSearch(query, keepFocus: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||||
@@ -129,6 +129,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
});
|
});
|
||||||
|
// Keep keyboard open if user is still typing
|
||||||
|
if (keepFocus && _focusNode.hasFocus) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Search error: $e');
|
_logger.log('Search error: $e');
|
||||||
@@ -138,6 +142,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
_searchError = 'Search failed. Please check your connection.';
|
_searchError = 'Search failed. Please check your connection.';
|
||||||
});
|
});
|
||||||
|
if (keepFocus && _focusNode.hasFocus) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +164,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
cursorColor: colorScheme.primary,
|
cursorColor: colorScheme.primary,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search music...',
|
hintText: 'Search music...',
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
@@ -166,15 +174,20 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_performSearch('');
|
setState(() {
|
||||||
|
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
_hasSearched = false;
|
||||||
|
_searchError = null;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
|
setState(() {}); // Update clear button visibility
|
||||||
_onSearchChanged(value);
|
_onSearchChanged(value);
|
||||||
},
|
},
|
||||||
onSubmitted: _performSearch,
|
onSubmitted: (query) => _performSearch(query),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: !maProvider.isConnected
|
body: !maProvider.isConnected
|
||||||
@@ -286,6 +299,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final listItems = _buildListItems(artists, albums, tracks);
|
final listItems = _buildListItems(artists, albums, tracks);
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemCount: listItems.length,
|
itemCount: listItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = listItems[index];
|
final item = listItems[index];
|
||||||
@@ -391,8 +407,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
Widget _buildArtistTile(Artist artist) {
|
Widget _buildArtistTile(Artist artist) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
leading: ArtistAvatar(
|
leading: ArtistAvatar(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
radius: 24,
|
radius: 24,
|
||||||
@@ -411,14 +431,18 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
'Artist',
|
'Artist',
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
builder: (context) => ArtistDetailsScreen(
|
||||||
),
|
artist: artist,
|
||||||
);
|
initialImageUrl: imageUrl,
|
||||||
},
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,8 +451,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -460,14 +486,15 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
builder: (context) => AlbumDetailsScreen(album: album),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +505,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
: null;
|
: null;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -511,13 +540,14 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _playTrack(track),
|
onTap: () => _playTrack(track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final _lastFmApiKeyController = TextEditingController();
|
final _lastFmApiKeyController = TextEditingController();
|
||||||
final _audioDbApiKeyController = TextEditingController();
|
final _audioDbApiKeyController = TextEditingController();
|
||||||
|
// Main rows (default on)
|
||||||
|
bool _showRecentAlbums = true;
|
||||||
|
bool _showDiscoverArtists = true;
|
||||||
|
bool _showDiscoverAlbums = true;
|
||||||
|
// Favorites rows (default off)
|
||||||
|
bool _showFavoriteAlbums = false;
|
||||||
|
bool _showFavoriteArtists = false;
|
||||||
|
bool _showFavoriteTracks = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,6 +43,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (audioDbKey != null) {
|
if (audioDbKey != null) {
|
||||||
_audioDbApiKeyController.text = audioDbKey;
|
_audioDbApiKeyController.text = audioDbKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load home screen settings
|
||||||
|
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||||
|
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||||
|
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||||
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showRecentAlbums = showRecent;
|
||||||
|
_showDiscoverArtists = showDiscArtists;
|
||||||
|
_showDiscoverAlbums = showDiscAlbums;
|
||||||
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
|
_showFavoriteArtists = showFavArtists;
|
||||||
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -308,6 +334,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Home Screen section
|
||||||
|
Text(
|
||||||
|
'Home Screen',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Choose which rows to display on the home screen',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Main rows section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Recently Played',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show recently played albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showRecentAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showRecentAlbums = value);
|
||||||
|
SettingsService.setShowRecentAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Discover Artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show random artists to discover',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showDiscoverArtists,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showDiscoverArtists = value);
|
||||||
|
SettingsService.setShowDiscoverArtists(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Discover Albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show random albums to discover',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showDiscoverAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showDiscoverAlbums = value);
|
||||||
|
SettingsService.setShowDiscoverAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Favorites',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Favorites rows section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteAlbums = value);
|
||||||
|
SettingsService.setShowFavoriteAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteArtists,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteArtists = value);
|
||||||
|
SettingsService.setShowFavoriteArtists(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Tracks',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite tracks',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteTracks,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteTracks = value);
|
||||||
|
SettingsService.setShowFavoriteTracks(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Metadata APIs (Optional)',
|
'Metadata APIs (Optional)',
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Comprehensive animation debugging utility for diagnosing jank and performance issues.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// 1. Call AnimationDebugger.startSession('playerExpand') at animation start
|
||||||
|
/// 2. Call AnimationDebugger.recordFrame() in animation listener (every frame)
|
||||||
|
/// 3. Call AnimationDebugger.endSession() when animation completes
|
||||||
|
/// 4. Logs will show frame timing, jank detection, and summary stats
|
||||||
|
class AnimationDebugger {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
static bool _enabled = true;
|
||||||
|
|
||||||
|
// Session tracking
|
||||||
|
static String? _currentSession;
|
||||||
|
static int _frameCount = 0;
|
||||||
|
static final List<_FrameData> _frames = [];
|
||||||
|
static DateTime? _sessionStart;
|
||||||
|
static double? _lastAnimationValue;
|
||||||
|
|
||||||
|
// Scheduler binding for frame timing
|
||||||
|
static Duration? _lastFrameTime;
|
||||||
|
|
||||||
|
/// Enable or disable animation debugging
|
||||||
|
static void setEnabled(bool enabled) {
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new animation debugging session
|
||||||
|
static void startSession(String name) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
_currentSession = name;
|
||||||
|
_frameCount = 0;
|
||||||
|
_frames.clear();
|
||||||
|
_sessionStart = DateTime.now();
|
||||||
|
_lastFrameTime = null;
|
||||||
|
_lastAnimationValue = null;
|
||||||
|
|
||||||
|
// Listen to frame timing
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback(_onFirstFrame);
|
||||||
|
|
||||||
|
_logger.log('🎬 ANIM[$name] START');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onFirstFrame(Duration timestamp) {
|
||||||
|
_lastFrameTime = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a frame during animation
|
||||||
|
/// [animationValue] - current animation progress (0.0 to 1.0)
|
||||||
|
/// [context] - optional context string for this frame
|
||||||
|
static void recordFrame(double animationValue, {String? context}) {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||||
|
|
||||||
|
Duration? frameDuration;
|
||||||
|
if (_lastFrameTime != null) {
|
||||||
|
frameDuration = frameTime - _lastFrameTime!;
|
||||||
|
}
|
||||||
|
_lastFrameTime = frameTime;
|
||||||
|
|
||||||
|
// Calculate animation delta
|
||||||
|
double? animationDelta;
|
||||||
|
if (_lastAnimationValue != null) {
|
||||||
|
animationDelta = (animationValue - _lastAnimationValue!).abs();
|
||||||
|
}
|
||||||
|
_lastAnimationValue = animationValue;
|
||||||
|
|
||||||
|
final frame = _FrameData(
|
||||||
|
frameNumber: _frameCount,
|
||||||
|
timestamp: now,
|
||||||
|
frameDuration: frameDuration,
|
||||||
|
animationValue: animationValue,
|
||||||
|
animationDelta: animationDelta,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
_frames.add(frame);
|
||||||
|
_frameCount++;
|
||||||
|
|
||||||
|
// Log jank immediately (frames > 16.67ms for 60fps)
|
||||||
|
if (frameDuration != null && frameDuration.inMicroseconds > 16667) {
|
||||||
|
final ms = frameDuration.inMicroseconds / 1000;
|
||||||
|
_logger.log('🔴 ANIM[$_currentSession] JANK frame $_frameCount: ${ms.toStringAsFixed(2)}ms (>${(ms/16.67).toStringAsFixed(1)}x target)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a build/layout event during animation
|
||||||
|
static void recordBuild(String widgetName) {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
_logger.log('🔨 ANIM[$_currentSession] BUILD: $widgetName at frame $_frameCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the current session and log summary
|
||||||
|
static void endSession() {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
|
||||||
|
final sessionName = _currentSession!;
|
||||||
|
final totalDuration = DateTime.now().difference(_sessionStart!);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
int jankFrames = 0;
|
||||||
|
int severeJankFrames = 0;
|
||||||
|
double maxFrameMs = 0;
|
||||||
|
double totalFrameMs = 0;
|
||||||
|
int measuredFrames = 0;
|
||||||
|
|
||||||
|
for (final frame in _frames) {
|
||||||
|
if (frame.frameDuration != null) {
|
||||||
|
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||||
|
totalFrameMs += ms;
|
||||||
|
measuredFrames++;
|
||||||
|
|
||||||
|
if (ms > maxFrameMs) maxFrameMs = ms;
|
||||||
|
if (ms > 16.67) jankFrames++;
|
||||||
|
if (ms > 33.33) severeJankFrames++; // Dropped more than 1 frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final avgFrameMs = measuredFrames > 0 ? totalFrameMs / measuredFrames : 0;
|
||||||
|
final effectiveFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0;
|
||||||
|
|
||||||
|
// Build summary
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('🎬 ANIMATION SUMMARY: $sessionName');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('Duration: ${totalDuration.inMilliseconds}ms');
|
||||||
|
buffer.writeln('Total frames: $_frameCount');
|
||||||
|
buffer.writeln('Measured frames: $measuredFrames');
|
||||||
|
buffer.writeln('Average frame: ${avgFrameMs.toStringAsFixed(2)}ms');
|
||||||
|
buffer.writeln('Max frame: ${maxFrameMs.toStringAsFixed(2)}ms');
|
||||||
|
buffer.writeln('Effective FPS: ${effectiveFps.toStringAsFixed(1)}');
|
||||||
|
buffer.writeln('Jank frames (>16.67ms): $jankFrames (${measuredFrames > 0 ? (jankFrames * 100 / measuredFrames).toStringAsFixed(1) : 0}%)');
|
||||||
|
buffer.writeln('Severe jank (>33.33ms): $severeJankFrames');
|
||||||
|
|
||||||
|
// List worst frames
|
||||||
|
if (_frames.isNotEmpty) {
|
||||||
|
final sortedFrames = List<_FrameData>.from(_frames)
|
||||||
|
..sort((a, b) {
|
||||||
|
final aMs = a.frameDuration?.inMicroseconds ?? 0;
|
||||||
|
final bMs = b.frameDuration?.inMicroseconds ?? 0;
|
||||||
|
return bMs.compareTo(aMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
final worstFrames = sortedFrames.take(5).where((f) => f.frameDuration != null && f.frameDuration!.inMicroseconds > 16667).toList();
|
||||||
|
|
||||||
|
if (worstFrames.isNotEmpty) {
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('Worst frames:');
|
||||||
|
for (final frame in worstFrames) {
|
||||||
|
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||||
|
buffer.writeln(' Frame ${frame.frameNumber}: ${ms.toStringAsFixed(2)}ms (anim=${frame.animationValue.toStringAsFixed(3)})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
|
||||||
|
_logger.log(buffer.toString());
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
_currentSession = null;
|
||||||
|
_frames.clear();
|
||||||
|
_sessionStart = null;
|
||||||
|
_lastAnimationValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a one-off animation event (for Hero transitions, page transitions, etc.)
|
||||||
|
static void logEvent(String event, {String? details}) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
final detailStr = details != null ? ' - $details' : '';
|
||||||
|
_logger.log('🎬 ANIM_EVENT: $event$detailStr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log frame callback timing (call this from addPostFrameCallback)
|
||||||
|
static void logPostFrame(String context) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||||
|
_logger.log('📐 POST_FRAME[$context]: ${frameTime.inMilliseconds}ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a HeroFlightShuttleBuilder that logs Hero animation progress
|
||||||
|
static HeroFlightShuttleBuilder createHeroDebugShuttle(String heroTag) {
|
||||||
|
return (
|
||||||
|
BuildContext flightContext,
|
||||||
|
Animation<double> animation,
|
||||||
|
HeroFlightDirection flightDirection,
|
||||||
|
BuildContext fromHeroContext,
|
||||||
|
BuildContext toHeroContext,
|
||||||
|
) {
|
||||||
|
// Log Hero flight start
|
||||||
|
if (animation.value < 0.1) {
|
||||||
|
logEvent('Hero flight START', details: 'tag=$heroTag, direction=${flightDirection.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener for completion (one-shot)
|
||||||
|
void onComplete(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
|
||||||
|
logEvent('Hero flight END', details: 'tag=$heroTag, status=${status.name}');
|
||||||
|
animation.removeStatusListener(onComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animation.addStatusListener(onComplete);
|
||||||
|
|
||||||
|
// Return default shuttle (the toHero's child)
|
||||||
|
final Hero toHero = toHeroContext.widget as Hero;
|
||||||
|
return toHero.child;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrameData {
|
||||||
|
final int frameNumber;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final Duration? frameDuration;
|
||||||
|
final double animationValue;
|
||||||
|
final double? animationDelta;
|
||||||
|
final String? context;
|
||||||
|
|
||||||
|
_FrameData({
|
||||||
|
required this.frameNumber,
|
||||||
|
required this.timestamp,
|
||||||
|
this.frameDuration,
|
||||||
|
required this.animationValue,
|
||||||
|
this.animationDelta,
|
||||||
|
this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,32 +1,243 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
|
/// Log levels for filtering and display
|
||||||
|
enum LogLevel {
|
||||||
|
perf, // Performance timing (for FPS diagnosis)
|
||||||
|
debug, // Verbose debugging info (hidden by default)
|
||||||
|
info, // General information
|
||||||
|
warning, // Potential issues
|
||||||
|
error, // Errors and failures
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final LogLevel level;
|
||||||
|
final String message;
|
||||||
|
final String? context;
|
||||||
|
|
||||||
|
LogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.message,
|
||||||
|
this.context,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get formatted {
|
||||||
|
final time = timestamp.toIso8601String().substring(11, 23);
|
||||||
|
final levelStr = level.name.toUpperCase().padRight(5);
|
||||||
|
final contextStr = context != null ? '[$context] ' : '';
|
||||||
|
return '[$time] $levelStr $contextStr$message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DebugLogger {
|
class DebugLogger {
|
||||||
static final DebugLogger _instance = DebugLogger._internal();
|
static final DebugLogger _instance = DebugLogger._internal();
|
||||||
factory DebugLogger() => _instance;
|
factory DebugLogger() => _instance;
|
||||||
DebugLogger._internal();
|
DebugLogger._internal();
|
||||||
|
|
||||||
final List<String> _logs = [];
|
final List<LogEntry> _entries = [];
|
||||||
final _maxLogs = 500;
|
final _maxLogs = 1000;
|
||||||
|
|
||||||
List<String> get logs => List.unmodifiable(_logs);
|
// Minimum level to display in debug console (debug builds show all)
|
||||||
|
LogLevel _minConsoleLevel = kDebugMode ? LogLevel.debug : LogLevel.info;
|
||||||
|
|
||||||
void log(String message) {
|
List<LogEntry> get entries => List.unmodifiable(_entries);
|
||||||
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
|
|
||||||
final logMessage = '[$timestamp] $message';
|
|
||||||
|
|
||||||
debugPrint(logMessage);
|
/// Legacy getter for backward compatibility
|
||||||
|
List<String> get logs => _entries.map((e) => e.formatted).toList();
|
||||||
|
|
||||||
_logs.add(logMessage);
|
// Set to true to only log perf entries (for FPS diagnosis)
|
||||||
if (_logs.length > _maxLogs) {
|
// Set to false for normal operation with full logging for user bug reports
|
||||||
_logs.removeAt(0);
|
static const bool _perfOnlyMode = false;
|
||||||
|
|
||||||
|
void _addEntry(LogLevel level, String message, {String? context}) {
|
||||||
|
// In perf-only mode, skip all non-perf logs
|
||||||
|
if (_perfOnlyMode && level != LogLevel.perf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entry = LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
message: message,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always store in memory
|
||||||
|
_entries.add(entry);
|
||||||
|
if (_entries.length > _maxLogs) {
|
||||||
|
_entries.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only print to console if above minimum level
|
||||||
|
if (level.index >= _minConsoleLevel.index) {
|
||||||
|
debugPrint(entry.formatted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Debug level - verbose info for development
|
||||||
|
void debug(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.debug, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info level - general operational info
|
||||||
|
void info(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.info, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warning level - potential issues
|
||||||
|
void warning(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.warning, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error level - failures and errors
|
||||||
|
void error(String message, {String? context, Object? error, StackTrace? stackTrace}) {
|
||||||
|
var fullMessage = message;
|
||||||
|
if (error != null) {
|
||||||
|
fullMessage += ': $error';
|
||||||
|
}
|
||||||
|
_addEntry(LogLevel.error, fullMessage, context: context);
|
||||||
|
if (stackTrace != null && kDebugMode) {
|
||||||
|
debugPrint('Stack trace: $stackTrace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy method for backward compatibility
|
||||||
|
void log(String message) {
|
||||||
|
_addEntry(LogLevel.info, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance logging - always prints to console for real-time monitoring
|
||||||
|
void perf(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.perf, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track build time of a widget
|
||||||
|
final Map<String, Stopwatch> _buildTimers = {};
|
||||||
|
|
||||||
|
void startBuild(String widgetName) {
|
||||||
|
_buildTimers[widgetName] = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endBuild(String widgetName) {
|
||||||
|
final sw = _buildTimers.remove(widgetName);
|
||||||
|
if (sw != null) {
|
||||||
|
sw.stop();
|
||||||
|
if (sw.elapsedMilliseconds > 4) { // Only log if > 4ms (slow for 60fps)
|
||||||
|
perf('BUILD ${sw.elapsedMilliseconds}ms', context: widgetName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track frame times during scroll
|
||||||
|
Stopwatch? _frameTimer;
|
||||||
|
int _slowFrameCount = 0;
|
||||||
|
int _totalFrameCount = 0;
|
||||||
|
|
||||||
|
void startFrame() {
|
||||||
|
_frameTimer = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endFrame() {
|
||||||
|
if (_frameTimer != null) {
|
||||||
|
_frameTimer!.stop();
|
||||||
|
_totalFrameCount++;
|
||||||
|
final ms = _frameTimer!.elapsedMilliseconds;
|
||||||
|
if (ms > 16) { // > 16ms means dropped frame at 60fps
|
||||||
|
_slowFrameCount++;
|
||||||
|
perf('SLOW FRAME ${ms}ms (${_slowFrameCount}/${_totalFrameCount} slow)', context: 'Scroll');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetFrameStats() {
|
||||||
|
_slowFrameCount = 0;
|
||||||
|
_totalFrameCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_logs.clear();
|
_entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAllLogs() {
|
String getAllLogs() {
|
||||||
return _logs.join('\n');
|
return _entries.map((e) => e.formatted).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logs filtered by minimum level
|
||||||
|
String getLogsFiltered({LogLevel minLevel = LogLevel.info}) {
|
||||||
|
return _entries
|
||||||
|
.where((e) => e.level.index >= minLevel.index)
|
||||||
|
.map((e) => e.formatted)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a formatted bug report with device info
|
||||||
|
Future<String> generateBugReport() async {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('ENSEMBLE BUG REPORT');
|
||||||
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
// App info
|
||||||
|
try {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
buffer.writeln('APP INFO:');
|
||||||
|
buffer.writeln(' Version: ${packageInfo.version} (${packageInfo.buildNumber})');
|
||||||
|
buffer.writeln(' Package: ${packageInfo.packageName}');
|
||||||
|
buffer.writeln();
|
||||||
|
} catch (e) {
|
||||||
|
buffer.writeln('APP INFO: Unable to retrieve');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device info
|
||||||
|
try {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final android = await deviceInfo.androidInfo;
|
||||||
|
buffer.writeln('DEVICE INFO:');
|
||||||
|
buffer.writeln(' Model: ${android.manufacturer} ${android.model}');
|
||||||
|
buffer.writeln(' Android: ${android.version.release} (SDK ${android.version.sdkInt})');
|
||||||
|
buffer.writeln(' Device: ${android.device}');
|
||||||
|
buffer.writeln();
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final ios = await deviceInfo.iosInfo;
|
||||||
|
buffer.writeln('DEVICE INFO:');
|
||||||
|
buffer.writeln(' Model: ${ios.model}');
|
||||||
|
buffer.writeln(' iOS: ${ios.systemVersion}');
|
||||||
|
buffer.writeln(' Name: ${ios.name}');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
buffer.writeln('DEVICE INFO: Unable to retrieve');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log statistics
|
||||||
|
final errorCount = _entries.where((e) => e.level == LogLevel.error).length;
|
||||||
|
final warningCount = _entries.where((e) => e.level == LogLevel.warning).length;
|
||||||
|
buffer.writeln('LOG SUMMARY:');
|
||||||
|
buffer.writeln(' Total entries: ${_entries.length}');
|
||||||
|
buffer.writeln(' Errors: $errorCount');
|
||||||
|
buffer.writeln(' Warnings: $warningCount');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('LOGS (newest first):');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
// Add logs in reverse order (newest first) for easier reading
|
||||||
|
for (final entry in _entries.reversed) {
|
||||||
|
buffer.writeln(entry.formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Service to intercept hardware volume button presses
|
||||||
|
/// and route them to the selected Music Assistant player.
|
||||||
|
///
|
||||||
|
/// For MA players: intercept buttons and send volume commands via API
|
||||||
|
/// For builtin player: let native volume work (don't intercept)
|
||||||
|
class HardwareVolumeService {
|
||||||
|
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
|
||||||
|
factory HardwareVolumeService() => _instance;
|
||||||
|
HardwareVolumeService._internal();
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
final _volumeUpController = StreamController<void>.broadcast();
|
||||||
|
final _volumeDownController = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
Stream<void> get onVolumeUp => _volumeUpController.stream;
|
||||||
|
Stream<void> get onVolumeDown => _volumeDownController.stream;
|
||||||
|
|
||||||
|
bool _isListening = false;
|
||||||
|
bool get isListening => _isListening;
|
||||||
|
|
||||||
|
bool _isIntercepting = false;
|
||||||
|
bool get isIntercepting => _isIntercepting;
|
||||||
|
|
||||||
|
/// Initialize the service and start listening for volume button events
|
||||||
|
Future<void> init() async {
|
||||||
|
_logger.log('🔊 HardwareVolumeService.init() called, _isListening=$_isListening');
|
||||||
|
|
||||||
|
if (_isListening) {
|
||||||
|
_logger.log('🔊 HardwareVolumeService.init() - already listening, returning early');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔊 Setting up MethodChannel handler for volume events...');
|
||||||
|
_channel.setMethodCallHandler((call) async {
|
||||||
|
_logger.log('🔊 MethodChannel received call: ${call.method}');
|
||||||
|
switch (call.method) {
|
||||||
|
case 'volumeUp':
|
||||||
|
_logger.log('🔊 Hardware VOLUME UP pressed - broadcasting event');
|
||||||
|
_volumeUpController.add(null);
|
||||||
|
break;
|
||||||
|
case 'volumeDown':
|
||||||
|
_logger.log('🔊 Hardware VOLUME DOWN pressed - broadcasting event');
|
||||||
|
_volumeDownController.add(null);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_logger.log('🔊 Unknown method call: ${call.method}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_logger.log('🔊 MethodChannel handler set up');
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔊 Invoking startListening on native channel...');
|
||||||
|
await _channel.invokeMethod('startListening');
|
||||||
|
_isListening = true;
|
||||||
|
_isIntercepting = true;
|
||||||
|
_logger.log('🔊 Native startListening succeeded, _isListening=$_isListening');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to start volume button listening', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable volume button interception.
|
||||||
|
/// When disabled, hardware volume buttons control device volume normally.
|
||||||
|
/// When enabled, volume button events are sent to Flutter for MA player control.
|
||||||
|
Future<void> setIntercepting(bool intercept) async {
|
||||||
|
if (!_isListening) {
|
||||||
|
_logger.log('🔊 setIntercepting($intercept) called but not listening yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isIntercepting == intercept) {
|
||||||
|
_logger.log('🔊 setIntercepting($intercept) - already in that state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (intercept) {
|
||||||
|
await _channel.invokeMethod('startListening');
|
||||||
|
_isIntercepting = true;
|
||||||
|
_logger.log('🔊 Volume interception ENABLED - will capture buttons for MA players');
|
||||||
|
} else {
|
||||||
|
await _channel.invokeMethod('stopListening');
|
||||||
|
_isIntercepting = false;
|
||||||
|
_logger.log('🔊 Volume interception DISABLED - native volume buttons work normally');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to set volume interception to $intercept', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening for volume button events
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_isListening) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('stopListening');
|
||||||
|
_isListening = false;
|
||||||
|
_isIntercepting = false;
|
||||||
|
_logger.info('Hardware volume button listening stopped', context: 'VolumeService');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'debug_logger.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
|
|
||||||
class MetadataService {
|
class MetadataService {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
// Cache to avoid repeated API calls for the same artist/album
|
// Cache to avoid repeated API calls for the same artist/album
|
||||||
static final Map<String, String> _cache = {};
|
static final Map<String, String> _cache = {};
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ class MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Last.fm API error: $e');
|
_logger.warning('Last.fm API error: $e', context: 'Metadata');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,7 @@ class MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ TheAudioDB API error: $e');
|
_logger.warning('TheAudioDB API error: $e', context: 'Metadata');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -241,7 +243,7 @@ class MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Deezer artist image error: $e');
|
_logger.warning('Deezer artist image error: $e', context: 'Metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the null result to avoid repeated failed lookups
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
|||||||
@@ -641,6 +641,26 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get artist details by URI
|
||||||
|
Future<Artist?> getArtistByUri(String uri) async {
|
||||||
|
try {
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/item_by_uri',
|
||||||
|
args: {
|
||||||
|
'uri': uri,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = response['result'];
|
||||||
|
if (result == null) return null;
|
||||||
|
|
||||||
|
return Artist.fromJson(result as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error getting artist by URI: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||||
return await RetryHelper.retryNetwork(
|
return await RetryHelper.retryNetwork(
|
||||||
operation: () async {
|
operation: () async {
|
||||||
@@ -1055,12 +1075,14 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Play radio based on a track (generates similar tracks)
|
/// Play radio based on a track (generates similar tracks)
|
||||||
|
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
|
||||||
Future<void> playRadio(String playerId, Track track) async {
|
Future<void> playRadio(String playerId, Track track) async {
|
||||||
return await RetryHelper.retryCritical(
|
return await RetryHelper.retryCritical(
|
||||||
operation: () async {
|
operation: () async {
|
||||||
// Try using library URI if available, otherwise use provider URI
|
// For radio mode, prefer streaming providers (Spotify, Tidal, etc.)
|
||||||
final libraryUri = track.uri;
|
// as they provide better dynamic track recommendations
|
||||||
final trackUri = libraryUri ?? _buildTrackUri(track);
|
final trackUri = _buildRadioUri(track);
|
||||||
|
_logger.log('Radio: Starting with URI $trackUri');
|
||||||
|
|
||||||
final args = {
|
final args = {
|
||||||
'queue_id': playerId,
|
'queue_id': playerId,
|
||||||
@@ -1069,7 +1091,6 @@ class MusicAssistantAPI {
|
|||||||
'radio_mode': true, // Enable radio mode for similar tracks
|
'radio_mode': true, // Enable radio mode for similar tracks
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/play_media',
|
'player_queues/play_media',
|
||||||
args: args,
|
args: args,
|
||||||
@@ -1079,6 +1100,114 @@ class MusicAssistantAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build URI for radio mode, preferring streaming providers for better recommendations
|
||||||
|
/// Priority: Spotify > Tidal > Deezer > Apple > YTM > Subsonic > any available > library
|
||||||
|
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
|
||||||
|
String _buildRadioUri(Track track) {
|
||||||
|
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||||
|
// Providers that support dynamic radio recommendations (in priority order)
|
||||||
|
// Qobuz is excluded - it doesn't support similar tracks API
|
||||||
|
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
|
||||||
|
|
||||||
|
// Try to find a streaming provider first
|
||||||
|
for (final providerPrefix in radioProviders) {
|
||||||
|
final mapping = track.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
|
||||||
|
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||||
|
);
|
||||||
|
if (mapping.providerInstance.isNotEmpty) {
|
||||||
|
return '${mapping.providerInstance}://track/${mapping.itemId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to any available provider (but not library)
|
||||||
|
final nonLibraryMapping = track.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||||
|
);
|
||||||
|
if (nonLibraryMapping.providerInstance.isNotEmpty) {
|
||||||
|
return '${nonLibraryMapping.providerInstance}://track/${nonLibraryMapping.itemId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use library URI or build from top-level provider
|
||||||
|
return track.uri ?? _buildTrackUri(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play radio based on an artist (generates tracks from and similar to the artist)
|
||||||
|
/// Prefers streaming provider URIs (Spotify, Tidal, etc.) for better recommendations
|
||||||
|
Future<void> playArtistRadio(String playerId, Artist artist) async {
|
||||||
|
return await RetryHelper.retryCritical(
|
||||||
|
operation: () async {
|
||||||
|
final artistUri = _buildArtistRadioUri(artist);
|
||||||
|
_logger.log('Radio: Starting artist radio with URI $artistUri');
|
||||||
|
|
||||||
|
final args = {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [artistUri],
|
||||||
|
'option': 'replace',
|
||||||
|
'radio_mode': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: args,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add artist radio to queue (instead of replacing)
|
||||||
|
Future<void> playArtistRadioToQueue(String playerId, Artist artist) async {
|
||||||
|
return await RetryHelper.retryCritical(
|
||||||
|
operation: () async {
|
||||||
|
final artistUri = _buildArtistRadioUri(artist);
|
||||||
|
_logger.log('Radio: Adding artist radio to queue with URI $artistUri');
|
||||||
|
|
||||||
|
final args = {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [artistUri],
|
||||||
|
'option': 'add', // Add to queue instead of replace
|
||||||
|
'radio_mode': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: args,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build URI for artist radio mode, preferring streaming providers
|
||||||
|
/// Note: Qobuz does NOT support radio mode (no dynamic_tracks API)
|
||||||
|
String _buildArtistRadioUri(Artist artist) {
|
||||||
|
if (artist.providerMappings != null && artist.providerMappings!.isNotEmpty) {
|
||||||
|
// Qobuz is excluded - it doesn't support similar tracks API
|
||||||
|
const radioProviders = ['spotify', 'tidal', 'deezer', 'apple', 'ytmusic', 'subsonic', 'opensubsonic'];
|
||||||
|
|
||||||
|
for (final providerPrefix in radioProviders) {
|
||||||
|
final mapping = artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance.toLowerCase().startsWith(providerPrefix),
|
||||||
|
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||||
|
);
|
||||||
|
if (mapping.providerInstance.isNotEmpty) {
|
||||||
|
return '${mapping.providerInstance}://artist/${mapping.itemId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final nonLibraryMapping = artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => ProviderMapping(providerInstance: '', providerDomain: '', itemId: '', available: false),
|
||||||
|
);
|
||||||
|
if (nonLibraryMapping.providerInstance.isNotEmpty) {
|
||||||
|
return '${nonLibraryMapping.providerInstance}://artist/${nonLibraryMapping.itemId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artist.uri ?? '${artist.provider}://artist/${artist.itemId}';
|
||||||
|
}
|
||||||
|
|
||||||
/// Build track URI from provider mappings
|
/// Build track URI from provider mappings
|
||||||
String _buildTrackUri(Track track) {
|
String _buildTrackUri(Track track) {
|
||||||
// Use provider mappings to get the actual provider instance
|
// Use provider mappings to get the actual provider instance
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class SettingsService {
|
|||||||
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 const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
|
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||||
|
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||||
|
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -313,4 +319,66 @@ class SettingsService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home Screen Row Settings (Main rows - default on)
|
||||||
|
static Future<bool> getShowRecentAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowRecentAlbums) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowRecentAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowRecentAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverArtists() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverArtists(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverArtists, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home Screen Favorites Settings (default off)
|
||||||
|
static Future<bool> getShowFavoriteAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoriteArtists() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteArtists(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteArtists, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoriteTracks() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteTracks(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
/// Extracted adaptive colors from album art
|
/// Extracted adaptive colors from album art
|
||||||
class AdaptiveColors {
|
class AdaptiveColors {
|
||||||
@@ -40,7 +43,7 @@ class PaletteHelper {
|
|||||||
);
|
);
|
||||||
return palette;
|
return palette;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Failed to extract palette: $e');
|
_logger.warning('Failed to extract palette: $e', context: 'Palette');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _systemThemeLogger = DebugLogger();
|
||||||
|
|
||||||
class SystemThemeHelper {
|
class SystemThemeHelper {
|
||||||
/// Attempts to get system color schemes from the device (Android 12+)
|
/// Attempts to get system color schemes from the device (Android 12+)
|
||||||
@@ -35,7 +38,7 @@ class SystemThemeHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Failed to get system colors: $e');
|
_systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'package:flutter/material.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 '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'palette_helper.dart';
|
import 'palette_helper.dart';
|
||||||
|
|
||||||
|
final _themeLogger = DebugLogger();
|
||||||
|
|
||||||
/// Global function to update adaptive colors from an image URL
|
/// Global function to update adaptive colors from an image URL
|
||||||
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
||||||
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
||||||
@@ -17,7 +20,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail - colors will update when track plays
|
// Silently fail - colors will update when track plays
|
||||||
print('Failed to extract colors on tap: $e');
|
_themeLogger.debug('Failed to extract colors on tap: $e', context: 'Theme');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'album_card.dart';
|
import 'album_card.dart';
|
||||||
|
|
||||||
class AlbumRow extends StatefulWidget {
|
class AlbumRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Album>> Function() loadAlbums;
|
final Future<List<Album>> Function() loadAlbums;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
const AlbumRow({
|
const AlbumRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadAlbums,
|
required this.loadAlbums,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,79 +47,103 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('AlbumRow:${widget.title}');
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
// Total row height includes title + content
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||||
children: [
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
Padding(
|
final contentHeight = totalHeight - titleHeight;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Text(
|
final result = RepaintBoundary(
|
||||||
widget.title,
|
child: SizedBox(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
height: totalHeight,
|
||||||
fontWeight: FontWeight.bold,
|
child: Column(
|
||||||
color: colorScheme.onBackground,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(
|
child: FutureBuilder<List<Album>>(
|
||||||
height: 193,
|
future: _albumsFuture,
|
||||||
child: FutureBuilder<List<Album>>(
|
builder: (context, snapshot) {
|
||||||
future: _albumsFuture,
|
// Use cached data immediately if available (prevents flash on rebuild)
|
||||||
builder: (context, snapshot) {
|
final albums = snapshot.data ?? _cachedAlbums;
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final albums = snapshot.data ?? _cachedAlbums;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
// Only show loading if we have no data at all
|
||||||
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
|
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError && albums == null) {
|
if (snapshot.hasError && albums == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Error: ${snapshot.error}'),
|
child: Text('Error: ${snapshot.error}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (albums == null || albums.isEmpty) {
|
if (albums == null || albums.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No albums found',
|
'No albums found',
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: albums.length,
|
|
||||||
itemExtent: 162, // 150px width + 12px horizontal margins
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = albums[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(album.uri ?? album.itemId),
|
|
||||||
width: 150,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: AlbumCard(
|
|
||||||
album: album,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
|
_logger.endBuild('AlbumRow:${widget.title}');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/artist_details_screen.dart';
|
import '../screens/artist_details_screen.dart';
|
||||||
@@ -7,17 +8,20 @@ import '../constants/hero_tags.dart';
|
|||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatefulWidget {
|
class ArtistCard extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
final double? imageSize;
|
||||||
|
|
||||||
const ArtistCard({
|
const ArtistCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.imageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -25,13 +29,39 @@ class ArtistCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistCardState extends State<ArtistCard> {
|
class _ArtistCardState extends State<ArtistCard> {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
String? _fallbackImageUrl;
|
String? _fallbackImageUrl;
|
||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
|
String? _cachedMaImageUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Fetch fallback image once in initState, not during build
|
||||||
|
_initFallbackImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initFallbackImage() {
|
||||||
|
// We'll check if MA has an image after first build, then fetch fallback if needed
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||||
|
_cachedMaImageUrl = maImageUrl;
|
||||||
|
|
||||||
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
|
_triedFallback = true;
|
||||||
|
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
// Use cached URL if available, otherwise get fresh (but don't trigger fetches during build)
|
||||||
|
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -40,15 +70,6 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
// Use MA image if available, otherwise try fallback
|
// Use MA image if available, otherwise try fallback
|
||||||
final imageUrl = maImageUrl ?? _fallbackImageUrl;
|
final imageUrl = maImageUrl ?? _fallbackImageUrl;
|
||||||
|
|
||||||
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
|
|
||||||
if (maImageUrl == null && !_triedFallback) {
|
|
||||||
_triedFallback = true;
|
|
||||||
print('🎨 No MA image for "${widget.artist.name}", trying fallback...');
|
|
||||||
_fetchFallbackImage();
|
|
||||||
} else if (maImageUrl != null && !_triedFallback) {
|
|
||||||
print('🎨 MA has image for "${widget.artist.name}": $maImageUrl');
|
|
||||||
}
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
@@ -60,6 +81,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
child: ArtistDetailsScreen(
|
child: ArtistDetailsScreen(
|
||||||
artist: widget.artist,
|
artist: widget.artist,
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -67,44 +89,61 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Artist image - circular
|
// Artist image - circular, constrained to be a proper circle
|
||||||
Hero(
|
Expanded(
|
||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
child: Center(
|
||||||
child: ClipOval(
|
child: LayoutBuilder(
|
||||||
child: Container(
|
builder: (context, constraints) {
|
||||||
width: 110,
|
// Use the smaller dimension to ensure a circle
|
||||||
height: 110,
|
final size = constraints.maxWidth < constraints.maxHeight
|
||||||
color: colorScheme.surfaceVariant,
|
? constraints.maxWidth
|
||||||
child: imageUrl != null
|
: constraints.maxHeight;
|
||||||
? Image.network(
|
return Hero(
|
||||||
imageUrl,
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
fit: BoxFit.cover,
|
child: ClipOval(
|
||||||
cacheWidth: 256,
|
child: Container(
|
||||||
cacheHeight: 256,
|
width: size,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
height: size,
|
||||||
Icons.person_rounded,
|
color: colorScheme.surfaceVariant,
|
||||||
size: 60,
|
child: imageUrl != null
|
||||||
color: colorScheme.onSurfaceVariant,
|
? CachedNetworkImage(
|
||||||
),
|
imageUrl: imageUrl,
|
||||||
)
|
fit: BoxFit.cover,
|
||||||
: Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant),
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
size: size * 0.55,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
// Artist name
|
// Artist name - fixed height container so image size is consistent
|
||||||
Hero(
|
SizedBox(
|
||||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
height: 36, // Fixed height for 2 lines of text
|
||||||
child: Material(
|
child: Hero(
|
||||||
color: Colors.transparent,
|
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
child: Text(
|
child: Material(
|
||||||
widget.artist.name,
|
color: Colors.transparent,
|
||||||
maxLines: 2,
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
widget.artist.name,
|
||||||
textAlign: TextAlign.center,
|
maxLines: 2,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w500,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -117,8 +156,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
|
|
||||||
Future<void> _fetchFallbackImage() async {
|
Future<void> _fetchFallbackImage() async {
|
||||||
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
print('🎨 Fallback result for "${widget.artist.name}": $fallbackUrl');
|
|
||||||
if (fallbackUrl != null && mounted) {
|
if (fallbackUrl != null && mounted) {
|
||||||
|
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
|
||||||
setState(() {
|
setState(() {
|
||||||
_fallbackImageUrl = fallbackUrl;
|
_fallbackImageUrl = fallbackUrl;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'artist_card.dart';
|
import 'artist_card.dart';
|
||||||
|
|
||||||
class ArtistRow extends StatefulWidget {
|
class ArtistRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Artist>> Function() loadArtists;
|
final Future<List<Artist>> Function() loadArtists;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
const ArtistRow({
|
const ArtistRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadArtists,
|
required this.loadArtists,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,79 +47,103 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('ArtistRow:${widget.title}');
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
// Total row height includes title + content
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||||
children: [
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
Padding(
|
final contentHeight = totalHeight - titleHeight;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Text(
|
final result = RepaintBoundary(
|
||||||
widget.title,
|
child: SizedBox(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
height: totalHeight,
|
||||||
fontWeight: FontWeight.bold,
|
child: Column(
|
||||||
color: colorScheme.onBackground,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(
|
child: FutureBuilder<List<Artist>>(
|
||||||
height: 163,
|
future: _artistsFuture,
|
||||||
child: FutureBuilder<List<Artist>>(
|
builder: (context, snapshot) {
|
||||||
future: _artistsFuture,
|
// Use cached data immediately if available (prevents flash on rebuild)
|
||||||
builder: (context, snapshot) {
|
final artists = snapshot.data ?? _cachedArtists;
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final artists = snapshot.data ?? _cachedArtists;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
// Only show loading if we have no data at all
|
||||||
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
|
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError && artists == null) {
|
if (snapshot.hasError && artists == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Error: ${snapshot.error}'),
|
child: Text('Error: ${snapshot.error}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artists == null || artists.isEmpty) {
|
if (artists == null || artists.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No artists found',
|
'No artists found',
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: circle image + name below
|
||||||
|
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: artists.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = artists[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: artists.length,
|
|
||||||
itemExtent: 136, // 120px width + 16px horizontal margins
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = artists[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(artist.uri ?? artist.itemId),
|
|
||||||
width: 120,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
|
_logger.endBuild('ArtistRow:${widget.title}');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ class EmptyState extends StatelessWidget {
|
|||||||
message: 'No results found',
|
message: 'No results found',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Creates a custom empty state with title and subtitle
|
||||||
|
factory EmptyState.custom({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
VoidCallback? onRefresh,
|
||||||
|
}) =>
|
||||||
|
_CustomEmptyState(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -106,3 +120,71 @@ class EmptyState extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A custom empty state with title and optional subtitle
|
||||||
|
class _CustomEmptyState extends EmptyState {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
const _CustomEmptyState({
|
||||||
|
required super.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.onRefresh,
|
||||||
|
}) : super(message: title);
|
||||||
|
|
||||||
|
@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(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
Spacing.vGap8,
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.4),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (onRefresh != null) ...[
|
||||||
|
Spacing.vGap24,
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
|
import '../services/animation_debugger.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'animated_icon_button.dart';
|
import 'animated_icon_button.dart';
|
||||||
@@ -81,6 +82,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// When true, we hide main content and show peek content at center
|
// When true, we hide main content and show peek content at center
|
||||||
bool _inTransition = false;
|
bool _inTransition = false;
|
||||||
|
|
||||||
|
// Track favorite state for current track
|
||||||
|
bool _isCurrentTrackFavorite = false;
|
||||||
|
String? _lastTrackUri; // Track which track we last checked favorite status for
|
||||||
|
|
||||||
|
// Cached title height to avoid TextPainter.layout() every animation frame
|
||||||
|
double? _cachedExpandedTitleHeight;
|
||||||
|
String? _lastMeasuredTrackName;
|
||||||
|
double? _lastMeasuredTitleWidth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -97,6 +107,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Notify listeners of expansion progress changes
|
// Notify listeners of expansion progress changes
|
||||||
_controller.addListener(_notifyExpansionProgress);
|
_controller.addListener(_notifyExpansionProgress);
|
||||||
|
|
||||||
|
// Animation debugging - record every frame
|
||||||
|
_controller.addListener(_recordAnimationFrame);
|
||||||
|
|
||||||
// Queue panel animation
|
// Queue panel animation
|
||||||
_queuePanelController = AnimationController(
|
_queuePanelController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -164,15 +177,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _recordAnimationFrame() {
|
||||||
|
AnimationDebugger.recordFrame(_controller.value);
|
||||||
|
}
|
||||||
|
|
||||||
void expand() {
|
void expand() {
|
||||||
_controller.forward();
|
AnimationDebugger.startSession('playerExpand');
|
||||||
|
_controller.forward().then((_) {
|
||||||
|
AnimationDebugger.endSession();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void collapse() {
|
void collapse() {
|
||||||
|
AnimationDebugger.startSession('playerCollapse');
|
||||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||||
// during Android's predictive back gesture
|
// during Android's predictive back gesture
|
||||||
_queuePanelController.value = 0;
|
_queuePanelController.value = 0;
|
||||||
_controller.reverse();
|
_controller.reverse().then((_) {
|
||||||
|
AnimationDebugger.endSession();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isExpanded => _controller.value > 0.5;
|
bool get isExpanded => _controller.value > 0.5;
|
||||||
@@ -283,6 +306,64 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
||||||
|
|
||||||
|
/// Update favorite status when track changes
|
||||||
|
void _updateFavoriteStatus(dynamic currentTrack) {
|
||||||
|
if (currentTrack == null) {
|
||||||
|
_isCurrentTrackFavorite = false;
|
||||||
|
_lastTrackUri = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackUri = currentTrack.uri as String?;
|
||||||
|
if (trackUri != _lastTrackUri) {
|
||||||
|
_lastTrackUri = trackUri;
|
||||||
|
_isCurrentTrackFavorite = currentTrack.favorite == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle favorite status for current track
|
||||||
|
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
|
||||||
|
if (currentTrack == null) return;
|
||||||
|
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isCurrentTrackFavorite) {
|
||||||
|
// Remove from favorites
|
||||||
|
final libraryItemId = currentTrack.itemId;
|
||||||
|
if (libraryItemId is int) {
|
||||||
|
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||||
|
} else if (libraryItemId is String) {
|
||||||
|
final parsed = int.tryParse(libraryItemId);
|
||||||
|
if (parsed != null) {
|
||||||
|
await maProvider.api!.removeFromFavorites('track', parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
final provider = currentTrack.provider as String? ?? 'library';
|
||||||
|
final itemId = currentTrack.itemId?.toString() ?? '';
|
||||||
|
await maProvider.api!.addToFavorites('track', itemId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle local state
|
||||||
|
setState(() {
|
||||||
|
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Show error feedback
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update favorite: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get available players sorted alphabetically (consistent with device selector)
|
/// Get available players sorted alphabetically (consistent with device selector)
|
||||||
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
||||||
// Use the provider's already-sorted list, filter for available only
|
// Use the provider's already-sorted list, filter for available only
|
||||||
@@ -612,6 +693,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_extractColors(imageUrl);
|
_extractColors(imageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update favorite status when track changes
|
||||||
|
_updateFavoriteStatus(currentTrack);
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -821,19 +905,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||||
|
|
||||||
// Measure actual title height for dynamic layout
|
// Measure actual title height for dynamic layout (CACHED to avoid layout every frame)
|
||||||
final titleStyle = TextStyle(
|
final titleStyle = TextStyle(
|
||||||
fontSize: 24.0,
|
fontSize: 24.0,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
);
|
);
|
||||||
final titlePainter = TextPainter(
|
// Only recalculate if track name or width changed
|
||||||
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
if (_lastMeasuredTrackName != currentTrack.name ||
|
||||||
maxLines: 2,
|
_lastMeasuredTitleWidth != expandedTitleWidth ||
|
||||||
textDirection: TextDirection.ltr,
|
_cachedExpandedTitleHeight == null) {
|
||||||
)..layout(maxWidth: expandedTitleWidth);
|
final titlePainter = TextPainter(
|
||||||
final expandedTitleHeight = titlePainter.height;
|
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
||||||
|
maxLines: 2,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: expandedTitleWidth);
|
||||||
|
_cachedExpandedTitleHeight = titlePainter.height;
|
||||||
|
_lastMeasuredTrackName = currentTrack.name;
|
||||||
|
_lastMeasuredTitleWidth = expandedTitleWidth;
|
||||||
|
}
|
||||||
|
final expandedTitleHeight = _cachedExpandedTitleHeight!;
|
||||||
|
|
||||||
// Calculate track info block height (title + gap + artist + gap + album)
|
// Calculate track info block height (title + gap + artist + gap + album)
|
||||||
final titleToArtistGap = 12.0;
|
final titleToArtistGap = 12.0;
|
||||||
@@ -992,53 +1084,57 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Album art - with slide animation when collapsed
|
// Album art - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash (peek content shows instead)
|
// Hidden during transition to prevent flash (peek content shows instead)
|
||||||
Positioned(
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
left: artLeft + miniPlayerSlideOffset,
|
if (!(_inTransition && t < 0.1))
|
||||||
top: artTop,
|
Positioned(
|
||||||
child: Opacity(
|
left: artLeft + miniPlayerSlideOffset,
|
||||||
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
top: artTop,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: artSize,
|
width: artSize,
|
||||||
height: artSize,
|
height: artSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
|
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||||
boxShadow: t > 0.3
|
boxShadow: t > 0.3
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.3 * t),
|
color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||||
blurRadius: 24 * t,
|
blurRadius: 20,
|
||||||
offset: Offset(0, 8 * t),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
// Use RepaintBoundary to isolate art repaints during animation
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
child: RepaintBoundary(
|
||||||
child: imageUrl != null
|
child: ClipRRect(
|
||||||
? CachedNetworkImage(
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
imageUrl: imageUrl,
|
child: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? CachedNetworkImage(
|
||||||
memCacheWidth: t > 0.5 ? 1024 : 256,
|
imageUrl: imageUrl,
|
||||||
memCacheHeight: t > 0.5 ? 1024 : 256,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
// Fixed cache size to avoid mid-animation cache thrashing
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheWidth: 512,
|
||||||
placeholderFadeInDuration: Duration.zero,
|
memCacheHeight: 512,
|
||||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
fadeInDuration: Duration.zero,
|
||||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
fadeOutDuration: Duration.zero,
|
||||||
)
|
placeholderFadeInDuration: Duration.zero,
|
||||||
: _buildPlaceholderArt(colorScheme, t),
|
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
)
|
||||||
|
: _buildPlaceholderArt(colorScheme, t),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Track title - with slide animation when collapsed
|
// Track title - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
Positioned(
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
if (!(_inTransition && t < 0.1))
|
||||||
top: titleTop,
|
Positioned(
|
||||||
child: Opacity(
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
top: titleTop,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1056,15 +1152,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Artist name - with slide animation when collapsed
|
// Artist name - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
Positioned(
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
if (!(_inTransition && t < 0.1))
|
||||||
top: artistTop,
|
Positioned(
|
||||||
child: Opacity(
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
top: artistTop,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1080,38 +1175,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Album name (expanded only)
|
// Album name (expanded only)
|
||||||
|
// GPU PERF: Use color alpha instead of Opacity widget
|
||||||
if (currentTrack.album != null && t > 0.3)
|
if (currentTrack.album != null && t > 0.3)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: contentPadding,
|
left: contentPadding,
|
||||||
right: contentPadding,
|
right: contentPadding,
|
||||||
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
|
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
|
||||||
child: Opacity(
|
child: Text(
|
||||||
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0),
|
currentTrack.album!.name,
|
||||||
child: Text(
|
style: TextStyle(
|
||||||
currentTrack.album!.name,
|
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||||
style: TextStyle(
|
fontSize: 15,
|
||||||
color: textColor.withOpacity(0.45),
|
fontWeight: FontWeight.w300,
|
||||||
fontSize: 15, // Increased from 13 to 15
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Progress bar (expanded only)
|
// Progress bar (expanded only)
|
||||||
|
// GPU PERF: Use FadeTransition instead of Opacity
|
||||||
if (t > 0.5 && currentTrack.duration != null)
|
if (t > 0.5 && currentTrack.duration != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: contentPadding,
|
left: contentPadding,
|
||||||
right: contentPadding,
|
right: contentPadding,
|
||||||
top: expandedProgressTop,
|
top: expandedProgressTop,
|
||||||
child: Opacity(
|
child: FadeTransition(
|
||||||
opacity: expandedElementsOpacity,
|
opacity: _expandAnimation.drive(
|
||||||
|
Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: ValueListenableBuilder<int>(
|
child: ValueListenableBuilder<int>(
|
||||||
valueListenable: _progressNotifier,
|
valueListenable: _progressNotifier,
|
||||||
builder: (context, elapsedTime, child) {
|
builder: (context, elapsedTime, child) {
|
||||||
@@ -1202,14 +1299,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Shuffle (expanded only)
|
// Shuffle (expanded only)
|
||||||
if (t > 0.5)
|
// GPU PERF: Use color alpha instead of Opacity
|
||||||
Opacity(
|
if (t > 0.5 && expandedElementsOpacity > 0.1)
|
||||||
opacity: expandedElementsOpacity,
|
_buildSecondaryButton(
|
||||||
child: _buildSecondaryButton(
|
icon: Icons.shuffle_rounded,
|
||||||
icon: Icons.shuffle_rounded,
|
color: (_queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||||
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.5),
|
.withOpacity(expandedElementsOpacity),
|
||||||
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
@@ -1247,85 +1343,109 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Repeat (expanded only)
|
// Repeat (expanded only)
|
||||||
|
// GPU PERF: Use color alpha instead of Opacity
|
||||||
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (t > 0.5) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
if (t > 0.5)
|
if (t > 0.5 && expandedElementsOpacity > 0.1)
|
||||||
Opacity(
|
_buildSecondaryButton(
|
||||||
opacity: expandedElementsOpacity,
|
icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||||
child: _buildSecondaryButton(
|
color: (_queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
||||||
icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
? primaryColor
|
||||||
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
: textColor.withOpacity(0.5))
|
||||||
? primaryColor
|
.withOpacity(expandedElementsOpacity),
|
||||||
: textColor.withOpacity(0.5),
|
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
||||||
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Volume control (expanded only)
|
// Volume control (expanded only)
|
||||||
|
// GPU PERF: Use FadeTransition instead of Opacity
|
||||||
if (t > 0.5)
|
if (t > 0.5)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 48,
|
left: 48,
|
||||||
right: 48,
|
right: 48,
|
||||||
top: volumeTop,
|
top: volumeTop,
|
||||||
child: Opacity(
|
child: FadeTransition(
|
||||||
opacity: expandedElementsOpacity,
|
opacity: _expandAnimation.drive(
|
||||||
|
Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: const VolumeControl(compact: false),
|
child: const VolumeControl(compact: false),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Collapse button (expanded only)
|
// Collapse button (expanded only)
|
||||||
|
// GPU PERF: Use icon color alpha instead of Opacity
|
||||||
if (t > 0.3)
|
if (t > 0.3)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPadding + 4,
|
top: topPadding + 4,
|
||||||
left: 4,
|
left: 4,
|
||||||
child: Opacity(
|
child: IconButton(
|
||||||
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0),
|
icon: Icon(
|
||||||
child: IconButton(
|
Icons.keyboard_arrow_down_rounded,
|
||||||
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
|
color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||||
onPressed: collapse,
|
size: 28,
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
|
onPressed: collapse,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Favorite button (expanded only) - hide when queue panel is open
|
||||||
|
// GPU PERF: Use icon color alpha instead of Opacity
|
||||||
|
if (t > 0.3 && queueT < 0.5)
|
||||||
|
Positioned(
|
||||||
|
top: topPadding + 4,
|
||||||
|
right: 52,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
|
||||||
|
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Queue button (expanded only) - hide when queue panel is open
|
// Queue button (expanded only) - hide when queue panel is open
|
||||||
|
// GPU PERF: Use icon color alpha instead of Opacity
|
||||||
if (t > 0.3 && queueT < 0.5)
|
if (t > 0.3 && queueT < 0.5)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPadding + 4,
|
top: topPadding + 4,
|
||||||
right: 4,
|
right: 4,
|
||||||
child: Opacity(
|
child: IconButton(
|
||||||
opacity: ((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0),
|
icon: Icon(
|
||||||
child: IconButton(
|
Icons.queue_music_rounded,
|
||||||
icon: Icon(Icons.queue_music_rounded, color: textColor, size: 24),
|
color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
||||||
onPressed: _toggleQueuePanel,
|
size: 24,
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
|
onPressed: _toggleQueuePanel,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Player name (expanded only)
|
// Player name (expanded only)
|
||||||
|
// GPU PERF: Use text color alpha instead of Opacity
|
||||||
if (t > 0.5)
|
if (t > 0.5)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPadding + 12,
|
top: topPadding + 12,
|
||||||
left: 56,
|
left: 56,
|
||||||
right: 56,
|
right: 56,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: Opacity(
|
child: Text(
|
||||||
opacity: ((t - 0.5) / 0.5).clamp(0.0, 1.0),
|
selectedPlayer.name,
|
||||||
child: Text(
|
style: TextStyle(
|
||||||
selectedPlayer.name,
|
color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
|
||||||
style: TextStyle(
|
fontSize: 15,
|
||||||
color: textColor.withOpacity(0.6),
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 15, // Increased from 13 to 15
|
letterSpacing: 0.2,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ import '../providers/navigation_provider.dart';
|
|||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'expandable_player.dart';
|
import 'expandable_player.dart';
|
||||||
|
|
||||||
|
/// Cached color with contrast adjustment
|
||||||
|
/// Avoids expensive HSL conversions during scroll
|
||||||
|
class _CachedNavColor {
|
||||||
|
Color? _sourceColor;
|
||||||
|
bool? _isDark;
|
||||||
|
Color? _adjustedColor;
|
||||||
|
|
||||||
|
Color getAdjustedColor(Color sourceColor, bool isDark) {
|
||||||
|
// Return cached value if inputs haven't changed
|
||||||
|
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
|
||||||
|
return _adjustedColor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute new adjusted color
|
||||||
|
var navSelectedColor = sourceColor;
|
||||||
|
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
|
||||||
|
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||||
|
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
|
||||||
|
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
|
||||||
|
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||||
|
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
_sourceColor = sourceColor;
|
||||||
|
_isDark = isDark;
|
||||||
|
_adjustedColor = navSelectedColor;
|
||||||
|
|
||||||
|
return navSelectedColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _cachedNavColor = _CachedNavColor();
|
||||||
|
|
||||||
/// A global key to access the player state from anywhere in the app
|
/// A global key to access the player state from anywhere in the app
|
||||||
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
|
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
|
||||||
|
|
||||||
@@ -23,7 +57,7 @@ class BottomSpacing {
|
|||||||
static const double navBarOnly = navBarHeight + 16.0;
|
static const double navBarOnly = navBarHeight + 16.0;
|
||||||
|
|
||||||
/// Space needed when mini player is also visible
|
/// Space needed when mini player is also visible
|
||||||
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 14.0;
|
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
||||||
@@ -135,33 +169,73 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
return Consumer<ThemeProvider>(
|
return Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
||||||
var navSelectedColor = themeProvider.adaptiveTheme
|
final sourceColor = themeProvider.adaptiveTheme
|
||||||
? themeProvider.adaptivePrimaryColor
|
? themeProvider.adaptivePrimaryColor
|
||||||
: colorScheme.primary;
|
: colorScheme.primary;
|
||||||
|
|
||||||
// Ensure nav color has sufficient contrast against the surface background
|
// Use cached color computation to avoid expensive HSL operations during scroll
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
|
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
|
||||||
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
|
// Base background: use adaptive surface color if available, otherwise default surface
|
||||||
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
|
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
? themeProvider.adaptiveSurfaceColor!
|
||||||
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
: colorScheme.surface;
|
||||||
}
|
|
||||||
|
|
||||||
return ValueListenableBuilder<PlayerExpansionState>(
|
return ValueListenableBuilder<PlayerExpansionState>(
|
||||||
valueListenable: playerExpansionNotifier,
|
valueListenable: playerExpansionNotifier,
|
||||||
builder: (context, expansionState, child) {
|
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
||||||
// Base background: use adaptive surface color if available, otherwise default surface
|
child: BottomNavigationBar(
|
||||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
currentIndex: navigationProvider.selectedIndex,
|
||||||
? themeProvider.adaptiveSurfaceColor!
|
onTap: (index) {
|
||||||
: colorScheme.surface;
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
// Lerp between base color and player background when expanded
|
}
|
||||||
|
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
if (index == 3) {
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
} else if (navigationProvider.selectedIndex == 3) {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.setSelectedIndex(index);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
selectedItemColor: navSelectedColor,
|
||||||
|
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
elevation: 0,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
selectedFontSize: 12,
|
||||||
|
unselectedFontSize: 12,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
activeIcon: Icon(Icons.home_rounded),
|
||||||
|
label: 'Home',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.library_music_outlined),
|
||||||
|
activeIcon: Icon(Icons.library_music_rounded),
|
||||||
|
label: 'Library',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.search_rounded),
|
||||||
|
activeIcon: Icon(Icons.search_rounded),
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
builder: (context, expansionState, navBar) {
|
||||||
|
// Only compute colors during animation - this is the hot path
|
||||||
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
||||||
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
||||||
: baseBgColor;
|
: baseBgColor;
|
||||||
|
|
||||||
|
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: navBgColor,
|
color: navBgColor,
|
||||||
@@ -175,56 +249,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
]
|
]
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: BottomNavigationBar(
|
child: navBar, // Reuse pre-built navigation bar
|
||||||
currentIndex: navigationProvider.selectedIndex,
|
|
||||||
onTap: (index) {
|
|
||||||
// Collapse player if expanded
|
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop any pushed routes (album/artist details) first
|
|
||||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
||||||
|
|
||||||
// Hide mini player on Settings screen, show on other screens
|
|
||||||
if (index == 3) {
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
|
||||||
} else if (navigationProvider.selectedIndex == 3) {
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationProvider.setSelectedIndex(index);
|
|
||||||
},
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
selectedItemColor: navSelectedColor,
|
|
||||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
elevation: 0,
|
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
selectedFontSize: 12,
|
|
||||||
unselectedFontSize: 12,
|
|
||||||
items: const [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
activeIcon: Icon(Icons.home_rounded),
|
|
||||||
label: 'Home',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.library_music_outlined),
|
|
||||||
activeIcon: Icon(Icons.library_music_rounded),
|
|
||||||
label: 'Library',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.search_rounded),
|
|
||||||
activeIcon: Icon(Icons.search_rounded),
|
|
||||||
label: 'Search',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
|
||||||
label: 'Settings',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -234,17 +259,22 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
||||||
AnimatedBuilder(
|
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||||
animation: _slideAnimation,
|
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||||
builder: (context, child) {
|
selector: (_, provider) => (
|
||||||
return Consumer<MusicAssistantProvider>(
|
isConnected: provider.isConnected,
|
||||||
builder: (context, maProvider, _) {
|
hasPlayer: provider.selectedPlayer != null,
|
||||||
// Only show player if connected and has a selected player
|
),
|
||||||
// Mini player is now permanent - shows device selector even without a track
|
builder: (context, state, child) {
|
||||||
if (!maProvider.isConnected ||
|
// Only show player if connected and has a selected player
|
||||||
maProvider.selectedPlayer == null) {
|
if (!state.isConnected || !state.hasPlayer) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _slideAnimation,
|
||||||
|
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
||||||
|
builder: (context, staticChild) {
|
||||||
|
// Only slideOffset changes during animation, player widget stays same
|
||||||
return ExpandablePlayer(
|
return ExpandablePlayer(
|
||||||
key: globalPlayerKey,
|
key: globalPlayerKey,
|
||||||
slideOffset: _slideAnimation.value,
|
slideOffset: _slideAnimation.value,
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class QueuePanel extends StatelessWidget {
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: Spacing.paddingH8,
|
padding: Spacing.paddingH8,
|
||||||
cacheExtent: 500,
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
@@ -96,7 +98,8 @@ class QueuePanel extends StatelessWidget {
|
|||||||
final isPastItem = index < currentIndex;
|
final isPastItem = index < currentIndex;
|
||||||
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
||||||
|
|
||||||
return Opacity(
|
return RepaintBoundary(
|
||||||
|
child: Opacity(
|
||||||
opacity: isPastItem ? 0.5 : 1.0,
|
opacity: isPastItem ? 0.5 : 1.0,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
||||||
@@ -162,6 +165,7 @@ class QueuePanel extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
class TrackRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<Track>> Function() loadTracks;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
|
const TrackRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadTracks,
|
||||||
|
this.rowHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TrackRow> createState() => _TrackRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
late Future<List<Track>> _tracksFuture;
|
||||||
|
List<Track>? _cachedTracks;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTracksOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadTracksOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_tracksFuture = widget.loadTracks().then((tracks) {
|
||||||
|
_cachedTracks = tracks;
|
||||||
|
return tracks;
|
||||||
|
});
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('TrackRow:${widget.title}');
|
||||||
|
super.build(context);
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Total row height includes title + content
|
||||||
|
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
|
||||||
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<Track>>(
|
||||||
|
future: _tracksFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final tracks = snapshot.data ?? _cachedTracks;
|
||||||
|
|
||||||
|
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError && tracks == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text('Error: ${snapshot.error}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks == null || tracks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No tracks found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: tracks.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _TrackCard(
|
||||||
|
track: track,
|
||||||
|
tracks: tracks,
|
||||||
|
index: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('TrackRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackCard extends StatelessWidget {
|
||||||
|
final Track track;
|
||||||
|
final List<Track> tracks;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const _TrackCard({
|
||||||
|
required this.track,
|
||||||
|
required this.tracks,
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Try track image first, then album image
|
||||||
|
final trackImageUrl = maProvider.getImageUrl(track, size: 256);
|
||||||
|
final albumImageUrl = track.album != null
|
||||||
|
? maProvider.getImageUrl(track.album!, size: 256)
|
||||||
|
: null;
|
||||||
|
final imageUrl = trackImageUrl ?? albumImageUrl;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _playTrack(context, maProvider),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Album artwork
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Track title
|
||||||
|
Text(
|
||||||
|
track.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Artist name
|
||||||
|
Text(
|
||||||
|
track.artistsString,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playTrack(BuildContext context, MusicAssistantProvider maProvider) async {
|
||||||
|
if (maProvider.selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await maProvider.playTracks(
|
||||||
|
maProvider.selectedPlayer!.playerId,
|
||||||
|
tracks,
|
||||||
|
startIndex: index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.6.1+8
|
version: 1.8.0+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -28,6 +28,9 @@ dependencies:
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid: ^4.2.1
|
uuid: ^4.2.1
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
device_info_plus: ^10.1.0
|
||||||
|
share_plus: ^9.0.0
|
||||||
|
|
||||||
# Audio - Local Playback
|
# Audio - Local Playback
|
||||||
just_audio: ^0.9.36
|
just_audio: ^0.9.36
|
||||||
|
|||||||