Compare commits

...
43 Commits
Author SHA1 Message Date
Your NameandClaude 92a15ba37c Update README with theming features and fix download link
- Added new Theming Options section with Material You, Adaptive Theming, and Light/Dark Mode
- Added Play on... button to control features
- Added device selector mention
- Fixed download link to point to correct repository

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:36:12 +00:00
Your NameandClaude 1df539028f Update connection screen and fix play on button
- Increased logo size to 160px on connection screen
- Removed Welcome text, subtitle, and help text from connection screen
- Added selectPlayer() call when using play on button to make chosen player active
- This ensures now playing bar appears when playing on selected device

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:20:28 +00:00
Your NameandClaude fa53ab06f4 Increase logo size on connection screen to 120px
- Changed logo height from 80 to 120 pixels
- Makes logo more prominent on login screen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:05:47 +00:00
CollotsSpot 3be160660f Increase logo height in README
Updated logo height in README from 160 to 200.
2025-11-25 17:50:02 +00:00
CollotsSpot dfc18c28ea Update README.md 2025-11-25 17:49:49 +00:00
CollotsSpot 5de45ad228 Update README.md 2025-11-25 17:49:35 +00:00
Your Name 12ce53dda9 Use new long logo for login screen and readme 2025-11-25 17:48:04 +00:00
Your Name 39ff3558c5 Restore adaptive theming in details screens and update logo asset 2025-11-25 17:47:00 +00:00
Your Name dfe6903a16 Fix SettingsScreen build error by restoring class body 2025-11-25 17:34:36 +00:00
Your Name 6b07f403d1 Remove shadows from library menu cards 2025-11-25 17:17:24 +00:00
Your Name 2993288f6d Refactor home and library rows to use shared cards 2025-11-25 17:16:11 +00:00
Your Name 075f1531e4 Update home screen logo to ATTM short version and remove layout debug 2025-11-25 17:11:52 +00:00
Your Name 8b8d1f0269 Implement Layout Debugger and refactor NewHomeScreen 2025-11-25 16:43:02 +00:00
Your Name 93552f5008 Adjust logo vertical alignment 2025-11-25 16:22:15 +00:00
CollotsSpot 624cd32b86 Update README with logo and project description
Added a logo image to the README and updated the description.
2025-11-25 16:08:50 +00:00
CollotsSpot cf6f85a818 Update README.md 2025-11-25 16:08:28 +00:00
CollotsSpot 97ef755c90 Add description for Music Assistant mobile client 2025-11-25 16:08:03 +00:00
CollotsSpot d149955425 Update README.md 2025-11-25 16:07:37 +00:00
Your Name a71437e7fd Rename logo to attm_logo.png to force cache update 2025-11-25 16:06:01 +00:00
Your Name fd4efdf82c Update logo asset 2025-11-25 15:57:11 +00:00
Your Name 1ee710fe0a Fix logo alignment, update library screens to use theming 2025-11-25 15:45:49 +00:00
Your Name dca2001301 Fix Library screens theming 2025-11-25 15:31:16 +00:00
Your Name 42ede54b3b Revert logo adjustments and enforce light theme contrast 2025-11-25 15:26:05 +00:00
Your Name c16548e8b0 Fix home screen alignment and enforce light theme background 2025-11-25 15:12:09 +00:00
Your Name 7fdb1c0511 Update keystore CN 2025-11-25 14:47:39 +00:00
Your Name 2c2049cae2 Rename project to AssistantToTheMusic and update README 2025-11-25 14:47:20 +00:00
Your Name 91504c75a9 Final polish: theming, logo updates, library limits, and back navigation 2025-11-25 14:43:48 +00:00
Your Name 970ece0fe5 App name, logo font, nav, theming, and recent albums fixes 2025-11-25 14:24:58 +00:00
Your Name 45dc50834f Fix missing _normalizeServerUrl method in LoginScreen 2025-11-25 13:51:27 +00:00
Your Name b0a109f196 Restore missing connect method and fix type casting 2025-11-25 13:36:55 +00:00
Your Name 5120112435 Fix build errors: syntax, undefined method, and type check 2025-11-25 13:30:31 +00:00
Your Name 519248afcf Rebranding, Theming overhaul, and Album fallback search 2025-11-25 13:22:13 +00:00
Your Name 4ab5b02156 Fix devices list update and recent albums sorting 2025-11-25 12:37:29 +00:00
Your Name 50a2bd922d Fix power toggle refresh and revert to client-side album filtering 2025-11-25 12:12:54 +00:00
Your Name 999c1b63b1 Fix player power command and theming 2025-11-25 11:44:29 +00:00
Your Name b8b16957f8 Fix album sorting, player power control, and ghost filtering 2025-11-25 11:29:39 +00:00
Your Name 9472d154d9 Fix ghost devices, update devices UI, and optimize artist albums 2025-11-25 10:54:44 +00:00
Your Name 873ac3034a Fix settings screen theme colors 2025-11-25 10:01:43 +00:00
Your NameandClaude 67caa5946d Fix theme consistency across app - part 1
Applied theme colors throughout the main UI:
- Home screen navigation and bottom bar
- New home screen with recent/discover sections
- Album and artist row widgets
- Mini player widget

All hardcoded dark colors (Color(0xFF1a1a1a), Colors.white, etc.)
have been replaced with theme-aware colors (colorScheme.background,
colorScheme.onSurface, etc.) so they properly respond to light/dark
theme mode changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:58:38 +00:00
Your NameandClaude 00485736bb Implement adaptive theme with album artwork color extraction
Major Changes:
- Removed high contrast theme feature (menu toggle and all related code)
- Added adaptive theme toggle in settings (enabled by default)
- Added palette_generator package for color extraction from artwork
- Created PaletteHelper utility for extracting color schemes from images

Adaptive Theme Implementation:
- Album details screen: Extracts colors from album artwork
- Artist details screen: Extracts colors from artist images
- Now playing screen: Extracts colors from currently playing track

The adaptive theme dynamically changes background, text, and accent colors
based on the dominant colors in album/artist artwork. When disabled, falls
back to the original dark theme colors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:02:40 +00:00
Your NameandClaude 020b22e5d5 Pin dynamic_color to exact version 1.6.8
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 07:13:17 +00:00
Your NameandClaude bd4ba27da6 Fix Flutter 3.24.0 compatibility issues
- Remove .harmonized() calls (not available in Flutter 3.24.0)
- Downgrade dynamic_color to 1.6.8 for compatibility
- Keep high contrast functionality without harmonization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 06:54:44 +00:00
Your NameandClaude 1df8465133 Add comprehensive theming system inspired by Vaani
Implemented a full-featured theming system with the following capabilities:
- Theme mode switching (Light/Dark/System)
- High contrast mode for accessibility
- Material You dynamic colors (Android 12+)
- Persistent theme preferences via SharedPreferences
- Real-time theme updates without app restart

New files:
- lib/theme/app_theme.dart: Brand colors and theme generation
- lib/theme/theme_provider.dart: Theme state management
- lib/theme/system_theme_helper.dart: Material You integration

Modified files:
- lib/main.dart: Integrated dynamic theming with MultiProvider
- lib/services/settings_service.dart: Added theme preference storage
- lib/screens/settings_screen.dart: Added theme controls UI
- pubspec.yaml: Added dynamic_color dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 06:50:22 +00:00
38 changed files with 1883 additions and 953 deletions
+1 -1
View File
@@ -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
+21 -7
View File
@@ -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,13 +26,19 @@
- **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
@@ -36,7 +50,7 @@
## 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 +92,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
+1 -1
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.
+62 -27
View File
@@ -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(),
);
},
);
},
),
);
}
+47 -12
View File
@@ -143,10 +143,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 +174,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 +204,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,
@@ -337,6 +338,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 +452,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 +488,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]}');
}
+159 -42
View File
@@ -3,6 +3,8 @@ 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';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
@@ -17,12 +19,37 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@override
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_extractColors();
}
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 {
@@ -136,20 +163,35 @@ 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),
backgroundColor: colorScheme.background,
body: 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(
@@ -159,11 +201,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
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 +221,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,
),
@@ -203,9 +245,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
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,
),
),
@@ -218,9 +259,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
color: Colors.transparent,
child: Text(
widget.album.artistsString,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
),
@@ -228,17 +268,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
const SizedBox(height: 24),
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 +289,49 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
),
const SizedBox(width: 12),
// "Play on..." Button
Expanded(
flex: 1,
child: SizedBox(
height: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
),
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 +341,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,
),
),
@@ -306,14 +368,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
@@ -321,8 +383,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
title: Text(
track.name,
style: const TextStyle(
color: Colors.white,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -330,9 +392,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
subtitle: Text(
track.artistsString,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -340,9 +401,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
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.5),
),
)
: null,
@@ -357,6 +417,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
);
}
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: 32),
],
),
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
+108 -40
View File
@@ -4,6 +4,8 @@ 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';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -17,11 +19,36 @@ class ArtistDetailsScreen extends StatefulWidget {
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _albums = [];
bool _isLoading = true;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@override
void initState() {
super.initState();
_loadArtistAlbums();
_extractColors();
}
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> _loadArtistAlbums() async {
@@ -33,51 +60,94 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
print(' Provider: ${widget.artist.provider}');
print(' ItemId: ${widget.artist.itemId}');
// Get all albums and filter by this artist
// Get all albums and filter locally (API filtering not reliable yet)
final allAlbums = await provider.api!.getAlbums();
// Filter albums that include this artist
final artistAlbums = allAlbums.where((album) {
var artistAlbums = 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 // Fallback to name match
);
}).toList();
print(' Got ${artistAlbums.length} albums for this artist (from ${allAlbums.length} total)');
if (artistAlbums.isNotEmpty) {
print(' First album: ${artistAlbums.first.name}');
print(' Got ${artistAlbums.length} library albums for this artist');
// Fallback: If no library albums, try searching the provider
if (artistAlbums.isEmpty && widget.artist.name.isNotEmpty) {
print('🔍 No library albums found. Searching provider for "${widget.artist.name}"...');
try {
final searchResults = await provider.search(widget.artist.name);
final searchAlbums = searchResults['albums'] ?? [];
// Filter search results to ensure they are actually for this artist
final validSearchAlbums = searchAlbums.where((item) {
// Ensure item is an Album and check artist match
if (item is! Album) return false;
return item.artists?.any((a) =>
a.name.toLowerCase() == widget.artist.name.toLowerCase()
) ?? false;
}).cast<Album>().toList();
if (validSearchAlbums.isNotEmpty) {
print('✅ Found ${validSearchAlbums.length} albums via search fallback');
artistAlbums = validSearchAlbums;
} else {
print('⚠️ Search returned no valid albums for this artist');
}
} catch (e) {
print('❌ Error searching for artist albums: $e');
}
}
setState(() {
_albums = artistAlbums;
_isLoading = false;
});
if (mounted) {
setState(() {
_albums = artistAlbums;
_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);
// 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),
backgroundColor: colorScheme.background,
body: 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(
@@ -88,14 +158,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
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,
),
@@ -116,9 +186,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
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,
),
),
@@ -127,9 +196,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
const SizedBox(height: 24),
Text(
'Albums',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
@@ -139,18 +207,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
if (_isLoading)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: Colors.white),
child: CircularProgressIndicator(color: colorScheme.primary),
),
)
else if (_albums.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Text(
'No albums found',
style: TextStyle(
color: Colors.white54,
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
),
),
@@ -182,6 +250,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 +268,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 +278,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 +291,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 +300,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,
+70 -58
View File
@@ -24,66 +24,78 @@ class _HomeScreenState extends State<HomeScreen> {
@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: _screens[_selectedIndex],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Mini player
const MiniPlayer(),
// Bottom navigation bar
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;
});
},
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',
),
],
),
),
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',
),
],
),
),
],
],
),
),
);
}
+24 -98
View File
@@ -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,10 @@ 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);
},
),
);
}
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,
),
),
),
],
),
);
}
}
+29 -22
View File
@@ -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,14 +90,16 @@ 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;
return Builder(
builder: (context) => ListTile(
@@ -100,10 +107,10 @@ class LibraryArtistsScreen extends StatelessWidget {
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
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,
),
),
@@ -113,8 +120,8 @@ class LibraryArtistsScreen extends StatelessWidget {
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,
+32 -23
View File
@@ -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,
),
+67 -55
View File
@@ -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: () {
+24 -19
View File
@@ -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),
+53 -83
View File
@@ -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
@@ -170,8 +173,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 +189,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 +210,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 +247,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 +274,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 +285,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 +306,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 +317,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 +347,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 +369,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 +378,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 +394,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,
),
],
),
),
+18 -12
View File
@@ -4,6 +4,7 @@ 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';
@@ -12,18 +13,21 @@ class NewHomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
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,
@@ -57,31 +61,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 +105,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,
+28 -22
View File
@@ -15,17 +15,18 @@ 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,
),
),
@@ -41,6 +42,7 @@ class NewLibraryScreen extends StatelessWidget {
),
);
},
color: colorScheme.onBackground,
),
const PlayerSelector(),
],
@@ -53,22 +55,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 +90,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 +193,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 +208,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,
+87 -22
View File
@@ -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,49 @@ 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,
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 +194,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,
@@ -202,8 +261,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 +273,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 +285,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 +346,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
}
}
},
activeColor: Colors.white,
inactiveColor: Colors.white24,
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.24),
),
),
Padding(
@@ -298,11 +357,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 +382,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,
@@ -336,7 +395,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: Colors.white,
color: textColor,
iconSize: 42,
onPressed: () async {
try {
@@ -361,6 +420,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
color: Colors.transparent,
child: _AnimatedPlayButton(
isPlaying: selectedPlayer.isPlaying,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
onPressed: () async {
try {
await maProvider.playPauseSelectedPlayer();
@@ -384,7 +445,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: Colors.white,
color: textColor,
iconSize: 42,
onPressed: () async {
try {
@@ -409,8 +470,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 +507,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 +565,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,
),
+45 -35
View File
@@ -65,23 +65,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 +106,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 +135,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 +151,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 +180,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,
),
),
@@ -217,8 +224,8 @@ class _SearchScreenState extends State<SearchScreen> {
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 +236,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 +274,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 +291,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 +305,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 +325,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 +342,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 +356,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),
+193 -76
View File
@@ -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 {
@@ -64,7 +65,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 +84,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 +95,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 +115,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
// Connect to Music Assistant
final provider = context.read<MusicAssistantProvider>();
await provider.connectToServer(_serverUrlController.text);
@@ -153,22 +150,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 +182,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
},
color: Colors.white,
color: colorScheme.onBackground,
tooltip: 'Debug Logs',
),
],
@@ -194,18 +192,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 +210,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 +233,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 +271,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 +310,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 +359,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 +378,138 @@ 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),
SizedBox(
width: double.infinity,
height: 50,
@@ -406,8 +525,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 +536,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 +550,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 +600,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);
}
}
+45 -8
View File
@@ -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');
}
@@ -327,7 +341,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');
@@ -393,12 +407,12 @@ class MusicAssistantAPI {
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
try {
_logger.log('Fetching recently played albums (limit=$limit)');
// Fetch a large number of albums to ensure we include recently played ones,
// then sort client-side since server-side sorting can be unreliable.
final response = await _sendCommand(
'music/albums/library_items',
args: {
'limit': limit,
'sort': 'timestamp_played', // Sort by when last played
'order': 'desc', // Most recent first
'limit': 2000, // Increase limit to capture most of the library
},
);
@@ -408,11 +422,24 @@ class MusicAssistantAPI {
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'}');
// Sort client-side by timestamp_played or last_played
items.sort((a, b) {
// Try to find timestamp in different possible locations
final tA = (a['timestamp_played'] ?? a['last_played'] ?? a['metadata']?['timestamp_played'] ?? a['metadata']?['last_played'] ?? 0) as num;
final tB = (b['timestamp_played'] ?? b['last_played'] ?? b['metadata']?['timestamp_played'] ?? b['metadata']?['last_played'] ?? 0) as num;
return tB.compareTo(tA); // Descending
});
_logger.log('✅ Got ${items.length} albums, sorting by timestamp_played');
// Take top 'limit'
final topItems = items.take(limit).toList();
if (topItems.isNotEmpty) {
_logger.log(' First recent album: ${topItems[0]['name']}');
}
return items
return topItems
.map((item) => Album.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
@@ -1000,6 +1027,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');
}
+45
View File
@@ -8,6 +8,10 @@ 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 Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
@@ -111,6 +115,47 @@ 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);
}
static Future<void> clearSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
+70
View File
@@ -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',
);
}
}
+75
View File
@@ -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;
}
}
}
+43
View File
@@ -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;
}
}
+96
View File
@@ -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();
}
}
+100
View File
@@ -0,0 +1,100 @@
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;
const AlbumCard({
super.key,
required this.album,
this.onTap,
});
@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;
return GestureDetector(
onTap: onTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album artwork with Hero animation
Expanded(
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
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 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: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
// Artist name with Hero animation
Hero(
tag: HeroTags.artistName + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
),
],
),
);
}
}
+16 -107
View File
@@ -2,8 +2,7 @@ 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;
@@ -30,6 +29,9 @@ class _AlbumRowState extends State<AlbumRow> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,14 +39,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 +62,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 +77,10 @@ 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),
);
},
);
@@ -85,101 +92,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],
),
),
),
),
],
),
),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
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;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
});
@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;
return GestureDetector(
onTap: onTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: artist),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular with Hero animation
Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
child: CircleAvatar(
radius: 60, // Fixed size radius for row, or flexible? Row uses fixed width 120.
// In Grid we might want flexible.
// Let's make it responsive to container width if possible, or just use AspectRatio.
// But for CircleAvatar, radius is explicit.
// Better to use Container with BoxShape.circle
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 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: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
+15 -72
View File
@@ -2,8 +2,7 @@ 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;
@@ -30,6 +29,9 @@ class _ArtistRowState extends State<ArtistRow> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,9 +39,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 +62,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 +77,10 @@ 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),
);
},
);
@@ -84,67 +91,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,
),
),
),
),
],
),
),
);
}
}
+33
View File
@@ -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,
);
}
}
+19 -17
View File
@@ -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;
@@ -36,7 +38,7 @@ class MiniPlayer extends StatelessWidget {
child: Hero(
tag: HeroTags.nowPlayingBackground,
child: Material(
color: const Color(0xFF2a2a2a),
color: colorScheme.surface,
elevation: 0,
child: Container(
height: 64,
@@ -55,8 +57,8 @@ class MiniPlayer extends StatelessWidget {
Container(
height: 2,
color: selectedPlayer.isPlaying
? Colors.white.withOpacity(0.3)
: Colors.white.withOpacity(0.1),
? colorScheme.primary.withOpacity(0.7)
: colorScheme.onSurface.withOpacity(0.1),
),
// Player content
Expanded(
@@ -81,20 +83,20 @@ class MiniPlayer extends StatelessWidget {
filterQuality: FilterQuality.medium,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.white12,
child: const Icon(
color: colorScheme.surfaceVariant,
child: Icon(
Icons.music_note_rounded,
color: Colors.white54,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
},
)
: Container(
color: Colors.white12,
child: const Icon(
color: colorScheme.surfaceVariant,
child: Icon(
Icons.music_note_rounded,
color: Colors.white54,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
@@ -110,8 +112,8 @@ class MiniPlayer extends StatelessWidget {
children: [
Text(
currentTrack.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -121,8 +123,8 @@ class MiniPlayer extends StatelessWidget {
const SizedBox(height: 2),
Text(
currentTrack.artistsString,
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 12,
),
maxLines: 1,
@@ -134,7 +136,7 @@ class MiniPlayer extends StatelessWidget {
// Queue button
IconButton(
icon: const Icon(Icons.queue_music),
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
iconSize: 22,
onPressed: () {
Navigator.push(
@@ -154,7 +156,7 @@ class MiniPlayer extends StatelessWidget {
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: Colors.white,
color: colorScheme.onSurface,
iconSize: 26,
onPressed: () async {
try {
@@ -179,7 +181,7 @@ class MiniPlayer extends StatelessWidget {
icon: selectedPlayer.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white,
color: colorScheme.onSurface,
iconSize: 32,
onPressed: () async {
try {
@@ -202,7 +204,7 @@ class MiniPlayer extends StatelessWidget {
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: Colors.white,
color: colorScheme.onSurface,
iconSize: 28,
onPressed: () async {
try {
+180 -94
View File
@@ -10,11 +10,12 @@ 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),
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
if (selectedPlayer != null)
Positioned(
right: 0,
@@ -23,9 +24,9 @@ class PlayerSelector extends StatelessWidget {
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
color: Colors.green, // Keep green for "active" status or use primary
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF1a1a1a), width: 1),
border: Border.all(color: colorScheme.background, width: 1),
),
),
),
@@ -43,101 +44,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,
),
),
],
),
),
],
),
),
);
},
),
),
],
),
);
},
);
},
);
},
);
+2
View File
@@ -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