Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950af5f141 | ||
|
|
4cc1cc57d9 | ||
|
|
770dfac664 | ||
|
|
8b7a6edbba | ||
|
|
2c3b2a2bf6 | ||
|
|
b73fe04b45 | ||
|
|
c3c12f848e | ||
|
|
f116056c55 | ||
|
|
28db3f7ebb | ||
|
|
3646de2a33 | ||
|
|
c97ddd8d5c | ||
|
|
c23c7748c2 | ||
|
|
0cd58436f8 | ||
|
|
5b7e178181 | ||
|
|
2ff25a4ffb | ||
|
|
5aeac9f0fb | ||
|
|
8430abf5fd | ||
|
|
489503247e | ||
|
|
0e9ebc6a4a | ||
|
|
d5a4167d19 | ||
|
|
5a503941dd | ||
|
|
657dba0703 | ||
|
|
d9657b462d | ||
|
|
a0430dd674 | ||
|
|
e1fc9d00b7 | ||
|
|
754aa81e2a | ||
|
|
d4d51b00d9 | ||
|
|
fd391755a7 | ||
|
|
dbe395d88f | ||
|
|
9f7a1f4f50 | ||
|
|
2219996aea | ||
|
|
38ec41505c | ||
|
|
7b38b2ffa3 | ||
|
|
f6e21de414 | ||
|
|
0130a10396 | ||
|
|
573891f29a | ||
|
|
3b493e59e5 | ||
|
|
bc5a3014e4 | ||
|
|
19110ef4fe | ||
|
|
47ecb715b2 | ||
|
|
6c5d34c0ee | ||
|
|
dd18d7bdb5 | ||
|
|
69e9b4ece5 | ||
|
|
9ee9ddf29a | ||
|
|
32b8085b08 | ||
|
|
f9329ef289 | ||
|
|
d2619994b9 | ||
|
|
f064fe507c | ||
|
|
5eaf101b05 | ||
|
|
184dfdf670 | ||
|
|
56c93e808f | ||
|
|
7e40725a5f | ||
|
|
4ba4c44b56 | ||
|
|
a0dee2ab92 | ||
|
|
0b0ca089c7 | ||
|
|
91606e7e82 | ||
|
|
6a5a071ebe | ||
|
|
fd5730a87f | ||
|
|
33d3c65e52 | ||
|
|
e3d6afe9eb | ||
|
|
b7fe6ac06b | ||
|
|
505de2ff11 | ||
|
|
903051c8cb | ||
|
|
b2ed5ea085 | ||
|
|
8a6eba8775 | ||
|
|
3c6c3a14ba | ||
|
|
3ffe67a178 | ||
|
|
5c5b340915 | ||
|
|
a0d4d85ff9 | ||
|
|
abff289a12 | ||
|
|
93def48a8e | ||
|
|
92a15ba37c | ||
|
|
1df539028f | ||
|
|
fa53ab06f4 | ||
|
|
3be160660f | ||
|
|
dfc18c28ea | ||
|
|
5de45ad228 | ||
|
|
12ce53dda9 | ||
|
|
39ff3558c5 | ||
|
|
dfe6903a16 | ||
|
|
6b07f403d1 | ||
|
|
2993288f6d | ||
|
|
075f1531e4 | ||
|
|
8b8d1f0269 | ||
|
|
93552f5008 | ||
|
|
624cd32b86 | ||
|
|
cf6f85a818 | ||
|
|
97ef755c90 | ||
|
|
d149955425 | ||
|
|
a71437e7fd | ||
|
|
fd4efdf82c | ||
|
|
1ee710fe0a | ||
|
|
dca2001301 | ||
|
|
42ede54b3b | ||
|
|
c16548e8b0 | ||
|
|
7fdb1c0511 | ||
|
|
2c2049cae2 | ||
|
|
91504c75a9 | ||
|
|
970ece0fe5 | ||
|
|
45dc50834f | ||
|
|
b0a109f196 | ||
|
|
5120112435 | ||
|
|
519248afcf | ||
|
|
4ab5b02156 | ||
|
|
50a2bd922d | ||
|
|
999c1b63b1 | ||
|
|
b8b16957f8 | ||
|
|
9472d154d9 | ||
|
|
873ac3034a | ||
|
|
67caa5946d | ||
|
|
00485736bb | ||
|
|
020b22e5d5 | ||
|
|
bd4ba27da6 | ||
|
|
1df8465133 |
@@ -48,7 +48,7 @@ jobs:
|
||||
keytool -genkey -v -keystore android/app/upload-keystore.jks \
|
||||
-storetype JKS -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass android -keypass android \
|
||||
-alias upload -dname "CN=Amass, OU=HomeServer, O=Personal, L=Home, S=Home, C=US"
|
||||
-alias upload -dname "CN=AssistantToTheMusic, OU=HomeServer, O=Personal, L=Home, S=Home, C=US"
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
|
||||
<h1>Amass Your Music</h1>
|
||||
<img src="assets/images/attm_long_logo.png" alt="Assistant To The Music Logo" height="200">
|
||||
|
||||
---
|
||||
|
||||
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
||||
<p>Control your Music Assistant server and manage your entire music library from your mobile device.</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Music Assistant Integration
|
||||
@@ -18,25 +26,37 @@
|
||||
- **Settings Management** - Configure server URL with persistent storage
|
||||
|
||||
### Control Features
|
||||
- Clean, minimalistic dark UI design
|
||||
- Full playback controls (play/pause/skip/seek) for server players
|
||||
- Progress bar with time display
|
||||
- Volume control for server players
|
||||
- Now playing display with track information
|
||||
- Queue management and viewing
|
||||
- Multi-player support
|
||||
- Multi-player support with device selector
|
||||
- Play on... button for quick device selection
|
||||
|
||||
### Theming Options
|
||||
- **Material You** - Dynamic theming based on your device's wallpaper
|
||||
- **Adaptive Theming** - Album artwork-based color schemes
|
||||
- **Light/Dark Mode** - System-aware or manual theme selection
|
||||
- Clean, minimalistic UI design
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
|
||||
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
|
||||
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
|
||||
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
|
||||
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
|
||||
<img src="assets/screenshots/6.png" alt="Settings" width="250">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Amass/releases).
|
||||
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Assistant-To-The-Music/releases).
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -78,8 +98,8 @@ MIT License
|
||||
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/CollotsSpot/Amass.git
|
||||
cd Amass
|
||||
git clone https://github.com/CollotsSpot/AssistantToTheMusic.git
|
||||
cd AssistantToTheMusic
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<application
|
||||
android:label="Amass"
|
||||
android:label="ATTM"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB |
@@ -5,6 +5,9 @@ import 'providers/music_assistant_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'theme/theme_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/system_theme_helper.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -36,12 +39,14 @@ class MusicAssistantApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
||||
late MusicAssistantProvider _provider;
|
||||
late MusicAssistantProvider _musicProvider;
|
||||
late ThemeProvider _themeProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_provider = MusicAssistantProvider();
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@@ -58,7 +63,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// App came back to foreground - check connection and reconnect if needed
|
||||
print('📱 App resumed - checking WebSocket connection...');
|
||||
_provider.checkAndReconnect();
|
||||
_musicProvider.checkAndReconnect();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
print('📱 App paused (backgrounded)');
|
||||
} else if (state == AppLifecycleState.detached) {
|
||||
@@ -68,30 +73,60 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: _provider,
|
||||
child: MaterialApp(
|
||||
title: 'Amass',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: const Color(0xFF1a1a1a),
|
||||
scaffoldBackgroundColor: const Color(0xFF1a1a1a),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: Colors.white,
|
||||
secondary: Colors.white70,
|
||||
surface: Color(0xFF2a2a2a),
|
||||
background: Color(0xFF1a1a1a),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
home: const AppStartup(),
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: _musicProvider),
|
||||
ChangeNotifierProvider.value(value: _themeProvider),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
return FutureBuilder<(ColorScheme, ColorScheme)?>(
|
||||
future: themeProvider.useMaterialTheme
|
||||
? SystemThemeHelper.getSystemColorSchemes()
|
||||
: null,
|
||||
builder: (context, snapshot) {
|
||||
// Determine which color schemes to use
|
||||
ColorScheme? lightColorScheme;
|
||||
ColorScheme? darkColorScheme;
|
||||
|
||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||
// Use system color schemes
|
||||
final (light, dark) = snapshot.data!;
|
||||
lightColorScheme = light;
|
||||
darkColorScheme = dark;
|
||||
} else {
|
||||
// Use brand color schemes
|
||||
lightColorScheme = brandLightColorScheme;
|
||||
darkColorScheme = brandDarkColorScheme;
|
||||
}
|
||||
|
||||
// Update system UI overlay style based on theme mode
|
||||
final isDark = themeProvider.themeMode == ThemeMode.dark ||
|
||||
(themeProvider.themeMode == ThemeMode.system &&
|
||||
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark
|
||||
? darkColorScheme.background
|
||||
: lightColorScheme.background,
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'ATTM', // Updated title
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: themeProvider.themeMode,
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
home: const AppStartup(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,33 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
String? get error => _error;
|
||||
bool get isConnected => _connectionState == MAConnectionState.connected;
|
||||
|
||||
// Search state persistence
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
// No notifyListeners() needed here as SearchScreen manages its own UI updates
|
||||
// and just reads this on init
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
}
|
||||
|
||||
// Player selection getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
@@ -143,10 +170,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
// Load artists, albums, and tracks in parallel
|
||||
// Use a high limit to fetch "all" items (assuming library < 5000 items per type)
|
||||
final results = await Future.wait([
|
||||
_api!.getArtists(limit: 500),
|
||||
_api!.getAlbums(limit: 500),
|
||||
_api!.getTracks(limit: 500),
|
||||
_api!.getArtists(limit: 5000),
|
||||
_api!.getAlbums(limit: 5000),
|
||||
_api!.getTracks(limit: 5000),
|
||||
]);
|
||||
|
||||
_artists = results[0] as List<Artist>;
|
||||
@@ -173,7 +201,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
_artists = await _api!.getArtists(
|
||||
limit: limit,
|
||||
limit: limit ?? 5000, // Default to high limit if not specified
|
||||
offset: offset,
|
||||
search: search,
|
||||
);
|
||||
@@ -203,7 +231,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
_albums = await _api!.getAlbums(
|
||||
limit: limit,
|
||||
limit: limit ?? 5000, // Default to high limit
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
@@ -231,13 +259,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<MediaItem>>> search(String query) async {
|
||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||
if (!isConnected) {
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
return await _api!.search(query);
|
||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Search', e);
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
@@ -264,9 +292,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return await _api?.getQueue(playerId);
|
||||
}
|
||||
|
||||
Future<void> playTrack(String playerId, Track track) async {
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTrack(playerId, track);
|
||||
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play track');
|
||||
_error = errorInfo.userMessage;
|
||||
@@ -276,9 +304,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex}) async {
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex);
|
||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play tracks');
|
||||
_error = errorInfo.userMessage;
|
||||
@@ -288,6 +316,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playRadio(String playerId, Track track) async {
|
||||
try {
|
||||
await _api?.playRadio(playerId, track);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Play radio');
|
||||
_error = errorInfo.userMessage;
|
||||
ErrorHandler.logError('Play radio', e);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getCurrentStreamUrl(String playerId) async {
|
||||
return await _api?.getCurrentStreamUrl(playerId);
|
||||
}
|
||||
@@ -337,6 +377,27 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePower(String playerId) async {
|
||||
try {
|
||||
// Find the player to get current power state
|
||||
final player = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||
? _selectedPlayer!
|
||||
: throw Exception("Player not found"),
|
||||
);
|
||||
|
||||
// Toggle the state
|
||||
await _api?.setPower(playerId, !player.powered);
|
||||
|
||||
// Immediately refresh players to update UI
|
||||
await refreshPlayers();
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle power', e);
|
||||
// Don't rethrow to avoid crashing UI, just log
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
try {
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
@@ -430,22 +491,35 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
final allPlayers = await getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
// Filter out leftover "Music Assistant Mobile" players from old app builds
|
||||
// These were registered during development but are no longer used
|
||||
// Keep "this device" (web UI player) and all other legitimate players
|
||||
// Filter out ghost players and duplicates
|
||||
// 1. "Music Assistant Mobile" players are legacy/ghosts
|
||||
// 2. "This Device" players: Only keep the one matching our client ID
|
||||
int filteredCount = 0;
|
||||
final List<String> ghostPlayerIds = [];
|
||||
|
||||
_availablePlayers = allPlayers.where((player) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
// Exclude players that are exactly "music assistant mobile" or similar
|
||||
// but keep "this device" and other players
|
||||
|
||||
// Legacy ghosts
|
||||
if (nameLower.contains('music assistant mobile')) {
|
||||
filteredCount++;
|
||||
ghostPlayerIds.add('${player.playerId} (available: ${player.available})');
|
||||
ghostPlayerIds.add('${player.playerId} (Mobile Ghost)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter "This Device" duplicates
|
||||
if (nameLower.contains('this device')) {
|
||||
// If we have a builtin ID, only keep the player that matches it
|
||||
if (builtinPlayerId != null && player.playerId != builtinPlayerId) {
|
||||
filteredCount++;
|
||||
ghostPlayerIds.add('${player.playerId} (Duplicate This Device)');
|
||||
return false;
|
||||
}
|
||||
// If we don't have a builtin ID yet (rare), or this matches, keep it
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
@@ -453,7 +527,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Log details about ghost players
|
||||
if (filteredCount > 0) {
|
||||
_logger.log('🧹 Filtered out $filteredCount ghost "Music Assistant Mobile" players:');
|
||||
_logger.log('🧹 Filtered out $filteredCount ghost players:');
|
||||
for (int i = 0; i < ghostPlayerIds.length && i < 5; i++) {
|
||||
_logger.log(' - ${ghostPlayerIds[i]}');
|
||||
}
|
||||
|
||||
@@ -3,26 +3,67 @@ import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../widgets/mini_player.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
class AlbumDetailsScreen extends StatefulWidget {
|
||||
final Album album;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const AlbumDetailsScreen({super.key, required this.album});
|
||||
const AlbumDetailsScreen({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
||||
}
|
||||
|
||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
int? _expandedTrackIndex;
|
||||
bool _isDescriptionExpanded = false;
|
||||
String? _albumDescription;
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_loadTracks();
|
||||
_extractColors();
|
||||
_loadAlbumDescription();
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
||||
|
||||
if (imageUrl == null) return;
|
||||
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
NetworkImage(imageUrl),
|
||||
);
|
||||
|
||||
if (colorSchemes != null && mounted) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract colors for album: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
@@ -122,6 +163,140 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addAlbumToQueue() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player == null) {
|
||||
_showError('No player selected');
|
||||
return;
|
||||
}
|
||||
|
||||
print('➕ Adding album to queue on ${player.name}');
|
||||
|
||||
// Add all tracks to queue without clearing
|
||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
|
||||
print('✓ Album added to queue on ${player.name}');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Album added to queue'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding album to queue: $e');
|
||||
_showError('Failed to add album to queue: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _addTrackToQueue(BuildContext context, int index) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Add to queue 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, playerIndex) {
|
||||
final player = players[playerIndex];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
// Add tracks from this index onwards to queue
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: index,
|
||||
clearQueue: false,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tracks added to queue'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding to queue: $e');
|
||||
_showError('Failed to add to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToArtist() {
|
||||
// Navigate to the first artist if available
|
||||
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(artist: widget.album.artists!.first),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAlbumDescription() async {
|
||||
final artistName = widget.album.artists?.firstOrNull?.name ?? '';
|
||||
final albumName = widget.album.name;
|
||||
|
||||
if (artistName.isEmpty || albumName.isEmpty) return;
|
||||
|
||||
final description = await MetadataService.getAlbumDescription(
|
||||
artistName,
|
||||
albumName,
|
||||
widget.album.metadata,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albumDescription = description;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -136,20 +311,37 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
||||
|
||||
// Determine if we should use adaptive theme colors
|
||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Get the color scheme to use
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (useAdaptiveTheme) {
|
||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||
}
|
||||
|
||||
// Use adaptive scheme if available, otherwise use global theme
|
||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
backgroundColor: colorScheme.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
expandedHeight: 350, // Increased height for bigger art
|
||||
pinned: true,
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Column(
|
||||
@@ -157,13 +349,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
@@ -179,10 +371,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 100,
|
||||
color: Colors.white54,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -198,14 +390,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId),
|
||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -213,32 +404,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId),
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToArtist(),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
// Album Description
|
||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
_albumDescription!,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.8),
|
||||
),
|
||||
maxLines: _isDescriptionExpanded ? null : 2,
|
||||
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
// Main Play Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Play Album'),
|
||||
label: const Text('Play'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
disabledBackgroundColor: Colors.white38,
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -247,29 +469,66 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// "Play on..." Button (Square)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _isLoading || _tracks.isEmpty ? null : () => _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 Album to Queue" Button (Square)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Favorite Button
|
||||
Container(
|
||||
height: 50,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _isFavorite ? Colors.red : Colors.white24,
|
||||
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _toggleFavorite,
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite ? Colors.white : Colors.white70,
|
||||
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
Text(
|
||||
'Tracks',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -279,18 +538,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
),
|
||||
)
|
||||
else if (_tracks.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onBackground.withOpacity(0.54),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -301,58 +560,303 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = _tracks[index];
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.position ?? index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.bold,
|
||||
final isExpanded = _expandedTrackIndex == index;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.position ?? index + 1}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(index),
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_expandedTrackIndex = isExpanded ? null : index;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: isExpanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayRadioMenu(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.tertiaryContainer,
|
||||
foregroundColor: colorScheme.onTertiaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text('Start radio'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addTrackToQueue(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text('Add to queue'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(index),
|
||||
],
|
||||
);
|
||||
},
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 88)), // Extra space for floating mini player (80px height + 8px bottom margin)
|
||||
],
|
||||
),
|
||||
// Floating mini player at bottom of screen
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Play on...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (players.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: Text('No players available'),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// Set this as the active player
|
||||
maProvider.selectPlayer(player);
|
||||
// Play album from this track onwards
|
||||
maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: startIndex,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Play on...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (players.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: Text('No players available'),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// Set this as the active player
|
||||
maProvider.selectPlayer(player);
|
||||
// Play radio based on this track
|
||||
maProvider.playRadio(
|
||||
player.playerId,
|
||||
track,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Play on...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (players.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: Text('No players available'),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// Set this as the active player
|
||||
maProvider.selectPlayer(player);
|
||||
// Play on this specific player
|
||||
maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../widgets/mini_player.dart';
|
||||
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const ArtistDetailsScreen({super.key, required this.artist});
|
||||
const ArtistDetailsScreen({
|
||||
super.key,
|
||||
required this.artist,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
||||
@@ -16,68 +25,151 @@ class ArtistDetailsScreen extends StatefulWidget {
|
||||
|
||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
List<Album> _albums = [];
|
||||
List<Album> _providerAlbums = [];
|
||||
bool _isLoading = true;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
bool _isDescriptionExpanded = false;
|
||||
String? _artistDescription;
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadArtistAlbums();
|
||||
_extractColors();
|
||||
_loadArtistDescription();
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
||||
|
||||
if (imageUrl == null) return;
|
||||
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
NetworkImage(imageUrl),
|
||||
);
|
||||
|
||||
if (colorSchemes != null && mounted) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract colors for artist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadArtistDescription() async {
|
||||
final artistName = widget.artist.name;
|
||||
|
||||
if (artistName.isEmpty) return;
|
||||
|
||||
final description = await MetadataService.getArtistDescription(
|
||||
artistName,
|
||||
widget.artist.metadata,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_artistDescription = description;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadArtistAlbums() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Load albums for this specific artist by filtering
|
||||
if (provider.api != null) {
|
||||
print('🎵 Loading albums for artist: ${widget.artist.name}');
|
||||
print(' Provider: ${widget.artist.provider}');
|
||||
print(' ItemId: ${widget.artist.itemId}');
|
||||
|
||||
// Get all albums and filter by this artist
|
||||
// 1. Get Library Albums
|
||||
final allAlbums = await provider.api!.getAlbums();
|
||||
|
||||
// Filter albums that include this artist
|
||||
final artistAlbums = allAlbums.where((album) {
|
||||
|
||||
var libraryAlbums = allAlbums.where((album) {
|
||||
if (album.artists == null) return false;
|
||||
return album.artists!.any((artist) =>
|
||||
artist.itemId == widget.artist.itemId &&
|
||||
artist.provider == widget.artist.provider
|
||||
artist.itemId == widget.artist.itemId ||
|
||||
artist.name == widget.artist.name
|
||||
);
|
||||
}).toList();
|
||||
|
||||
print(' Got ${artistAlbums.length} albums for this artist (from ${allAlbums.length} total)');
|
||||
if (artistAlbums.isNotEmpty) {
|
||||
print(' First album: ${artistAlbums.first.name}');
|
||||
List<Album> providerAlbums = [];
|
||||
|
||||
// 2. Get Provider Albums (via search)
|
||||
if (widget.artist.name.isNotEmpty) {
|
||||
try {
|
||||
final searchResults = await provider.search(widget.artist.name);
|
||||
final searchAlbums = (searchResults['albums'] as List<dynamic>?)
|
||||
?.map((item) => item as Album)
|
||||
.toList() ?? [];
|
||||
|
||||
// Filter: Must match artist name
|
||||
providerAlbums = searchAlbums.where((album) {
|
||||
return album.artists?.any((a) =>
|
||||
a.name.toLowerCase() == widget.artist.name.toLowerCase()
|
||||
) ?? false;
|
||||
}).toList();
|
||||
|
||||
// Filter: Must NOT be in libraryAlbums
|
||||
final libraryAlbumNames = libraryAlbums.map((a) => a.name.toLowerCase()).toSet();
|
||||
|
||||
providerAlbums = providerAlbums.where((a) =>
|
||||
!libraryAlbumNames.contains(a.name.toLowerCase())
|
||||
).toList();
|
||||
|
||||
} catch (e) {
|
||||
print('Error searching provider albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_albums = artistAlbums;
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerAlbums;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
||||
|
||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (useAdaptiveTheme) {
|
||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||
}
|
||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
backgroundColor: colorScheme.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
pinned: true,
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Column(
|
||||
@@ -85,17 +177,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Hero(
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||
child: CircleAvatar(
|
||||
radius: 100,
|
||||
backgroundColor: Colors.white12,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage:
|
||||
imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(
|
||||
? Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -111,70 +203,142 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId),
|
||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Albums',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(height: 16),
|
||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
_artistDescription!,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.8),
|
||||
),
|
||||
maxLines: _isDescriptionExpanded ? null : 2,
|
||||
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
),
|
||||
)
|
||||
else if (_albums.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
else if (_albums.isEmpty && _providerAlbums.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No albums found',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onBackground.withOpacity(0.54),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _albums[index];
|
||||
return _buildAlbumCard(album, maProvider);
|
||||
},
|
||||
childCount: _albums.length,
|
||||
else ...[
|
||||
// Library Albums Section
|
||||
if (_albums.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
child: Text(
|
||||
'In Library',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _albums[index];
|
||||
return _buildAlbumCard(album, maProvider);
|
||||
},
|
||||
childCount: _albums.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Provider Albums Section
|
||||
if (_providerAlbums.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
|
||||
child: Text(
|
||||
'From Providers',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _providerAlbums[index];
|
||||
return _buildAlbumCard(album, maProvider);
|
||||
},
|
||||
childCount: _providerAlbums.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 88)), // Extra space for floating mini player (80px height + 8px bottom margin)
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
// Floating mini player at bottom of screen
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -182,6 +346,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -198,7 +364,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -208,11 +374,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Center(
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -221,9 +387,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -231,9 +396,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
Text(
|
||||
album.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -14,76 +14,108 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const NewHomeScreen(),
|
||||
const NewLibraryScreen(),
|
||||
const SearchScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
body: _screens[_selectedIndex],
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Mini player
|
||||
const MiniPlayer(),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Bottom navigation bar
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
return PopScope(
|
||||
canPop: _selectedIndex == 0,
|
||||
onPopInvoked: (didPop) {
|
||||
if (didPop) return;
|
||||
setState(() {
|
||||
_selectedIndex = 0;
|
||||
});
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Home and Library use IndexedStack for state preservation
|
||||
Offstage(
|
||||
offstage: _selectedIndex > 1,
|
||||
child: IndexedStack(
|
||||
index: _selectedIndex.clamp(0, 1),
|
||||
children: const [
|
||||
NewHomeScreen(),
|
||||
NewLibraryScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: Colors.white,
|
||||
unselectedItemColor: Colors.white54,
|
||||
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',
|
||||
),
|
||||
],
|
||||
// Search and Settings are conditionally rendered (removed from tree when not visible)
|
||||
if (_selectedIndex == 2)
|
||||
SearchScreen(key: _searchScreenKey),
|
||||
if (_selectedIndex == 3)
|
||||
const SettingsScreen(),
|
||||
// Floating mini player - positioned above bottom nav, hidden on settings
|
||||
if (_selectedIndex != 3)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
|
||||
// Auto-focus search field when switching to search tab
|
||||
if (index == 2) {
|
||||
// Use post-frame callback to ensure SearchScreen is built first
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_searchScreenKey.currentState?.requestFocus();
|
||||
});
|
||||
}
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: colorScheme.primary,
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../widgets/album_card.dart';
|
||||
|
||||
class LibraryAlbumsScreen extends StatelessWidget {
|
||||
const LibraryAlbumsScreen({super.key});
|
||||
@@ -11,35 +10,38 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Albums',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildAlbumsList(provider),
|
||||
body: _buildAlbumsList(context, provider),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
||||
Widget _buildAlbumsList(BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,16 +50,16 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.album_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No albums found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -67,8 +69,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -77,6 +79,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: () async {
|
||||
await provider.loadLibrary();
|
||||
},
|
||||
@@ -91,88 +95,13 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
itemCount: provider.albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = provider.albums[index];
|
||||
return _buildAlbumCard(context, album, provider);
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
heroTagSuffix: 'library',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(
|
||||
BuildContext context, Album album, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AlbumDetailsScreen(album: album),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: NetworkImage(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,35 +11,38 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Artists',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildArtistsList(provider),
|
||||
body: _buildArtistsList(context, provider),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
||||
Widget _buildArtistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,16 +51,16 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.person_outline_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No artists found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -67,8 +70,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -77,6 +80,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: () async {
|
||||
await provider.loadLibrary();
|
||||
},
|
||||
@@ -85,36 +90,40 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final artist = provider.artists[index];
|
||||
return _buildArtistTile(artist, provider);
|
||||
return _buildArtistTile(context, artist, provider);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
||||
Widget _buildArtistTile(BuildContext context, Artist artist, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
const suffix = '_library';
|
||||
|
||||
return Builder(
|
||||
builder: (context) => ListTile(
|
||||
leading: Hero(
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.white12,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
title: Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -126,7 +135,10 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
||||
builder: (context) => ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
heroTagSuffix: 'library',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -43,35 +43,38 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Playlists',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildPlaylistsList(provider),
|
||||
body: _buildPlaylistsList(context, provider),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistsList(MusicAssistantProvider provider) {
|
||||
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,16 +83,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.playlist_play_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No playlists found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -99,8 +102,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -109,20 +112,24 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: _loadPlaylists,
|
||||
child: ListView.builder(
|
||||
itemCount: _playlists.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return _buildPlaylistTile(playlist, provider);
|
||||
return _buildPlaylistTile(context, playlist, provider);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistTile(Playlist playlist, MusicAssistantProvider provider) {
|
||||
Widget _buildPlaylistTile(BuildContext context, Playlist playlist, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Builder(
|
||||
builder: (context) => ListTile(
|
||||
@@ -130,7 +137,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -140,13 +147,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.playlist_play_rounded, color: Colors.white54)
|
||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -156,7 +163,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} tracks'
|
||||
: playlist.owner ?? 'Playlist',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -33,17 +33,18 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Library',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
@@ -59,14 +60,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white54,
|
||||
indicatorColor: colorScheme.primary,
|
||||
labelColor: colorScheme.primary,
|
||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||
tabs: const [
|
||||
Tab(text: 'Artists'),
|
||||
Tab(text: 'Albums'),
|
||||
@@ -88,22 +89,24 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'Not connected to Music Assistant',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -121,8 +124,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const Text('Configure Server'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
@@ -139,9 +142,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,16 +155,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.person_outline_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No artists found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -169,8 +174,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -190,20 +195,21 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
|
||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.white12,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -220,9 +226,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,16 +239,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.album_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No albums found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -250,8 +258,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -277,6 +285,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
|
||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -293,7 +302,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -303,11 +312,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Center(
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -316,8 +325,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -326,8 +335,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
),
|
||||
Text(
|
||||
album.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -339,9 +348,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildTracksList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,16 +361,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.music_note_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -369,8 +380,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -392,13 +403,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
final imageUrl = track.album != null
|
||||
? provider.getImageUrl(track.album!, size: 128)
|
||||
: null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -408,25 +420,25 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
|
||||
@@ -9,22 +9,23 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Tracks',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
@@ -36,9 +37,11 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildTracksList(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,16 +50,16 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.music_note_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -66,8 +69,8 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -94,13 +97,15 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
final imageUrl = track.album != null
|
||||
? maProvider.getImageUrl(track.album!, size: 128)
|
||||
: null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -110,25 +115,25 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(context, maProvider, index),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../widgets/logo_text.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -47,6 +48,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
String _normalizeServerUrl(String url) {
|
||||
// Remove any trailing slashes
|
||||
@@ -150,6 +153,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (provider.isConnected) {
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
// Ensure keyboard is closed before navigating
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
);
|
||||
@@ -170,8 +175,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -183,44 +191,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Logo
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
height: 80,
|
||||
'assets/images/attm_long_logo.png',
|
||||
height: 160,
|
||||
fit: BoxFit.contain,
|
||||
color: colorScheme.onBackground, // Tints the logo to match theme
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Welcome text
|
||||
const Text(
|
||||
'Welcome to Amass',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
const Text(
|
||||
'Connect to your Music Assistant server',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Server URL
|
||||
const Text(
|
||||
Text(
|
||||
'Server Address',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -228,34 +212,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
TextField(
|
||||
controller: _serverUrlController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2a2a2a),
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.dns_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
// ... rest of UI ...
|
||||
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Port
|
||||
const Text(
|
||||
Text(
|
||||
'Port',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -263,19 +249,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
TextField(
|
||||
controller: _portController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: '8095',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2a2a2a),
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.settings_ethernet_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
@@ -290,11 +276,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (_requiresAuth) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text(
|
||||
Text(
|
||||
'Username',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -302,19 +287,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Username',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2a2a2a),
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
@@ -323,11 +308,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text(
|
||||
Text(
|
||||
'Password',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -335,19 +319,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Password',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2a2a2a),
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
@@ -365,18 +349,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
border: Border.all(color: colorScheme.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_rounded, color: Colors.red, size: 20),
|
||||
Icon(Icons.error_rounded, color: colorScheme.error, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 14),
|
||||
style: TextStyle(color: colorScheme.onErrorContainer, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -387,8 +371,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: _isConnecting ? null : _connect,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -396,12 +380,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isConnecting
|
||||
? const SizedBox(
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF1a1a1a)),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
@@ -412,18 +396,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Help text
|
||||
const Text(
|
||||
'Need help? Make sure your Music Assistant server is running and accessible from this device.',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,42 +4,56 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/player_selector.dart';
|
||||
import '../widgets/album_row.dart';
|
||||
import '../widgets/artist_row.dart';
|
||||
import '../widgets/logo_text.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
|
||||
class NewHomeScreen extends StatelessWidget {
|
||||
class NewHomeScreen extends StatefulWidget {
|
||||
const NewHomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
||||
}
|
||||
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
|
||||
Key _refreshKey = UniqueKey();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
// Simulate a short delay or verify connection
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 8.0, bottom: 8.0),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
height: 72,
|
||||
'assets/images/attm_short_logo.png',
|
||||
height: 32,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SearchScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const PlayerSelector(),
|
||||
],
|
||||
),
|
||||
@@ -48,7 +62,12 @@ class NewHomeScreen extends StatelessWidget {
|
||||
builder: (context, maProvider, child) {
|
||||
return !maProvider.isConnected
|
||||
? _buildDisconnectedView(context, maProvider)
|
||||
: _buildConnectedView(context, maProvider);
|
||||
: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -57,31 +76,33 @@ class NewHomeScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildDisconnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 80,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
Text(
|
||||
'Not Connected',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
Text(
|
||||
'Connect to your Music Assistant server to start listening',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -99,8 +120,8 @@ class NewHomeScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const Text('Configure Server'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
@@ -118,14 +139,16 @@ class NewHomeScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// Pass the unique key to force rebuild of children when refreshed
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Recently played albums
|
||||
// Recently played albums (extracted from recently played tracks)
|
||||
AlbumRow(
|
||||
title: 'Recently Played',
|
||||
loadAlbums: () async {
|
||||
|
||||
@@ -15,33 +15,23 @@ class NewLibraryScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Library',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SearchScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const PlayerSelector(),
|
||||
],
|
||||
),
|
||||
@@ -53,22 +43,24 @@ class NewLibraryScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildDisconnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'Not connected to Music Assistant',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -86,8 +78,8 @@ class NewLibraryScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const Text('Configure Server'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
@@ -189,8 +181,12 @@ class NewLibraryScreen extends StatelessWidget {
|
||||
required String subtitle,
|
||||
required VoidCallback? onTap,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
elevation: 0, // Remove shadow
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -200,34 +196,32 @@ class NewLibraryScreen extends StatelessWidget {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 13,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
trailing: onTap != null
|
||||
? const Icon(
|
||||
? Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../widgets/volume_control.dart';
|
||||
import 'queue_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../widgets/animated_icon_button.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
|
||||
class NowPlayingScreen extends StatefulWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
@@ -20,6 +22,9 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
bool _isLoadingQueue = true;
|
||||
Timer? _progressTimer;
|
||||
double? _seekPosition; // Track seek position while dragging
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
String? _lastImageUrl; // Track last image URL to avoid re-extracting
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -28,6 +33,26 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
_startProgressTimer();
|
||||
}
|
||||
|
||||
Future<void> _extractColors(String imageUrl) async {
|
||||
if (_lastImageUrl == imageUrl) return; // Skip if same image
|
||||
_lastImageUrl = imageUrl;
|
||||
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
NetworkImage(imageUrl),
|
||||
);
|
||||
|
||||
if (colorSchemes != null && mounted) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract colors for now playing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressTimer?.cancel();
|
||||
@@ -87,6 +112,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
@@ -117,17 +144,50 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
||||
|
||||
// Extract colors if adaptive theme is enabled and we have an image URL
|
||||
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
||||
_extractColors(imageUrl);
|
||||
}
|
||||
|
||||
// Determine if we should use adaptive theme colors
|
||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Get the color scheme to use
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (useAdaptiveTheme) {
|
||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
||||
}
|
||||
|
||||
// Determine colors to use
|
||||
final backgroundColor = useAdaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.background
|
||||
: const Color(0xFF1a1a1a);
|
||||
|
||||
final surfaceColor = useAdaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.surface
|
||||
: const Color(0xFF2a2a2a);
|
||||
|
||||
final primaryColor = useAdaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.primary
|
||||
: Colors.white;
|
||||
|
||||
final textColor = useAdaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.onSurface
|
||||
: Colors.white;
|
||||
|
||||
return Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
transitionOnUserGestures: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
selectedPlayer.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
@@ -135,7 +195,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
icon: Icon(Icons.queue_music, color: textColor),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -160,6 +220,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
return Center(
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
transitionOnUserGestures: true,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -202,8 +263,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
// Track Info
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -214,8 +275,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -226,8 +287,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentTrack.album!.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.54),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -287,8 +348,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white24,
|
||||
activeColor: primaryColor,
|
||||
inactiveColor: primaryColor.withOpacity(0.24),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -298,11 +359,11 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(currentTrack.duration!.inSeconds),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -323,7 +384,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
color: _queue?.shuffle == true ? Colors.blue : Colors.white54,
|
||||
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.54),
|
||||
),
|
||||
iconSize: 24,
|
||||
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
||||
@@ -332,11 +393,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
// Previous
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
color: textColor,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
@@ -357,10 +419,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
// Play/Pause
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _AnimatedPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: backgroundColor,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
@@ -380,11 +445,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
// Next
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
color: textColor,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
@@ -409,8 +475,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
||||
? Colors.blue
|
||||
: Colors.white54,
|
||||
? primaryColor
|
||||
: textColor.withOpacity(0.54),
|
||||
),
|
||||
iconSize: 24,
|
||||
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
||||
@@ -446,10 +512,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
/// Animated play/pause button with scale animation
|
||||
class _AnimatedPlayButton extends StatefulWidget {
|
||||
final bool isPlaying;
|
||||
final Color primaryColor;
|
||||
final Color backgroundColor;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _AnimatedPlayButton({
|
||||
required this.isPlaying,
|
||||
required this.primaryColor,
|
||||
required this.backgroundColor,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@@ -500,15 +570,15 @@ class _AnimatedPlayButtonState extends State<_AnimatedPlayButton>
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
),
|
||||
color: const Color(0xFF1a1a1a),
|
||||
color: widget.backgroundColor,
|
||||
iconSize: 42,
|
||||
onPressed: _handleTap,
|
||||
),
|
||||
|
||||
@@ -9,10 +9,10 @@ class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
State<SearchScreen> createState() => SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> {
|
||||
class SearchScreenState extends State<SearchScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
Map<String, List<MediaItem>> _searchResults = {
|
||||
@@ -22,14 +22,25 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
};
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
String _activeFilter = 'all'; // 'all', 'artists', 'albums', 'tracks'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Auto-focus the search field when screen opens
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Don't auto-focus - let user tap to focus
|
||||
// This prevents keyboard popup bug when SearchScreen is in widget tree but not visible
|
||||
}
|
||||
|
||||
void requestFocus() {
|
||||
if (mounted) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void removeFocus() {
|
||||
if (mounted) {
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -65,23 +76,26 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF2a2a2a),
|
||||
backgroundColor: colorScheme.surface,
|
||||
elevation: 0,
|
||||
title: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search music...',
|
||||
hintStyle: const TextStyle(color: Colors.white54),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear_rounded, color: Colors.white54),
|
||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_performSearch('');
|
||||
@@ -103,22 +117,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDisconnectedView() {
|
||||
return const Center(
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onBackground.withOpacity(0.54),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Not connected to Music Assistant',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -130,9 +146,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildSearchContent() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isSearching) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,13 +162,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
Icon(
|
||||
Icons.search_rounded,
|
||||
size: 80,
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: colorScheme.onBackground.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search for artists, albums, or tracks',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
color: colorScheme.onBackground.withOpacity(0.5),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -173,13 +191,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
Icon(
|
||||
Icons.search_off_rounded,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: colorScheme.onBackground.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No results found',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
color: colorScheme.onBackground.withOpacity(0.5),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -188,37 +206,85 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
children: [
|
||||
if (artists.isNotEmpty) ...[
|
||||
_buildSectionHeader('Artists', artists.length),
|
||||
const SizedBox(height: 8),
|
||||
...artists.map((item) => _buildArtistTile(item as Artist)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (albums.isNotEmpty) ...[
|
||||
_buildSectionHeader('Albums', albums.length),
|
||||
const SizedBox(height: 8),
|
||||
...albums.map((item) => _buildAlbumTile(item as Album)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (tracks.isNotEmpty) ...[
|
||||
_buildSectionHeader('Tracks', tracks.length),
|
||||
const SizedBox(height: 8),
|
||||
...tracks.map((item) => _buildTrackTile(item as Track)),
|
||||
],
|
||||
// Filters
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('All', 'all'),
|
||||
const SizedBox(width: 8),
|
||||
if (artists.isNotEmpty) ...[
|
||||
_buildFilterChip('Artists', 'artists'),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (albums.isNotEmpty) ...[
|
||||
_buildFilterChip('Albums', 'albums'),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (tracks.isNotEmpty) ...[
|
||||
_buildFilterChip('Tracks', 'tracks'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Results
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) ...[
|
||||
if (_activeFilter == 'all') _buildSectionHeader('Artists', artists.length),
|
||||
...artists.map((item) => _buildArtistTile(item as Artist)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) ...[
|
||||
if (_activeFilter == 'all') _buildSectionHeader('Albums', albums.length),
|
||||
...albums.map((item) => _buildAlbumTile(item as Album)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) ...[
|
||||
if (_activeFilter == 'all') _buildSectionHeader('Tracks', tracks.length),
|
||||
...tracks.map((item) => _buildTrackTile(item as Track)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String label, String value) {
|
||||
final isSelected = _activeFilter == value;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_activeFilter = value;
|
||||
});
|
||||
},
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
checkmarkColor: colorScheme.onPrimaryContainer,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurface,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, int count) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
'$title ($count)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onBackground,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -229,28 +295,29 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
Widget _buildArtistTile(Artist artist) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.white12,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: const Text(
|
||||
subtitle: Text(
|
||||
'Artist',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
@@ -266,13 +333,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
Widget _buildAlbumTile(Album album) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -282,13 +350,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.album_rounded, color: Colors.white54)
|
||||
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -296,7 +364,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
subtitle: Text(
|
||||
album.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -316,13 +384,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
final imageUrl = track.album != null
|
||||
? maProvider.getImageUrl(track.album!, size: 128)
|
||||
: null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -332,13 +401,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -346,14 +415,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(track),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import 'debug_log_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -19,6 +20,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _portController = TextEditingController(text: '8095');
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _lastFmApiKeyController = TextEditingController();
|
||||
final _audioDbApiKeyController = TextEditingController();
|
||||
final _authService = AuthService();
|
||||
final _logger = DebugLogger();
|
||||
bool _isConnecting = false;
|
||||
@@ -47,6 +50,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (password != null) {
|
||||
_passwordController.text = password;
|
||||
}
|
||||
|
||||
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||
if (lastFmKey != null) {
|
||||
_lastFmApiKeyController.text = lastFmKey;
|
||||
}
|
||||
|
||||
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||
if (audioDbKey != null) {
|
||||
_audioDbApiKeyController.text = audioDbKey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,6 +68,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_portController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_lastFmApiKeyController.dispose();
|
||||
_audioDbApiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -64,7 +79,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and save port
|
||||
final port = _portController.text.trim();
|
||||
if (port.isEmpty) {
|
||||
_showError('Please enter a port number');
|
||||
@@ -84,7 +98,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Attempt authentication if username/password provided
|
||||
if (_usernameController.text.trim().isNotEmpty &&
|
||||
_passwordController.text.trim().isNotEmpty) {
|
||||
_logger.log('🔐 Attempting login with credentials...');
|
||||
@@ -96,7 +109,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
|
||||
if (token != null) {
|
||||
// Save credentials for future use
|
||||
await SettingsService.setUsername(_usernameController.text.trim());
|
||||
await SettingsService.setPassword(_passwordController.text.trim());
|
||||
|
||||
@@ -117,7 +129,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to Music Assistant
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
await provider.connectToServer(_serverUrlController.text);
|
||||
|
||||
@@ -153,22 +164,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
@@ -184,7 +196,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
tooltip: 'Debug Logs',
|
||||
),
|
||||
],
|
||||
@@ -194,18 +206,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Connection status
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getStatusIcon(provider.connectionState),
|
||||
color: _getStatusColor(provider.connectionState),
|
||||
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -213,19 +224,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'Connection Status',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getStatusText(provider.connectionState),
|
||||
style: TextStyle(
|
||||
color: _getStatusColor(provider.connectionState),
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -238,40 +247,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Server URL input
|
||||
const Text(
|
||||
Text(
|
||||
'Music Assistant Server',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'Enter your Music Assistant server URL or IP address',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _serverUrlController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: Colors.white12,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.dns_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
@@ -279,41 +285,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Port input
|
||||
const Text(
|
||||
Text(
|
||||
'Port',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'Music Assistant WebSocket port (usually 8095)',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _portController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: '8095',
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: Colors.white12,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.settings_ethernet_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
@@ -321,27 +324,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Connect button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isConnecting ? null : _connect,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
disabledBackgroundColor: Colors.white38,
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isConnecting
|
||||
? const SizedBox(
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF1a1a1a),
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
@@ -371,8 +373,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white38),
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error.withOpacity(0.5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -390,7 +392,228 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Debug logs button
|
||||
Text(
|
||||
'Theme',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Theme Mode',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
return SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Light'),
|
||||
icon: Icon(Icons.light_mode_rounded),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Dark'),
|
||||
icon: Icon(Icons.dark_mode_rounded),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.system,
|
||||
label: Text('System'),
|
||||
icon: Icon(Icons.auto_mode_rounded),
|
||||
),
|
||||
],
|
||||
selected: {themeProvider.themeMode},
|
||||
onSelectionChanged: (Set<ThemeMode> newSelection) {
|
||||
themeProvider.setThemeMode(newSelection.first);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return colorScheme.primaryContainer;
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
foregroundColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return colorScheme.onPrimaryContainer;
|
||||
}
|
||||
return colorScheme.onSurfaceVariant;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Material You',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Use system colors (Android 12+)',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: themeProvider.useMaterialTheme,
|
||||
onChanged: (value) {
|
||||
themeProvider.setUseMaterialTheme(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Adaptive Theme',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Extract colors from album and artist artwork',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: themeProvider.adaptiveTheme,
|
||||
onChanged: (value) {
|
||||
themeProvider.setAdaptiveTheme(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Text(
|
||||
'Metadata APIs (Optional)',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _lastFmApiKeyController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Last.fm API Key',
|
||||
hintText: 'Get free key at last.fm/api',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
suffixIcon: _lastFmApiKeyController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_lastFmApiKeyController.clear();
|
||||
});
|
||||
SettingsService.setLastFmApiKey(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
SettingsService.setLastFmApiKey(value.trim().isEmpty ? null : value.trim());
|
||||
setState(() {}); // Update UI to show/hide clear button
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _audioDbApiKeyController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TheAudioDB API Key (Premium)',
|
||||
hintText: 'Use "2" for free tier or premium key',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.audiotrack_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
suffixIcon: _audioDbApiKeyController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_audioDbApiKeyController.clear();
|
||||
});
|
||||
SettingsService.setTheAudioDbApiKey(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
SettingsService.setTheAudioDbApiKey(value.trim().isEmpty ? null : value.trim());
|
||||
setState(() {}); // Update UI to show/hide clear button
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -406,8 +629,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
icon: const Icon(Icons.bug_report_rounded),
|
||||
label: const Text('View Debug Logs'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white38),
|
||||
foregroundColor: colorScheme.onBackground,
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -417,14 +640,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Info section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -432,38 +654,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'Connection Info',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
Text(
|
||||
'• Default ports: 443 for HTTPS, 8095 for HTTP\n'
|
||||
'• You can override the port in the WebSocket Port field\n'
|
||||
'• Use domain name or IP address for server\n'
|
||||
'• Make sure your device can reach the server\n'
|
||||
'• Check debug logs if connection fails',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -483,16 +704,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(MAConnectionState state) {
|
||||
Color _getStatusColor(MAConnectionState state, ColorScheme colorScheme) {
|
||||
switch (state) {
|
||||
case MAConnectionState.connected:
|
||||
return Colors.green;
|
||||
case MAConnectionState.connecting:
|
||||
return Colors.orange;
|
||||
case MAConnectionState.error:
|
||||
return Colors.red;
|
||||
return colorScheme.error;
|
||||
case MAConnectionState.disconnected:
|
||||
return Colors.white54;
|
||||
return colorScheme.onSurface.withOpacity(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'settings_service.dart';
|
||||
|
||||
class MetadataService {
|
||||
// Cache to avoid repeated API calls for the same artist/album
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
/// Fetches artist biography/description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
/// 2. Last.fm API (if key configured)
|
||||
/// 3. TheAudioDB API (if key configured)
|
||||
static Future<String?> getArtistDescription(
|
||||
String artistName,
|
||||
Map<String, dynamic>? musicAssistantMetadata,
|
||||
) async {
|
||||
// Try Music Assistant metadata first
|
||||
if (musicAssistantMetadata != null) {
|
||||
final maDescription = musicAssistantMetadata['description'] ??
|
||||
musicAssistantMetadata['biography'] ??
|
||||
musicAssistantMetadata['wiki'] ??
|
||||
musicAssistantMetadata['bio'] ??
|
||||
musicAssistantMetadata['summary'];
|
||||
|
||||
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||
return maDescription;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'artist:$artistName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API
|
||||
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
|
||||
// Try TheAudioDB API
|
||||
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||
if (audioDbDesc != null) {
|
||||
_cache[cacheKey] = audioDbDesc;
|
||||
return audioDbDesc;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches album description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
/// 2. Last.fm API (if key configured)
|
||||
static Future<String?> getAlbumDescription(
|
||||
String artistName,
|
||||
String albumName,
|
||||
Map<String, dynamic>? musicAssistantMetadata,
|
||||
) async {
|
||||
// Try Music Assistant metadata first
|
||||
if (musicAssistantMetadata != null) {
|
||||
final maDescription = musicAssistantMetadata['description'] ??
|
||||
musicAssistantMetadata['wiki'] ??
|
||||
musicAssistantMetadata['biography'] ??
|
||||
musicAssistantMetadata['summary'];
|
||||
|
||||
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||
return maDescription;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'album:$artistName:$albumName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _fetchFromLastFm(
|
||||
String artistName,
|
||||
String? albumName,
|
||||
String apiKey,
|
||||
) async {
|
||||
try {
|
||||
final String method;
|
||||
final Map<String, String> params = {
|
||||
'api_key': apiKey,
|
||||
'format': 'json',
|
||||
};
|
||||
|
||||
if (albumName != null) {
|
||||
// Album info
|
||||
method = 'album.getinfo';
|
||||
params['artist'] = artistName;
|
||||
params['album'] = albumName;
|
||||
} else {
|
||||
// Artist info
|
||||
method = 'artist.getinfo';
|
||||
params['artist'] = artistName;
|
||||
}
|
||||
|
||||
params['method'] = method;
|
||||
|
||||
final uri = Uri.https('ws.audioscrobbler.com', '/2.0/', params);
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
||||
if (albumName != null) {
|
||||
// Parse album response
|
||||
final album = data['album'];
|
||||
if (album != null) {
|
||||
final wiki = album['wiki'];
|
||||
if (wiki != null) {
|
||||
// Prefer summary, fall back to content
|
||||
return _cleanLastFmText(wiki['summary'] ?? wiki['content']);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parse artist response
|
||||
final artist = data['artist'];
|
||||
if (artist != null) {
|
||||
final bio = artist['bio'];
|
||||
if (bio != null) {
|
||||
// Prefer summary, fall back to content
|
||||
return _cleanLastFmText(bio['summary'] ?? bio['content']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Last.fm API error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _fetchFromTheAudioDb(
|
||||
String artistName,
|
||||
String apiKey,
|
||||
) async {
|
||||
try {
|
||||
final uri = Uri.https(
|
||||
'theaudiodb.com',
|
||||
'/api/v1/json/$apiKey/search.php',
|
||||
{'s': artistName},
|
||||
);
|
||||
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final artists = data['artists'];
|
||||
|
||||
if (artists != null && artists.isNotEmpty) {
|
||||
final artist = artists[0];
|
||||
// Try multiple language fields
|
||||
return artist['strBiographyEN'] ??
|
||||
artist['strBiographyDE'] ??
|
||||
artist['strBiographyFR'] ??
|
||||
artist['strBiographyIT'] ??
|
||||
artist['strBiographyES'];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ TheAudioDB API error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Removes Last.fm HTML tags and links
|
||||
static String? _cleanLastFmText(String? text) {
|
||||
if (text == null) return null;
|
||||
|
||||
// Remove <a href...> tags
|
||||
text = text.replaceAll(RegExp(r'<a[^>]*>'), '');
|
||||
text = text.replaceAll('</a>', '');
|
||||
|
||||
// Remove "Read more on Last.fm" footer
|
||||
text = text.replaceAll(RegExp(r'\s*<a[^>]*>.*?</a>.*$'), '');
|
||||
|
||||
// Clean up any remaining HTML
|
||||
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/// Clears the metadata cache
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,16 @@ class MusicAssistantAPI {
|
||||
useSecure = wsUrl.startsWith('wss://');
|
||||
}
|
||||
|
||||
// Get or generate a persistent client ID to prevent ghost players
|
||||
var clientId = await SettingsService.getBuiltinPlayerId();
|
||||
if (clientId == null) {
|
||||
clientId = _uuid.v4();
|
||||
await SettingsService.setBuiltinPlayerId(clientId);
|
||||
_logger.log('Generated and saved new client ID: $clientId');
|
||||
} else {
|
||||
_logger.log('Using existing client ID: $clientId');
|
||||
}
|
||||
|
||||
// Construct WebSocket URL with proper port handling
|
||||
final uri = Uri.parse(wsUrl);
|
||||
|
||||
@@ -85,6 +95,7 @@ class MusicAssistantAPI {
|
||||
host: uri.host,
|
||||
port: _cachedCustomPort,
|
||||
path: '/ws',
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
||||
} else if (uri.hasPort) {
|
||||
@@ -94,6 +105,7 @@ class MusicAssistantAPI {
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
path: '/ws',
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using port from URL: ${uri.port}');
|
||||
} else {
|
||||
@@ -105,6 +117,7 @@ class MusicAssistantAPI {
|
||||
host: uri.host,
|
||||
port: 8095,
|
||||
path: '/ws',
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using port 8095 for unsecure connection');
|
||||
} else {
|
||||
@@ -115,6 +128,7 @@ class MusicAssistantAPI {
|
||||
host: uri.host,
|
||||
// NO PORT - let it use default 443 implicitly
|
||||
path: '/ws',
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using implicit default port (443) for secure connection');
|
||||
}
|
||||
@@ -259,9 +273,10 @@ class MusicAssistantAPI {
|
||||
int? offset,
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
bool albumArtistsOnly = true,
|
||||
}) async {
|
||||
try {
|
||||
_logger.log('Fetching artists with limit=$limit, offset=$offset, search=$search');
|
||||
_logger.log('Fetching artists with limit=$limit, offset=$offset, search=$search, albumArtistsOnly=$albumArtistsOnly');
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
args: {
|
||||
@@ -269,6 +284,7 @@ class MusicAssistantAPI {
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -289,14 +305,15 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10}) async {
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
|
||||
try {
|
||||
_logger.log('Fetching random artists (limit=$limit)');
|
||||
_logger.log('Fetching random artists (limit=$limit, albumArtistsOnly=$albumArtistsOnly)');
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -327,7 +344,7 @@ class MusicAssistantAPI {
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist': artistId,
|
||||
if (artistId != null) 'artist_id': artistId,
|
||||
};
|
||||
_logger.log('Albums API args: $args');
|
||||
|
||||
@@ -390,31 +407,60 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Get recently played albums
|
||||
/// Gets recently played tracks, then fetches full track details to extract album info
|
||||
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
||||
try {
|
||||
_logger.log('Fetching recently played albums (limit=$limit)');
|
||||
|
||||
// Get recently played tracks (simplified objects)
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
'music/recently_played_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'sort': 'timestamp_played', // Sort by when last played
|
||||
'order': 'desc', // Most recent first
|
||||
'limit': limit * 5,
|
||||
'media_types': ['track'],
|
||||
},
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('⚠️ No items returned for recent albums');
|
||||
if (items == null || items.isEmpty) {
|
||||
_logger.log('⚠️ No recently played tracks found');
|
||||
return [];
|
||||
}
|
||||
|
||||
_logger.log('✅ Got ${items.length} recent albums');
|
||||
if (items.isNotEmpty) {
|
||||
_logger.log(' First album: ${items[0]['name']} by ${items[0]['artists']?[0]?['name'] ?? 'Unknown'}');
|
||||
// Fetch full track details to get album data
|
||||
final seenAlbumIds = <String>{};
|
||||
final albums = <Album>[];
|
||||
|
||||
for (final item in items) {
|
||||
if (albums.length >= limit) break;
|
||||
|
||||
try {
|
||||
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
|
||||
if (trackUri == null) continue;
|
||||
|
||||
final trackResponse = await _sendCommand(
|
||||
'music/item_by_uri',
|
||||
args: {'uri': trackUri},
|
||||
).timeout(const Duration(seconds: 3), onTimeout: () => <String, dynamic>{'result': null});
|
||||
|
||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||
|
||||
if (albumData != null) {
|
||||
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
|
||||
if (albumId != null && !seenAlbumIds.contains(albumId)) {
|
||||
seenAlbumIds.add(albumId);
|
||||
albums.add(Album.fromJson(albumData));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Skip tracks that fail to fetch
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return items
|
||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
_logger.log('✅ Found ${albums.length} recently played albums');
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error getting recent albums: $e');
|
||||
return [];
|
||||
@@ -677,39 +723,42 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
// Search
|
||||
Future<Map<String, List<MediaItem>>> search(String query) async {
|
||||
/// Search across all providers (Spotify, etc.) and library
|
||||
/// Set libraryOnly to true to search only local library
|
||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||
return await RetryHelper.retryNetwork(
|
||||
operation: () async {
|
||||
try {
|
||||
_logger.log('Searching for: $query');
|
||||
_logger.log('Searching for: $query (libraryOnly: $libraryOnly)');
|
||||
|
||||
// Search each media type separately
|
||||
final artistsResponse = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
args: {'search': query, 'limit': 50},
|
||||
// Use the global search command that searches across all providers
|
||||
final searchResponse = await _sendCommand(
|
||||
'music/search',
|
||||
args: {
|
||||
'search_query': query,
|
||||
'limit': 50, // Results per media type
|
||||
'library_only': libraryOnly,
|
||||
},
|
||||
);
|
||||
|
||||
final albumsResponse = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
args: {'search': query, 'limit': 50},
|
||||
);
|
||||
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||
if (result == null) {
|
||||
_logger.log('No search results returned');
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
|
||||
final tracksResponse = await _sendCommand(
|
||||
'music/tracks/library_items',
|
||||
args: {'search': query, 'limit': 50},
|
||||
);
|
||||
|
||||
final artists = (artistsResponse['result'] as List<dynamic>?)
|
||||
// Parse results from the search response
|
||||
final artists = (result['artists'] as List<dynamic>?)
|
||||
?.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final albums = (albumsResponse['result'] as List<dynamic>?)
|
||||
final albums = (result['albums'] as List<dynamic>?)
|
||||
?.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final tracks = (tracksResponse['result'] as List<dynamic>?)
|
||||
final tracks = (result['tracks'] as List<dynamic>?)
|
||||
?.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
@@ -886,18 +935,20 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Play a single track via queue
|
||||
Future<void> playTrack(String playerId, Track track) async {
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
try {
|
||||
// Build URI from provider mappings
|
||||
final uri = _buildTrackUri(track);
|
||||
_logger.log('Playing track via queue: $uri on player $playerId');
|
||||
final option = clearQueue ? 'replace' : 'play';
|
||||
_logger.log('Playing track via queue: $uri on player $playerId (option: $option)');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri], // Array of URI strings, not objects
|
||||
'option': 'play', // Play immediately
|
||||
'option': option, // 'replace' clears queue, 'play' adds to queue
|
||||
},
|
||||
);
|
||||
|
||||
@@ -909,25 +960,67 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Play multiple tracks via queue
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex}) async {
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
operation: () async {
|
||||
// Build array of URI strings (not objects!)
|
||||
final mediaUris = tracks.map((track) => _buildTrackUri(track)).toList();
|
||||
// If startIndex is provided, slice the tracks list to start from that index
|
||||
// This is a workaround since Music Assistant ignores the start_item parameter
|
||||
final tracksToPlay = startIndex != null && startIndex > 0
|
||||
? tracks.sublist(startIndex)
|
||||
: tracks;
|
||||
|
||||
_logger.log('Playing ${tracks.length} tracks via queue on player $playerId');
|
||||
// Build array of URI strings (not objects!)
|
||||
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
|
||||
|
||||
final option = clearQueue ? 'replace' : 'play';
|
||||
_logger.log('Playing ${tracksToPlay.length} tracks via queue on player $playerId (option: $option, startIndex: $startIndex, sliced: ${startIndex != null && startIndex > 0})');
|
||||
|
||||
final args = {
|
||||
'queue_id': playerId,
|
||||
'media': mediaUris, // Array of URI strings
|
||||
'option': option, // 'replace' clears queue, 'play' adds to queue
|
||||
};
|
||||
|
||||
_logger.log('📤 Sending play_media command with args: $args');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': mediaUris, // Array of URI strings
|
||||
'option': 'play', // Play immediately
|
||||
if (startIndex != null) 'start_item': startIndex,
|
||||
},
|
||||
args: args,
|
||||
);
|
||||
|
||||
_logger.log('✓ ${tracks.length} tracks queued successfully');
|
||||
_logger.log('✓ ${tracksToPlay.length} tracks queued successfully');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Play radio based on a track (generates similar tracks)
|
||||
Future<void> playRadio(String playerId, Track track) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
operation: () async {
|
||||
// Try using library URI if available, otherwise use provider URI
|
||||
final libraryUri = track.uri;
|
||||
final trackUri = libraryUri ?? _buildTrackUri(track);
|
||||
_logger.log('Playing radio based on track: $trackUri (library URI: $libraryUri) on player $playerId');
|
||||
|
||||
final args = {
|
||||
'queue_id': playerId,
|
||||
'media': [trackUri],
|
||||
'option': 'replace',
|
||||
'radio_mode': true, // Enable radio mode for similar tracks
|
||||
};
|
||||
|
||||
_logger.log('📤 Sending play_media command (RADIO MODE) with args: $args');
|
||||
_logger.log(' Track details: provider=${track.provider}, itemId=${track.itemId}');
|
||||
_logger.log(' Available providers: ${track.providerMappings?.map((m) => '${m.providerDomain}:${m.available}').join(', ')}');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: args,
|
||||
);
|
||||
|
||||
_logger.log('✓ Radio mode command sent successfully');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1000,6 +1093,16 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
// Player control commands
|
||||
Future<void> setPower(String playerId, bool powered) async {
|
||||
await _sendCommand(
|
||||
'players/cmd/power',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'powered': powered,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pausePlayer(String playerId) async {
|
||||
await _sendQueueCommand(playerId, 'pause');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ class SettingsService {
|
||||
static const String _keyUsername = 'username';
|
||||
static const String _keyPassword = 'password';
|
||||
static const String _keyBuiltinPlayerId = 'builtin_player_id';
|
||||
static const String _keyThemeMode = 'theme_mode';
|
||||
static const String _keyUseMaterialTheme = 'use_material_theme';
|
||||
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
||||
static const String _keyCustomColor = 'custom_color';
|
||||
static const String _keyLastFmApiKey = 'lastfm_api_key';
|
||||
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -111,6 +117,76 @@ class SettingsService {
|
||||
await prefs.setString(_keyBuiltinPlayerId, id);
|
||||
}
|
||||
|
||||
// Theme settings
|
||||
static Future<String?> getThemeMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyThemeMode) ?? 'system';
|
||||
}
|
||||
|
||||
static Future<void> saveThemeMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyThemeMode, mode);
|
||||
}
|
||||
|
||||
static Future<bool> getUseMaterialTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyUseMaterialTheme) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> saveUseMaterialTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyUseMaterialTheme, enabled);
|
||||
}
|
||||
|
||||
static Future<bool> getAdaptiveTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyAdaptiveTheme) ?? true; // Default to true
|
||||
}
|
||||
|
||||
static Future<void> saveAdaptiveTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyAdaptiveTheme, enabled);
|
||||
}
|
||||
|
||||
static Future<String?> getCustomColor() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyCustomColor);
|
||||
}
|
||||
|
||||
static Future<void> saveCustomColor(String color) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyCustomColor, color);
|
||||
}
|
||||
|
||||
// Metadata API Keys
|
||||
static Future<String?> getLastFmApiKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLastFmApiKey);
|
||||
}
|
||||
|
||||
static Future<void> setLastFmApiKey(String? key) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (key == null || key.isEmpty) {
|
||||
await prefs.remove(_keyLastFmApiKey);
|
||||
} else {
|
||||
await prefs.setString(_keyLastFmApiKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> getTheAudioDbApiKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyTheAudioDbApiKey);
|
||||
}
|
||||
|
||||
static Future<void> setTheAudioDbApiKey(String? key) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (key == null || key.isEmpty) {
|
||||
await prefs.remove(_keyTheAudioDbApiKey);
|
||||
} else {
|
||||
await prefs.setString(_keyTheAudioDbApiKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clearSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Brand colors for Amass
|
||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||
const Color brandAccentColor = Color(0xFF604CEC);
|
||||
|
||||
// Brand color schemes
|
||||
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: brandAccentColor,
|
||||
brightness: Brightness.light,
|
||||
background: const Color(0xFFFAFAFA),
|
||||
onBackground: const Color(0xFF1a1a1a), // Ensure dark text
|
||||
surface: const Color(0xFFFFFFFF),
|
||||
onSurface: const Color(0xFF1a1a1a), // Ensure dark text
|
||||
);
|
||||
|
||||
final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: brandAccentColor,
|
||||
brightness: Brightness.dark,
|
||||
surface: const Color(0xFF2a2a2a),
|
||||
background: const Color(0xFF1a1a1a),
|
||||
);
|
||||
|
||||
class AppTheme {
|
||||
// Generate light theme
|
||||
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
||||
final scheme = colorScheme ?? brandLightColorScheme;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.background,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
);
|
||||
}
|
||||
|
||||
// Generate dark theme
|
||||
static ThemeData darkTheme({ColorScheme? colorScheme}) {
|
||||
final scheme = colorScheme ?? brandDarkColorScheme;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.background,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
class PaletteHelper {
|
||||
/// Extract a color palette from an image
|
||||
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
|
||||
try {
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
imageProvider,
|
||||
maximumColorCount: 20,
|
||||
);
|
||||
return palette;
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract palette: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate color schemes from a palette
|
||||
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
// Get the dominant color as the seed color
|
||||
Color seedColor = palette.dominantColor?.color ??
|
||||
palette.vibrantColor?.color ??
|
||||
palette.mutedColor?.color ??
|
||||
const Color(0xFF604CEC); // Fallback to brand color
|
||||
|
||||
// Generate light and dark color schemes from the seed color
|
||||
final lightScheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
final darkScheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.dark,
|
||||
// Use darker background colors for better contrast
|
||||
surface: const Color(0xFF2a2a2a),
|
||||
background: const Color(0xFF1a1a1a),
|
||||
);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
|
||||
/// Extract color schemes from an image provider in one call
|
||||
static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async {
|
||||
final palette = await extractPalette(imageProvider);
|
||||
return generateColorSchemes(palette);
|
||||
}
|
||||
|
||||
/// Get primary color for use in UI elements
|
||||
static Color? getPrimaryColor(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
return palette.vibrantColor?.color ??
|
||||
palette.dominantColor?.color ??
|
||||
palette.lightVibrantColor?.color;
|
||||
}
|
||||
|
||||
/// Get background color for use in UI
|
||||
static Color? getBackgroundColor(PaletteGenerator? palette, {required bool isDark}) {
|
||||
if (palette == null) return null;
|
||||
|
||||
if (isDark) {
|
||||
return palette.darkMutedColor?.color ??
|
||||
palette.mutedColor?.color?.withOpacity(0.3) ??
|
||||
const Color(0xFF1a1a1a);
|
||||
} else {
|
||||
return palette.lightMutedColor?.color ??
|
||||
palette.mutedColor?.color?.withOpacity(0.9) ??
|
||||
Colors.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
|
||||
class SystemThemeHelper {
|
||||
/// Attempts to get system color schemes from the device (Android 12+)
|
||||
/// Returns a tuple of (light, dark) ColorScheme if successful, null otherwise
|
||||
static Future<(ColorScheme, ColorScheme)?> getSystemColorSchemes() async {
|
||||
try {
|
||||
// Try to get dynamic color schemes from the system
|
||||
final corePalette = await DynamicColorPlugin.getCorePalette();
|
||||
|
||||
if (corePalette != null) {
|
||||
// Generate color schemes from the core palette
|
||||
final lightScheme = corePalette.toColorScheme(brightness: Brightness.light);
|
||||
final darkScheme = corePalette.toColorScheme(brightness: Brightness.dark);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
} else {
|
||||
// Fallback: try to get just the accent color
|
||||
final accentColor = await DynamicColorPlugin.getAccentColor();
|
||||
|
||||
if (accentColor != null) {
|
||||
// Generate color schemes from the accent color
|
||||
final lightScheme = ColorScheme.fromSeed(
|
||||
seedColor: accentColor,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
final darkScheme = ColorScheme.fromSeed(
|
||||
seedColor: accentColor,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to get system colors: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class ThemeProvider extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
bool _useMaterialTheme = false;
|
||||
bool _adaptiveTheme = true;
|
||||
Color _customColor = const Color(0xFF604CEC);
|
||||
|
||||
ThemeProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
bool get useMaterialTheme => _useMaterialTheme;
|
||||
bool get adaptiveTheme => _adaptiveTheme;
|
||||
Color get customColor => _customColor;
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final themeModeString = await SettingsService.getThemeMode();
|
||||
_themeMode = _parseThemeMode(themeModeString);
|
||||
|
||||
_useMaterialTheme = await SettingsService.getUseMaterialTheme();
|
||||
_adaptiveTheme = await SettingsService.getAdaptiveTheme();
|
||||
|
||||
final colorString = await SettingsService.getCustomColor();
|
||||
if (colorString != null) {
|
||||
_customColor = _parseColor(colorString);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ThemeMode _parseThemeMode(String? mode) {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
return ThemeMode.light;
|
||||
case 'dark':
|
||||
return ThemeMode.dark;
|
||||
case 'system':
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
Color _parseColor(String colorString) {
|
||||
try {
|
||||
// Remove # if present
|
||||
final hex = colorString.replaceAll('#', '');
|
||||
// Add FF for alpha if not present
|
||||
final hexWithAlpha = hex.length == 6 ? 'FF$hex' : hex;
|
||||
return Color(int.parse(hexWithAlpha, radix: 16));
|
||||
} catch (e) {
|
||||
return const Color(0xFF604CEC); // Default color
|
||||
}
|
||||
}
|
||||
|
||||
String _colorToString(Color color) {
|
||||
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
_themeMode = mode;
|
||||
await SettingsService.saveThemeMode(_themeModeToString(mode));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _themeModeToString(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
return 'light';
|
||||
case ThemeMode.dark:
|
||||
return 'dark';
|
||||
case ThemeMode.system:
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUseMaterialTheme(bool enabled) async {
|
||||
_useMaterialTheme = enabled;
|
||||
await SettingsService.saveUseMaterialTheme(enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setAdaptiveTheme(bool enabled) async {
|
||||
_adaptiveTheme = enabled;
|
||||
await SettingsService.saveAdaptiveTheme(enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCustomColor(Color color) async {
|
||||
_customColor = color;
|
||||
await SettingsService.saveCustomColor(_colorToString(color));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
final Album album;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const AlbumCard({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AlbumDetailsScreen(
|
||||
album: album,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album artwork
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + suffix,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
color: colorScheme.surfaceVariant,
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: NetworkImage(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Artist name
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (album.uri ?? album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,50 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import 'album_card.dart';
|
||||
|
||||
class AlbumRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Album>> Function() loadAlbums;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const AlbumRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadAlbums,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AlbumRow> createState() => _AlbumRowState();
|
||||
}
|
||||
|
||||
class _AlbumRowState extends State<AlbumRow> {
|
||||
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Album>> _albumsFuture;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumsFuture = widget.loadAlbums();
|
||||
_loadAlbumsOnce();
|
||||
}
|
||||
|
||||
void _loadAlbumsOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_albumsFuture = widget.loadAlbums();
|
||||
_hasLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -37,14 +53,14 @@ class _AlbumRowState extends State<AlbumRow> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
height: 210, // Slightly increased for text
|
||||
child: FutureBuilder<List<Album>>(
|
||||
future: _albumsFuture,
|
||||
builder: (context, snapshot) {
|
||||
@@ -60,8 +76,11 @@ class _AlbumRowState extends State<AlbumRow> {
|
||||
|
||||
final albums = snapshot.data ?? [];
|
||||
if (albums.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No albums found'),
|
||||
return Center(
|
||||
child: Text(
|
||||
'No albums found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,8 +91,13 @@ class _AlbumRowState extends State<AlbumRow> {
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return RepaintBoundary(
|
||||
child: _AlbumCard(album: album),
|
||||
return Container(
|
||||
width: 150,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: AlbumCard(
|
||||
album: album,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -85,101 +109,3 @@ class _AlbumRowState extends State<AlbumRow> {
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumCard extends StatelessWidget {
|
||||
final Album album;
|
||||
|
||||
const _AlbumCard({required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(album, size: 200);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AlbumDetailsScreen(
|
||||
album: album,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 150,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album artwork with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 300,
|
||||
cacheHeight: 300,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.album, size: 64),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.album, size: 64),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Artist name with Hero animation
|
||||
if (album.artists != null && album.artists!.isNotEmpty)
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.artists!.first.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/artist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
final Artist artist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const ArtistCard({
|
||||
super.key,
|
||||
required this.artist,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Artist image - circular
|
||||
Hero(
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: CircleAvatar(
|
||||
radius: 55,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Artist name
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,50 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/artist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import 'artist_card.dart';
|
||||
|
||||
class ArtistRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Artist>> Function() loadArtists;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const ArtistRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadArtists,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ArtistRow> createState() => _ArtistRowState();
|
||||
}
|
||||
|
||||
class _ArtistRowState extends State<ArtistRow> {
|
||||
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Artist>> _artistsFuture;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_artistsFuture = widget.loadArtists();
|
||||
_loadArtistsOnce();
|
||||
}
|
||||
|
||||
void _loadArtistsOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_artistsFuture = widget.loadArtists();
|
||||
_hasLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -37,9 +53,9 @@ class _ArtistRowState extends State<ArtistRow> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -60,8 +76,11 @@ class _ArtistRowState extends State<ArtistRow> {
|
||||
|
||||
final artists = snapshot.data ?? [];
|
||||
if (artists.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No artists found'),
|
||||
return Center(
|
||||
child: Text(
|
||||
'No artists found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,8 +91,13 @@ class _ArtistRowState extends State<ArtistRow> {
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index];
|
||||
return RepaintBoundary(
|
||||
child: _ArtistCard(artist: artist),
|
||||
return Container(
|
||||
width: 120,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: ArtistCard(
|
||||
artist: artist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -84,67 +108,3 @@ class _ArtistRowState extends State<ArtistRow> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistCard extends StatelessWidget {
|
||||
final Artist artist;
|
||||
|
||||
const _ArtistCard({required this.artist});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 200);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistDetailsScreen(
|
||||
artist: artist,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 120,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Artist image - circular with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
||||
child: CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: Colors.grey[800],
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.person_rounded, size: 60, color: Colors.white54)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Artist name with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class LogoText extends StatelessWidget {
|
||||
final double fontSize;
|
||||
final bool lightMode;
|
||||
|
||||
const LogoText({
|
||||
super.key,
|
||||
this.fontSize = 24,
|
||||
this.lightMode = false
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor = lightMode ? Colors.white : colorScheme.onBackground;
|
||||
|
||||
return Text(
|
||||
'Assistant To The Music',
|
||||
style: GoogleFonts.specialElite(
|
||||
textStyle: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Typewriter fonts often don't need extra letter spacing
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ class MiniPlayer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
@@ -28,202 +30,193 @@ class MiniPlayer extends StatelessWidget {
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NowPlayingScreen(),
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => const NowPlayingScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 350),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return child;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
child: Material(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Status indicator bar
|
||||
Container(
|
||||
height: 2,
|
||||
color: selectedPlayer.isPlaying
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
// Player content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 96,
|
||||
cacheHeight: 96,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
child: Container(
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black.withOpacity(0.3),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
transitionOnUserGestures: true,
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 96,
|
||||
cacheHeight: 96,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Queue button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
color: Colors.white70,
|
||||
iconSize: 22,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
),
|
||||
// Queue button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
iconSize: 26,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Playback controls for selected player
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 30,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
// Volume control (compact mute button)
|
||||
const VolumeControl(compact: true),
|
||||
// Playback controls for selected player
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 26,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: selectedPlayer.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 32,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 28,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: selectedPlayer.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 38,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
transitionOnUserGestures: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 32,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,29 +10,59 @@ class PlayerSelector extends StatelessWidget {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final availablePlayers = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(Icons.speaker_group_rounded),
|
||||
if (selectedPlayer != null)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFF1a1a1a), width: 1),
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selectedPlayer != null) ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Icon(
|
||||
Icons.speaker_group_rounded,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
tooltip: selectedPlayer?.name ?? 'Select Player',
|
||||
onPressed: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,101 +73,186 @@ class PlayerSelector extends StatelessWidget {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF2a2a2a),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true, // Allow tapping outside to close
|
||||
builder: (context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.speaker_group_rounded, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Select Player',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
onPressed: () async {
|
||||
await provider.refreshPlayers();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (players.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: Text(
|
||||
'No players available',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
final isSelected = player.playerId == provider.selectedPlayer?.playerId;
|
||||
// Use Consumer to listen for updates while sheet is open
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
// Use the fresh list of players from the provider
|
||||
final currentPlayers = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
_getPlayerIcon(player.name),
|
||||
color: player.available ? Colors.white : Colors.white38,
|
||||
),
|
||||
title: Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: player.available ? Colors.white : Colors.white38,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
player.available
|
||||
? (player.isPlaying ? 'Playing' : 'Available')
|
||||
: 'Unavailable',
|
||||
style: TextStyle(
|
||||
color: player.isPlaying
|
||||
? Colors.greenAccent
|
||||
: (player.available ? Colors.white54 : Colors.white24),
|
||||
fontSize: 12,
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Select Player',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh_rounded, color: colorScheme.onSurfaceVariant),
|
||||
onPressed: () async {
|
||||
await maProvider.refreshPlayers();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check_circle_rounded, color: Colors.white)
|
||||
: null,
|
||||
enabled: player.available,
|
||||
onTap: () {
|
||||
provider.selectPlayer(player);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: currentPlayers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No players available',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.54)),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.6, // Shorter cards
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: currentPlayers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = currentPlayers[index];
|
||||
final isSelected =
|
||||
player.playerId == maProvider.selectedPlayer?.playerId;
|
||||
// isOn checks if the player is powered on
|
||||
final isOn = player.available && player.powered;
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
maProvider.selectPlayer(player);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
// Tint whole card based on theme if selected
|
||||
color: isSelected
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
// No border, just tint
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Power/Status Indicator (Functional)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.power_settings_new_rounded,
|
||||
size: 24,
|
||||
color: player.available
|
||||
? (isOn ? colorScheme.primary : colorScheme.onSurfaceVariant.withOpacity(0.5))
|
||||
: colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
onPressed: player.available
|
||||
? () {
|
||||
maProvider.togglePower(player.playerId);
|
||||
// Don't close the sheet
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(player.name),
|
||||
color: player.available
|
||||
? (isSelected ? colorScheme.primary : colorScheme.onSurface)
|
||||
: colorScheme.onSurface.withOpacity(0.38),
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: player.available
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurface.withOpacity(0.38),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
player.available
|
||||
? (player.state == 'playing'
|
||||
? 'Playing'
|
||||
: (player.state == 'paused'
|
||||
? 'Paused'
|
||||
: 'Idle'))
|
||||
: 'Unavailable',
|
||||
style: TextStyle(
|
||||
color: player.available
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.onSurface.withOpacity(0.24),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ dependencies:
|
||||
# UI
|
||||
cupertino_icons: ^1.0.6
|
||||
google_fonts: ^6.1.0
|
||||
dynamic_color: 1.6.8
|
||||
palette_generator: ^0.3.3+3
|
||||
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
|
||||