Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a15ba37c | ||
|
|
1df539028f | ||
|
|
fa53ab06f4 | ||
|
|
3be160660f | ||
|
|
dfc18c28ea | ||
|
|
5de45ad228 | ||
|
|
12ce53dda9 | ||
|
|
39ff3558c5 | ||
|
|
dfe6903a16 | ||
|
|
6b07f403d1 | ||
|
|
2993288f6d | ||
|
|
075f1531e4 | ||
|
|
8b8d1f0269 | ||
|
|
93552f5008 | ||
|
|
624cd32b86 | ||
|
|
cf6f85a818 | ||
|
|
97ef755c90 | ||
|
|
d149955425 | ||
|
|
a71437e7fd | ||
|
|
fd4efdf82c | ||
|
|
1ee710fe0a | ||
|
|
dca2001301 | ||
|
|
42ede54b3b | ||
|
|
c16548e8b0 | ||
|
|
7fdb1c0511 | ||
|
|
2c2049cae2 | ||
|
|
91504c75a9 | ||
|
|
970ece0fe5 | ||
|
|
45dc50834f | ||
|
|
b0a109f196 | ||
|
|
5120112435 | ||
|
|
519248afcf | ||
|
|
4ab5b02156 | ||
|
|
50a2bd922d | ||
|
|
999c1b63b1 | ||
|
|
b8b16957f8 | ||
|
|
9472d154d9 | ||
|
|
873ac3034a | ||
|
|
67caa5946d | ||
|
|
00485736bb | ||
|
|
020b22e5d5 | ||
|
|
bd4ba27da6 | ||
|
|
1df8465133 | ||
|
|
69030d8ed7 | ||
|
|
a08ea90ef2 | ||
|
|
daed4c6fcb | ||
|
|
5bd4a61956 | ||
|
|
e34a89e990 | ||
|
|
76114fe6d2 | ||
|
|
1c7844af0f | ||
|
|
50841b7d86 | ||
|
|
9793da1a00 | ||
|
|
8f1a8634d1 | ||
|
|
688f4c82f0 | ||
|
|
efa1b79606 | ||
|
|
e4c68aa9f9 | ||
|
|
10aae85b33 | ||
|
|
eadbb2023c | ||
|
|
cd37b74aea | ||
|
|
215c05fd7e | ||
|
|
2a2a762cce | ||
|
|
918aa8a032 | ||
|
|
542e80e52d |
@@ -34,14 +34,30 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
|
||||
- name: Generate launcher icons
|
||||
run: flutter pub run flutter_launcher_icons
|
||||
|
||||
|
||||
- name: Analyze code
|
||||
run: flutter analyze
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create signing keystore
|
||||
run: |
|
||||
# Create a keystore for signing (valid for 10000 days)
|
||||
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=AssistantToTheMusic, OU=HomeServer, O=Personal, L=Home, S=Home, C=US"
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=android
|
||||
keyPassword=android
|
||||
keyAlias=upload
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Cleaning Up Ghost "Music Assistant Mobile" Players
|
||||
|
||||
## The Problem
|
||||
|
||||
Early versions of Amass registered the mobile device as a local playback player with Music Assistant. This created "Music Assistant Mobile" player entities on your server. Multiple app restarts/logins created multiple ghost players that persist on the server.
|
||||
|
||||
## Current Status
|
||||
|
||||
The Amass app now:
|
||||
- ✅ No longer registers as a player (acts as remote control only)
|
||||
- ✅ Filters out these ghost players automatically
|
||||
- ✅ Won't create new ghost players
|
||||
|
||||
However, the old players still exist on your Music Assistant server.
|
||||
|
||||
## Finding Ghost Players
|
||||
|
||||
### Using Amass Player Diagnostics Tool
|
||||
|
||||
1. Open Amass app
|
||||
2. Go to **Settings** → **Debug Logs**
|
||||
3. Tap the **speaker icon** (🔊) in the top right
|
||||
4. See all players including hidden ghost players (highlighted in red)
|
||||
5. Tap **Copy List** to copy player IDs for manual removal
|
||||
|
||||
This shows you:
|
||||
- All player names and IDs
|
||||
- Whether they're available or unavailable
|
||||
- Their current state (idle/playing/paused)
|
||||
|
||||
**Note**: Ghost players are marked as `available: true` but are builtin_player type with default_name "Music Assistant Mobile". They exist in your `settings.json` but don't show in the web UI because they're orphaned mobile players.
|
||||
|
||||
## Findings from Your Server
|
||||
|
||||
Your Music Assistant logs show:
|
||||
- **26 ghost "Music Assistant Mobile" players** found in `settings.json`
|
||||
- Created between Nov 22-23, 2025 (during app development/testing)
|
||||
- All are `provider: builtin_player` with `available: true`
|
||||
- Player IDs are UUIDs like `202f4c01-abbf-4a46-83f8-9954570e7e50`
|
||||
|
||||
These players are in your settings but not visible in the UI, which is why manual cleanup is needed.
|
||||
|
||||
## Automated Cleanup (Recommended)
|
||||
|
||||
A cleanup script has been created for your server:
|
||||
|
||||
```bash
|
||||
# Run the cleanup script
|
||||
cd /home/home-server/Amass
|
||||
./cleanup_ghost_players.sh
|
||||
|
||||
# It will:
|
||||
# 1. Show you all 26 ghost players
|
||||
# 2. Ask for confirmation
|
||||
# 3. Backup settings.json
|
||||
# 4. Remove ghost players
|
||||
# 5. Tell you to restart Music Assistant
|
||||
```
|
||||
|
||||
After running the script, restart Music Assistant:
|
||||
```bash
|
||||
docker restart music_assistant
|
||||
# Or if using docker-compose:
|
||||
cd /home/home-server/docker/music-assistant
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
## Manual Cleanup (Advanced)
|
||||
|
||||
If you prefer manual cleanup:
|
||||
|
||||
### Option 1: Via Web UI Settings
|
||||
|
||||
1. Open your Music Assistant web UI (e.g., `http://ma.serverscloud.org:8097`)
|
||||
2. Navigate to **Settings** → **Players**
|
||||
3. Look for players named "Music Assistant Mobile" or similar
|
||||
4. For each ghost player:
|
||||
- Click the **⋮** (three dots) menu
|
||||
- Select **Remove Player** or **Delete**
|
||||
5. Confirm deletion for each one
|
||||
|
||||
### Option 2: Via Players Tab
|
||||
|
||||
1. Open Music Assistant web UI
|
||||
2. Click the **Players** tab
|
||||
3. Find all "Music Assistant Mobile" entries
|
||||
4. Right-click or use the context menu to remove each one
|
||||
|
||||
## How to Clean Up (Home Assistant)
|
||||
|
||||
If Music Assistant is integrated with Home Assistant:
|
||||
|
||||
1. Open Home Assistant
|
||||
2. Go to **Settings** → **Devices & Services**
|
||||
3. Click **Music Assistant**
|
||||
4. Look for "Music Assistant Mobile" devices/entities
|
||||
5. For each one:
|
||||
- Click on the entity
|
||||
- Click **⋮** → **Delete**
|
||||
|
||||
## How to Clean Up (Command Line / SSH)
|
||||
|
||||
If you have SSH access to your Music Assistant server:
|
||||
|
||||
```bash
|
||||
# Connect to your server
|
||||
ssh user@ma.serverscloud.org
|
||||
|
||||
# Access Music Assistant container (if dockerized)
|
||||
docker exec -it music_assistant bash
|
||||
|
||||
# Or if running as an add-on, use Home Assistant CLI
|
||||
ha addons exec music_assistant
|
||||
|
||||
# Navigate to data directory (location varies by installation)
|
||||
cd /data # or wherever your config is stored
|
||||
|
||||
# Backup first!
|
||||
cp mass.json mass.json.backup
|
||||
|
||||
# Edit the config file
|
||||
nano mass.json # or vi, etc.
|
||||
|
||||
# Look for player entries with names like "Music Assistant Mobile"
|
||||
# Remove those entries (be careful with JSON syntax!)
|
||||
# Save and exit
|
||||
|
||||
# Restart Music Assistant
|
||||
# (from outside container if dockerized)
|
||||
docker restart music_assistant
|
||||
```
|
||||
|
||||
⚠️ **Warning**: Manual JSON editing is risky. Use web UI method if possible.
|
||||
|
||||
## Verifying Cleanup
|
||||
|
||||
After cleanup, check the Amass app logs. You should see:
|
||||
- No more "Filtering out leftover player" messages
|
||||
- "Loaded X players" with a reasonable count (not 30+)
|
||||
|
||||
## Prevention
|
||||
|
||||
The current version of Amass (v1.0.0+) will not create new ghost players. If you see new ones appearing:
|
||||
1. Make sure you're using the latest version
|
||||
2. Uninstall any old versions of the app
|
||||
3. Clear app data before reinstalling
|
||||
|
||||
## Questions?
|
||||
|
||||
If ghost players keep appearing after cleanup, there may be another app or integration registering them. Check:
|
||||
- Other Music Assistant mobile apps
|
||||
- Home Assistant automations
|
||||
- Music Assistant player announcements
|
||||
@@ -1,43 +1,65 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
|
||||
<h1>Amass</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>Stream your entire music library from your Music Assistant server to your mobile device.</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
|
||||
- **Server Connection** - Connect to your Music Assistant server via WebSocket
|
||||
- **Library Browsing** - Browse artists, albums, and tracks from your server
|
||||
- **Album Details** - View album information and track listings
|
||||
- **Music Streaming** - Stream audio directly from your Music Assistant server
|
||||
- **Playback Control** - Control playback on your Music Assistant server players
|
||||
- **Auto-Reconnect** - Automatic reconnection with connection status monitoring
|
||||
- **Settings Management** - Configure server URL with persistent storage
|
||||
|
||||
### Player Features
|
||||
- Clean, minimalistic dark UI design
|
||||
- Full audio playback controls (play/pause/skip/seek)
|
||||
### Control Features
|
||||
- Full playback controls (play/pause/skip/seek) for server players
|
||||
- Progress bar with time display
|
||||
- Volume control slider
|
||||
- Volume control for server players
|
||||
- Now playing display with track information
|
||||
- Background audio playback support
|
||||
- Queue management
|
||||
- Queue management and viewing
|
||||
- Multi-player support with device selector
|
||||
- Play on... button for quick device selection
|
||||
|
||||
### Theming Options
|
||||
- **Material You** - Dynamic theming based on your device's wallpaper
|
||||
- **Adaptive Theming** - Album artwork-based color schemes
|
||||
- **Light/Dark Mode** - System-aware or manual theme selection
|
||||
- Clean, minimalistic UI design
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Amass/actions) - look for the "amass-apk" artifact in successful builds.
|
||||
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Assistant-To-The-Music/releases).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Launch the app
|
||||
2. Navigate to the **Library** tab
|
||||
3. Tap **Configure Server** or go to **Settings**
|
||||
4. Enter your Music Assistant server URL (e.g., `music.serverscloud.org` or `192.168.1.100`)
|
||||
4. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
||||
5. Tap **Connect**
|
||||
6. Browse your library and start playing music!
|
||||
6. Browse your library and control playback on your Music Assistant players!
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +69,7 @@ Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Ama
|
||||
|
||||
## About Music Assistant
|
||||
|
||||
Amass is a client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app.
|
||||
Amass Your Music is a remote control client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app. Music playback happens on your Music Assistant server's configured players (speakers, smart devices, etc.), not on your mobile device.
|
||||
|
||||
Learn more: [music-assistant.io](https://music-assistant.io/)
|
||||
|
||||
@@ -70,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
|
||||
@@ -97,10 +119,9 @@ The APK will be available at `build/app/outputs/flutter-apk/app-release.apk`
|
||||
<summary>Technologies Used</summary>
|
||||
|
||||
- **Flutter** - Cross-platform mobile framework
|
||||
- **just_audio** - Audio playback
|
||||
- **audio_service** - Background audio support
|
||||
- **web_socket_channel** - WebSocket communication
|
||||
- **web_socket_channel** - WebSocket communication with Music Assistant server
|
||||
- **provider** - State management
|
||||
- **shared_preferences** - Local settings storage
|
||||
- **http** - HTTP requests for API communication
|
||||
|
||||
</details>
|
||||
|
||||
@@ -22,6 +22,12 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
@@ -40,6 +46,17 @@ android {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
@@ -50,7 +67,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<application
|
||||
android:label="Amass"
|
||||
android:label="ATTM"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# Script to remove ghost "Music Assistant Mobile" players from Music Assistant
|
||||
|
||||
set -e
|
||||
|
||||
SETTINGS_FILE="/home/home-server/docker/music-assistant/data/settings.json"
|
||||
BACKUP_FILE="/home/home-server/docker/music-assistant/data/settings.json.pre-cleanup-backup"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Music Assistant Ghost Player Cleanup ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if settings file exists
|
||||
if [ ! -f "$SETTINGS_FILE" ]; then
|
||||
echo -e "${RED}Error: Settings file not found at $SETTINGS_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${RED}Error: jq is not installed. Install with: sudo apt install jq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Count ghost players before cleanup
|
||||
GHOST_COUNT=$(cat "$SETTINGS_FILE" | jq '[.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile")] | length')
|
||||
|
||||
echo -e "Found ${RED}$GHOST_COUNT${NC} ghost \"Music Assistant Mobile\" players"
|
||||
echo ""
|
||||
|
||||
if [ "$GHOST_COUNT" -eq 0 ]; then
|
||||
echo -e "${GREEN}No ghost players to remove!${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# List the ghost players
|
||||
echo "Ghost players to be removed:"
|
||||
cat "$SETTINGS_FILE" | jq -r '.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile") | " - " + .key'
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
read -p "Do you want to remove these players? (yes/no): " CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
echo -e "${YELLOW}Cleanup cancelled.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create backup
|
||||
echo ""
|
||||
echo -e "${YELLOW}Creating backup...${NC}"
|
||||
cp "$SETTINGS_FILE" "$BACKUP_FILE"
|
||||
echo -e "${GREEN}Backup created at: $BACKUP_FILE${NC}"
|
||||
|
||||
# Remove ghost players
|
||||
echo ""
|
||||
echo -e "${YELLOW}Removing ghost players...${NC}"
|
||||
|
||||
# Get list of ghost player IDs
|
||||
GHOST_IDS=$(cat "$SETTINGS_FILE" | jq -r '.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile") | .key')
|
||||
|
||||
# Remove each ghost player from settings
|
||||
for player_id in $GHOST_IDS; do
|
||||
echo " Removing: $player_id"
|
||||
done
|
||||
|
||||
# Use jq to filter out ghost players
|
||||
cat "$SETTINGS_FILE" | jq '.players |= with_entries(select(.value.default_name != "Music Assistant Mobile"))' > "${SETTINGS_FILE}.tmp"
|
||||
mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
||||
|
||||
# Verify cleanup
|
||||
REMAINING=$(cat "$SETTINGS_FILE" | jq '[.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile")] | length')
|
||||
|
||||
echo ""
|
||||
if [ "$REMAINING" -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Successfully removed all $GHOST_COUNT ghost players!${NC}"
|
||||
else
|
||||
echo -e "${RED}Warning: $REMAINING ghost players still remain${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo "1. Restart Music Assistant: docker restart music_assistant"
|
||||
echo "2. Or: cd /home/home-server/docker/music-assistant && docker compose restart"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To restore backup if needed:${NC}"
|
||||
echo "cp $BACKUP_FILE $SETTINGS_FILE"
|
||||
echo "Then restart Music Assistant"
|
||||
@@ -11,4 +11,11 @@ class HeroTags {
|
||||
|
||||
/// Hero tag for artist image
|
||||
static const String artistImage = 'artist_image_';
|
||||
|
||||
/// Hero tags for now playing screen
|
||||
static const String nowPlayingArt = 'now_playing_art';
|
||||
static const String nowPlayingBackground = 'now_playing_background';
|
||||
static const String nowPlayingPreviousButton = 'now_playing_previous_button';
|
||||
static const String nowPlayingPlayButton = 'now_playing_play_button';
|
||||
static const String nowPlayingNextButton = 'now_playing_next_button';
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,14 +32,40 @@ class Player {
|
||||
|
||||
// Calculate current elapsed time (interpolated if playing)
|
||||
double get currentElapsedTime {
|
||||
if (elapsedTime == null || elapsedTimeLastUpdated == null) return 0;
|
||||
if (elapsedTime == null) {
|
||||
print('⚠️ elapsedTime is null, returning 0');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isPlaying) return elapsedTime!;
|
||||
if (!isPlaying || elapsedTimeLastUpdated == null) {
|
||||
print('🕐 Not playing or no last updated time, returning base: $elapsedTime');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
// If playing, interpolate based on time since last update
|
||||
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
||||
final timeSinceUpdate = now - elapsedTimeLastUpdated!;
|
||||
return elapsedTime! + timeSinceUpdate;
|
||||
|
||||
print('🕐 Time calculation:');
|
||||
print(' Base elapsed: $elapsedTime seconds');
|
||||
print(' Last updated: $elapsedTimeLastUpdated (Unix timestamp)');
|
||||
print(' Now: $now (Unix timestamp)');
|
||||
print(' Delta: $timeSinceUpdate seconds');
|
||||
|
||||
// Safety check: if time since update is negative or too large, just return elapsed time
|
||||
if (timeSinceUpdate < 0) {
|
||||
print('⚠️ Negative time delta! Clock skew detected.');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
if (timeSinceUpdate > 30) {
|
||||
print('⚠️ Time delta too large (${timeSinceUpdate}s), using base elapsed time');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
final calculatedTime = elapsedTime! + timeSinceUpdate;
|
||||
print(' Result: $calculatedTime seconds');
|
||||
return calculatedTime;
|
||||
}
|
||||
|
||||
factory Player.fromJson(Map<String, dynamic> json) {
|
||||
@@ -52,21 +78,24 @@ class Player {
|
||||
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
||||
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
|
||||
if (elapsedTime != null) {
|
||||
print('✅ Player ${json['name']}: Using top-level elapsed_time: $elapsedTime seconds (updated: $elapsedTimeLastUpdated)');
|
||||
}
|
||||
|
||||
// Extract current_item_id from current_media
|
||||
if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
||||
|
||||
// Only use current_media elapsed_time if we don't have top-level (fallback)
|
||||
if (elapsedTime == null) {
|
||||
elapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||
elapsedTimeLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
print('⚠️ Player ${json['name']}: Falling back to current_media elapsed_time: $elapsedTime seconds');
|
||||
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
|
||||
// Use current_media elapsed_time if:
|
||||
// 1. We don't have top-level elapsed_time, OR
|
||||
// 2. Top-level is 0 but current_media has a real value
|
||||
if (elapsedTime == null || (elapsedTime == 0 && currentMediaElapsedTime != null && currentMediaElapsedTime > 0)) {
|
||||
print('⚠️ Player ${json['name']}: Using current_media elapsed_time: $currentMediaElapsedTime (top-level was: $elapsedTime)');
|
||||
elapsedTime = currentMediaElapsedTime;
|
||||
elapsedTimeLastUpdated = currentMediaLastUpdated;
|
||||
} else if (elapsedTime != null) {
|
||||
print('✅ Player ${json['name']}: Using top-level elapsed_time: $elapsedTime seconds (updated: $elapsedTimeLastUpdated)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
|
||||
// Debug: Get ALL players including filtered ones
|
||||
Future<List<Player>> getAllPlayersUnfiltered() async {
|
||||
return await getPlayers();
|
||||
}
|
||||
|
||||
// API access
|
||||
MusicAssistantAPI? get api => _api;
|
||||
|
||||
@@ -138,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>;
|
||||
@@ -168,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,
|
||||
);
|
||||
@@ -198,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,
|
||||
@@ -332,11 +338,32 @@ 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);
|
||||
// Refresh player state to get updated volume
|
||||
await refreshPlayers();
|
||||
// Don't refresh immediately - let automatic polling pick up the change
|
||||
// This prevents the slider from snapping back before the server updates
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set volume', e);
|
||||
rethrow;
|
||||
@@ -354,6 +381,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int position) async {
|
||||
try {
|
||||
await _api?.seek(playerId, position);
|
||||
// Player state will be updated on next poll
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Seek', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
try {
|
||||
await _api?.toggleShuffle(queueId);
|
||||
@@ -411,33 +448,54 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_playersLastFetched != null &&
|
||||
_availablePlayers.isNotEmpty &&
|
||||
now.difference(_playersLastFetched!) < _playersCacheDuration) {
|
||||
_logger.log('Using cached player list (${_availablePlayers.length} players)');
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('Fetching fresh player list...');
|
||||
final allPlayers = await getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
// 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 = [];
|
||||
|
||||
// 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
|
||||
_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')) {
|
||||
_logger.log('Filtering out leftover player: ${player.name}');
|
||||
filteredCount++;
|
||||
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();
|
||||
|
||||
_playersLastFetched = DateTime.now();
|
||||
_logger.log('Loaded ${_availablePlayers.length} players (filtered from ${allPlayers.length} total)');
|
||||
|
||||
// Log each player for debugging
|
||||
for (final player in _availablePlayers) {
|
||||
_logger.log(' - ${player.name} (${player.playerId}) - available: ${player.available}, state: ${player.state}');
|
||||
// Log details about ghost players
|
||||
if (filteredCount > 0) {
|
||||
_logger.log('🧹 Filtered out $filteredCount ghost players:');
|
||||
for (int i = 0; i < ghostPlayerIds.length && i < 5; i++) {
|
||||
_logger.log(' - ${ghostPlayerIds[i]}');
|
||||
}
|
||||
if (ghostPlayerIds.length > 5) {
|
||||
_logger.log(' ... and ${ghostPlayerIds.length - 5} more');
|
||||
}
|
||||
_logger.log('📊 Result: ${_availablePlayers.length} valid players from ${allPlayers.length} total');
|
||||
}
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
@@ -484,21 +542,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('⚠️ No players available');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
// Don't call notifyListeners here - selectPlayer already does it
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a player for playback
|
||||
void selectPlayer(Player player) {
|
||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||
_selectedPlayer = player;
|
||||
_logger.log('Selected player: ${player.name} (${player.playerId})');
|
||||
|
||||
// Start polling for player state
|
||||
_startPlayerStatePolling();
|
||||
|
||||
notifyListeners();
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start polling for selected player's current state
|
||||
@@ -507,8 +567,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
// Poll every 2 seconds
|
||||
_playerStateTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
// Poll every 5 seconds for better performance
|
||||
_playerStateTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
_updatePlayerState();
|
||||
});
|
||||
|
||||
@@ -521,8 +581,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
// Refresh players list to get latest player state
|
||||
await refreshPlayers();
|
||||
bool stateChanged = false;
|
||||
|
||||
// Fetch only the selected player's state instead of all players
|
||||
final allPlayers = await getPlayers();
|
||||
final updatedPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
// Check if player state actually changed
|
||||
if (updatedPlayer.state != _selectedPlayer!.state ||
|
||||
updatedPlayer.volumeLevel != _selectedPlayer!.volumeLevel ||
|
||||
updatedPlayer.volumeMuted != _selectedPlayer!.volumeMuted ||
|
||||
updatedPlayer.available != _selectedPlayer!.available) {
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
// Only show tracks if player is available and not idle
|
||||
final shouldShowTrack = _selectedPlayer!.available &&
|
||||
@@ -532,7 +607,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Clear track if player is unavailable or idle
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
_logger.log('🗑️ Cleared track - player unavailable or idle (state: ${_selectedPlayer!.state}, available: ${_selectedPlayer!.available})');
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
@@ -542,23 +620,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
_logger.log('✅ Current track updated: ${_currentTrack!.name}');
|
||||
notifyListeners();
|
||||
} else {
|
||||
if (queue == null) {
|
||||
_logger.log('⚠️ Queue is null for player ${_selectedPlayer!.playerId}');
|
||||
} else if (queue.currentItem == null) {
|
||||
_logger.log('⚠️ Queue currentItem is null (currentIndex: ${queue.currentIndex}, items: ${queue.items.length})');
|
||||
// Only update if track actually changed
|
||||
if (_currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
// Clear current track if queue is empty
|
||||
_currentTrack = null;
|
||||
_logger.log('🗑️ Cleared current track');
|
||||
notifyListeners();
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify if something actually changed
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error updating player state: $e');
|
||||
}
|
||||
@@ -601,21 +681,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
/// Refresh the list of available players
|
||||
Future<void> refreshPlayers() async {
|
||||
final previousState = _selectedPlayer?.state;
|
||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||
|
||||
await _loadAndSelectPlayers(forceRefresh: true);
|
||||
|
||||
// Update the selected player with fresh data
|
||||
bool stateChanged = false;
|
||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||
try {
|
||||
final updatedPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
|
||||
// Check if state actually changed
|
||||
if (updatedPlayer.state != previousState ||
|
||||
updatedPlayer.volumeLevel != previousVolume) {
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
_logger.log('Updated selected player state: ${updatedPlayer.name} - ${updatedPlayer.state}');
|
||||
} catch (e) {
|
||||
// Selected player no longer available
|
||||
_logger.log('Selected player no longer in list');
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify if state changed
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/player.dart';
|
||||
|
||||
class DebugLogScreen extends StatefulWidget {
|
||||
const DebugLogScreen({super.key});
|
||||
@@ -53,6 +56,95 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAllPlayers() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final allPlayers = await maProvider.getAllPlayersUnfiltered();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF2a2a2a),
|
||||
title: const Text(
|
||||
'All Players (Including Hidden)',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: allPlayers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = allPlayers[index];
|
||||
final isGhost = player.name.toLowerCase().contains('music assistant mobile');
|
||||
|
||||
return Card(
|
||||
color: isGhost ? Colors.red.withOpacity(0.2) : Colors.white.withOpacity(0.1),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: isGhost ? Colors.red[300] : Colors.white,
|
||||
fontWeight: isGhost ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ID: ${player.playerId}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
Text(
|
||||
'Available: ${player.available} | State: ${player.state}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isGhost
|
||||
? const Icon(Icons.warning, color: Colors.red, size: 20)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final text = allPlayers.map((p) =>
|
||||
'Name: ${p.name}\nID: ${p.playerId}\nAvailable: ${p.available}\nState: ${p.state}\n---'
|
||||
).join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Player list copied to clipboard!')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy List'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error loading players: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -75,6 +167,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.speaker_group_rounded),
|
||||
onPressed: _showAllPlayers,
|
||||
color: Colors.white,
|
||||
tooltip: 'View all players',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
onPressed: _copyLogs,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,35 +43,38 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Playlists',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildPlaylistsList(provider),
|
||||
body: _buildPlaylistsList(context, provider),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistsList(MusicAssistantProvider provider) {
|
||||
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,16 +83,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.playlist_play_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No playlists found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -99,8 +102,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -109,20 +112,24 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
onRefresh: _loadPlaylists,
|
||||
child: ListView.builder(
|
||||
itemCount: _playlists.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return _buildPlaylistTile(playlist, provider);
|
||||
return _buildPlaylistTile(context, playlist, provider);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistTile(Playlist playlist, MusicAssistantProvider provider) {
|
||||
Widget _buildPlaylistTile(BuildContext context, Playlist playlist, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Builder(
|
||||
builder: (context) => ListTile(
|
||||
@@ -130,7 +137,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -140,13 +147,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.playlist_play_rounded, color: Colors.white54)
|
||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -156,7 +163,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
playlist.trackCount != null
|
||||
? '${playlist.trackCount} tracks'
|
||||
: playlist.owner ?? 'Playlist',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -33,17 +33,18 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Library',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
@@ -59,14 +60,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white54,
|
||||
indicatorColor: colorScheme.primary,
|
||||
labelColor: colorScheme.primary,
|
||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||
tabs: const [
|
||||
Tab(text: 'Artists'),
|
||||
Tab(text: 'Albums'),
|
||||
@@ -88,22 +89,24 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'Not connected to Music Assistant',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -121,8 +124,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const Text('Configure Server'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
@@ -139,9 +142,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,16 +155,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.person_outline_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No artists found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -169,8 +174,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -190,20 +195,21 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
|
||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.white12,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
artist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -220,9 +226,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,16 +239,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.album_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No albums found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -250,8 +258,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -277,6 +285,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
|
||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -293,7 +302,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -303,11 +312,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Center(
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -316,8 +325,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -326,8 +335,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
),
|
||||
Text(
|
||||
album.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -339,9 +348,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
}
|
||||
|
||||
Widget _buildTracksList(MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,16 +361,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.music_note_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -369,8 +380,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -392,13 +403,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
final imageUrl = track.album != null
|
||||
? provider.getImageUrl(track.album!, size: 128)
|
||||
: null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -408,25 +420,25 @@ class _LibraryScreenState extends State<LibraryScreen>
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
|
||||
@@ -9,22 +9,23 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
color: Colors.white,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Tracks',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
@@ -36,9 +37,11 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildTracksList(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,16 +50,16 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.music_note_outlined,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
@@ -66,8 +69,8 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Refresh'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1a1a1a),
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -94,13 +97,15 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
final imageUrl = track.album != null
|
||||
? maProvider.getImageUrl(track.album!, size: 128)
|
||||
: null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
@@ -110,25 +115,25 @@ class LibraryTracksScreen extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(context, maProvider, index),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../widgets/logo_text.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -47,6 +48,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
String _normalizeServerUrl(String url) {
|
||||
// Remove any trailing slashes
|
||||
url = url.trim();
|
||||
while (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
|
||||
// If URL already has a protocol, return it
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If it's an IP address or localhost, default to http://
|
||||
if (url.startsWith('192.') ||
|
||||
url.startsWith('10.') ||
|
||||
url.startsWith('172.') ||
|
||||
url == 'localhost' ||
|
||||
url.startsWith('127.')) {
|
||||
return 'http://$url';
|
||||
}
|
||||
|
||||
// For domain names, default to https://
|
||||
return 'https://$url';
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_serverUrlController.text.trim().isEmpty) {
|
||||
@@ -62,7 +90,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final serverUrl = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||
final port = _portController.text.trim();
|
||||
|
||||
// Validate port
|
||||
@@ -145,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),
|
||||
@@ -158,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,
|
||||
),
|
||||
),
|
||||
@@ -203,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,
|
||||
),
|
||||
),
|
||||
@@ -238,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,
|
||||
@@ -261,42 +270,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Authentication toggle
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _requiresAuth,
|
||||
onChanged: _isConnecting ? null : (value) {
|
||||
setState(() {
|
||||
_requiresAuth = value ?? false;
|
||||
});
|
||||
},
|
||||
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return Colors.white24;
|
||||
}
|
||||
return states.contains(WidgetState.selected)
|
||||
? Colors.white
|
||||
: Colors.white54;
|
||||
}),
|
||||
checkColor: const Color(0xFF1a1a1a),
|
||||
),
|
||||
const Text(
|
||||
'Requires authentication',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Authentication fields
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -304,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,
|
||||
@@ -325,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,
|
||||
),
|
||||
),
|
||||
@@ -337,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,
|
||||
@@ -367,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -389,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),
|
||||
@@ -398,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(
|
||||
@@ -414,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,20 +13,21 @@ class NewHomeScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
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,
|
||||
@@ -46,40 +48,46 @@ class NewHomeScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: !maProvider.isConnected
|
||||
? _buildDisconnectedView(context, maProvider)
|
||||
: _buildConnectedView(context, maProvider),
|
||||
child: Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
return !maProvider.isConnected
|
||||
? _buildDisconnectedView(context, maProvider)
|
||||
: _buildConnectedView(context, maProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -97,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,
|
||||
@@ -117,6 +125,7 @@ class NewHomeScreen extends StatelessWidget {
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
@@ -19,6 +21,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
PlayerQueue? _queue;
|
||||
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() {
|
||||
@@ -27,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();
|
||||
@@ -86,11 +112,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
@@ -115,84 +144,125 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
selectedPlayer.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
// 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: backgroundColor,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: textColor),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
// Album Art with Hero animation
|
||||
Hero(
|
||||
tag: 'now_playing_art',
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth.clamp(200.0, 400.0);
|
||||
return Center(
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 1024,
|
||||
cacheHeight: 1024,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Track Info
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -203,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,
|
||||
@@ -215,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,
|
||||
@@ -228,12 +298,57 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
// Progress Bar (showing elapsed time)
|
||||
if (currentTrack.duration != null) ...[
|
||||
Slider(
|
||||
value: selectedPlayer.currentElapsedTime.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||
onChanged: null, // TODO: Implement seek
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white24,
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 14,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: (_seekPosition ?? selectedPlayer.currentElapsedTime).clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_seekPosition = value;
|
||||
});
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
setState(() {
|
||||
_seekPosition = value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
final targetPosition = value.round();
|
||||
print('🎯 Seek slider released at $targetPosition seconds');
|
||||
try {
|
||||
await maProvider.seek(selectedPlayer.playerId, targetPosition);
|
||||
print('✅ Seek command completed');
|
||||
|
||||
// Wait briefly for server to update position
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
} catch (e) {
|
||||
print('❌ Error seeking: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error seeking: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_seekPosition = null;
|
||||
print('🎯 Cleared seek position');
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
activeColor: primaryColor,
|
||||
inactiveColor: primaryColor.withOpacity(0.24),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
@@ -241,12 +356,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(selectedPlayer.currentElapsedTime.toInt()),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -267,65 +382,85 @@ 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,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Previous
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: textColor,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Play/Pause
|
||||
_AnimatedPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _AnimatedPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: backgroundColor,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Next
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: textColor,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Repeat
|
||||
@@ -335,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,
|
||||
@@ -352,10 +487,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
@@ -369,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,
|
||||
});
|
||||
|
||||
@@ -423,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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -1025,7 +1062,7 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('Setting volume to $volumeLevel for player $playerId');
|
||||
await _sendCommand(
|
||||
'player_command/volume_set',
|
||||
'players/cmd/volume_set',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'volume_level': volumeLevel.clamp(0, 100),
|
||||
@@ -1042,7 +1079,7 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('${muted ? "Muting" : "Unmuting"} player $playerId');
|
||||
await _sendCommand(
|
||||
'player_command/volume_mute',
|
||||
'players/cmd/volume_mute',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'muted': muted,
|
||||
@@ -1085,6 +1122,23 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek to position in seconds
|
||||
Future<void> seek(String queueId, int position) async {
|
||||
try {
|
||||
_logger.log('Seeking to $position seconds for queue $queueId');
|
||||
await _sendCommand(
|
||||
'player_queues/seek',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'position': position,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error seeking: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendQueueCommand(String queueId, String command) async {
|
||||
try {
|
||||
_logger.log('🎮 Sending queue command: $command to queue $queueId');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Brand colors for Amass
|
||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||
const Color brandAccentColor = Color(0xFF604CEC);
|
||||
|
||||
// Brand color schemes
|
||||
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: brandAccentColor,
|
||||
brightness: Brightness.light,
|
||||
background: const Color(0xFFFAFAFA),
|
||||
onBackground: const Color(0xFF1a1a1a), // Ensure dark text
|
||||
surface: const Color(0xFFFFFFFF),
|
||||
onSurface: const Color(0xFF1a1a1a), // Ensure dark text
|
||||
);
|
||||
|
||||
final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: brandAccentColor,
|
||||
brightness: Brightness.dark,
|
||||
surface: const Color(0xFF2a2a2a),
|
||||
background: const Color(0xFF1a1a1a),
|
||||
);
|
||||
|
||||
class AppTheme {
|
||||
// Generate light theme
|
||||
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
||||
final scheme = colorScheme ?? brandLightColorScheme;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.background,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
);
|
||||
}
|
||||
|
||||
// Generate dark theme
|
||||
static ThemeData darkTheme({ColorScheme? colorScheme}) {
|
||||
final scheme = colorScheme ?? brandDarkColorScheme;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.background,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
class PaletteHelper {
|
||||
/// Extract a color palette from an image
|
||||
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
|
||||
try {
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
imageProvider,
|
||||
maximumColorCount: 20,
|
||||
);
|
||||
return palette;
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to extract palette: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate color schemes from a palette
|
||||
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
// Get the dominant color as the seed color
|
||||
Color seedColor = palette.dominantColor?.color ??
|
||||
palette.vibrantColor?.color ??
|
||||
palette.mutedColor?.color ??
|
||||
const Color(0xFF604CEC); // Fallback to brand color
|
||||
|
||||
// Generate light and dark color schemes from the seed color
|
||||
final lightScheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
final darkScheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.dark,
|
||||
// Use darker background colors for better contrast
|
||||
surface: const Color(0xFF2a2a2a),
|
||||
background: const Color(0xFF1a1a1a),
|
||||
);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
|
||||
/// Extract color schemes from an image provider in one call
|
||||
static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async {
|
||||
final palette = await extractPalette(imageProvider);
|
||||
return generateColorSchemes(palette);
|
||||
}
|
||||
|
||||
/// Get primary color for use in UI elements
|
||||
static Color? getPrimaryColor(PaletteGenerator? palette) {
|
||||
if (palette == null) return null;
|
||||
|
||||
return palette.vibrantColor?.color ??
|
||||
palette.dominantColor?.color ??
|
||||
palette.lightVibrantColor?.color;
|
||||
}
|
||||
|
||||
/// Get background color for use in UI
|
||||
static Color? getBackgroundColor(PaletteGenerator? palette, {required bool isDark}) {
|
||||
if (palette == null) return null;
|
||||
|
||||
if (isDark) {
|
||||
return palette.darkMutedColor?.color ??
|
||||
palette.mutedColor?.color?.withOpacity(0.3) ??
|
||||
const Color(0xFF1a1a1a);
|
||||
} else {
|
||||
return palette.lightMutedColor?.color ??
|
||||
palette.mutedColor?.color?.withOpacity(0.9) ??
|
||||
Colors.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
|
||||
class SystemThemeHelper {
|
||||
/// Attempts to get system color schemes from the device (Android 12+)
|
||||
/// Returns a tuple of (light, dark) ColorScheme if successful, null otherwise
|
||||
static Future<(ColorScheme, ColorScheme)?> getSystemColorSchemes() async {
|
||||
try {
|
||||
// Try to get dynamic color schemes from the system
|
||||
final corePalette = await DynamicColorPlugin.getCorePalette();
|
||||
|
||||
if (corePalette != null) {
|
||||
// Generate color schemes from the core palette
|
||||
final lightScheme = corePalette.toColorScheme(brightness: Brightness.light);
|
||||
final darkScheme = corePalette.toColorScheme(brightness: Brightness.dark);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
} else {
|
||||
// Fallback: try to get just the accent color
|
||||
final accentColor = await DynamicColorPlugin.getAccentColor();
|
||||
|
||||
if (accentColor != null) {
|
||||
// Generate color schemes from the accent color
|
||||
final lightScheme = ColorScheme.fromSeed(
|
||||
seedColor: accentColor,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
final darkScheme = ColorScheme.fromSeed(
|
||||
seedColor: accentColor,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
return (lightScheme, darkScheme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Failed to get system colors: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class ThemeProvider extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
bool _useMaterialTheme = false;
|
||||
bool _adaptiveTheme = true;
|
||||
Color _customColor = const Color(0xFF604CEC);
|
||||
|
||||
ThemeProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
bool get useMaterialTheme => _useMaterialTheme;
|
||||
bool get adaptiveTheme => _adaptiveTheme;
|
||||
Color get customColor => _customColor;
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final themeModeString = await SettingsService.getThemeMode();
|
||||
_themeMode = _parseThemeMode(themeModeString);
|
||||
|
||||
_useMaterialTheme = await SettingsService.getUseMaterialTheme();
|
||||
_adaptiveTheme = await SettingsService.getAdaptiveTheme();
|
||||
|
||||
final colorString = await SettingsService.getCustomColor();
|
||||
if (colorString != null) {
|
||||
_customColor = _parseColor(colorString);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ThemeMode _parseThemeMode(String? mode) {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
return ThemeMode.light;
|
||||
case 'dark':
|
||||
return ThemeMode.dark;
|
||||
case 'system':
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
Color _parseColor(String colorString) {
|
||||
try {
|
||||
// Remove # if present
|
||||
final hex = colorString.replaceAll('#', '');
|
||||
// Add FF for alpha if not present
|
||||
final hexWithAlpha = hex.length == 6 ? 'FF$hex' : hex;
|
||||
return Color(int.parse(hexWithAlpha, radix: 16));
|
||||
} catch (e) {
|
||||
return const Color(0xFF604CEC); // Default color
|
||||
}
|
||||
}
|
||||
|
||||
String _colorToString(Color color) {
|
||||
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
_themeMode = mode;
|
||||
await SettingsService.saveThemeMode(_themeModeToString(mode));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _themeModeToString(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
return 'light';
|
||||
case ThemeMode.dark:
|
||||
return 'dark';
|
||||
case ThemeMode.system:
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUseMaterialTheme(bool enabled) async {
|
||||
_useMaterialTheme = enabled;
|
||||
await SettingsService.saveUseMaterialTheme(enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setAdaptiveTheme(bool enabled) async {
|
||||
_adaptiveTheme = enabled;
|
||||
await SettingsService.saveAdaptiveTheme(enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCustomColor(Color color) async {
|
||||
_customColor = color;
|
||||
await SettingsService.saveCustomColor(_colorToString(color));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,18 +62,26 @@ 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return _AlbumCard(album: album);
|
||||
return Container(
|
||||
width: 150,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: AlbumCard(album: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -82,98 +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,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.album, size: 64),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.album, size: 64),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Artist name with Hero animation
|
||||
if (album.artists != null && album.artists!.isNotEmpty)
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (album.uri ?? album.itemId),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.artists!.first.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,18 +62,26 @@ 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index];
|
||||
return _ArtistCard(artist: artist);
|
||||
return Container(
|
||||
width: 120,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: ArtistCard(artist: artist),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -81,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class LogoText extends StatelessWidget {
|
||||
final double fontSize;
|
||||
final bool lightMode;
|
||||
|
||||
const LogoText({
|
||||
super.key,
|
||||
this.fontSize = 24,
|
||||
this.lightMode = false
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor = lightMode ? Colors.white : colorScheme.onBackground;
|
||||
|
||||
return Text(
|
||||
'Assistant To The Music',
|
||||
style: GoogleFonts.specialElite(
|
||||
textStyle: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Typewriter fonts often don't need extra letter spacing
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,196 +12,226 @@ class MiniPlayer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Don't show mini player if no track is playing or no player selected
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||
// Don't show mini player if no track is playing or no player selected
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NowPlayingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Status indicator bar
|
||||
Container(
|
||||
height: 2,
|
||||
color: selectedPlayer.isPlaying
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
// Player content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NowPlayingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
child: Material(
|
||||
color: colorScheme.surface,
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: 'now_playing_art',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
// Status indicator bar
|
||||
Container(
|
||||
height: 2,
|
||||
color: selectedPlayer.isPlaying
|
||||
? colorScheme.primary.withOpacity(0.7)
|
||||
: colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
// Player content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 96,
|
||||
cacheHeight: 96,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
// Queue button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
iconSize: 22,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Volume control (compact mute button)
|
||||
const VolumeControl(compact: true),
|
||||
// Playback controls for selected player
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 26,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: selectedPlayer.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 32,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: colorScheme.onSurface,
|
||||
iconSize: 28,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Queue button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
color: Colors.white70,
|
||||
iconSize: 22,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Volume control (compact mute button)
|
||||
const VolumeControl(compact: true),
|
||||
// Playback controls for selected player
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 26,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
AnimatedIconButton(
|
||||
icon: selectedPlayer.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 32,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 28,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -83,21 +83,45 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
),
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: isMuted ? null : (value) {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pendingVolume = value * 100;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
final volumeLevel = (value * 100).round();
|
||||
print('🔊 Volume slider released at $volumeLevel');
|
||||
try {
|
||||
// Unmute if changing volume while muted
|
||||
if (isMuted) {
|
||||
print('🔇 Unmuting player before setting volume');
|
||||
await maProvider.setMute(player.playerId, false);
|
||||
}
|
||||
print('🔊 Calling setVolume with $volumeLevel');
|
||||
await maProvider.setVolume(player.playerId, volumeLevel);
|
||||
print('✅ Volume set complete');
|
||||
|
||||
// Wait for the player state to update by polling
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted) {
|
||||
final updatedPlayer = maProvider.selectedPlayer;
|
||||
print('🔊 Poll $i: player volume = ${updatedPlayer?.volume}, target = $volumeLevel');
|
||||
if (updatedPlayer != null && (updatedPlayer.volume - volumeLevel).abs() <= 2) {
|
||||
print('✅ Volume updated on server');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
print('❌ Error setting volume: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
print('🔊 Cleared pending volume');
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -13,10 +13,8 @@ dependencies:
|
||||
# UI
|
||||
cupertino_icons: ^1.0.6
|
||||
google_fonts: ^6.1.0
|
||||
|
||||
# Audio playback
|
||||
just_audio: ^0.9.36
|
||||
audio_service: ^0.18.12
|
||||
dynamic_color: 1.6.8
|
||||
palette_generator: ^0.3.3+3
|
||||
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
@@ -28,13 +26,7 @@ dependencies:
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
|
||||
# File handling
|
||||
file_picker: ^6.1.1
|
||||
path_provider: ^2.1.1
|
||||
permission_handler: ^11.1.0
|
||||
|
||||
# Utilities
|
||||
rxdart: ^0.27.7
|
||||
uuid: ^4.2.1
|
||||
|
||||
dev_dependencies:
|
||||
@@ -49,7 +41,7 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
flutter_icons:
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: false
|
||||
image_path: "assets/images/icon.png"
|
||||
|
||||