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
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Generate launcher icons
|
- name: Generate launcher icons
|
||||||
run: flutter pub run flutter_launcher_icons
|
run: flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Analyze code
|
- name: Analyze code
|
||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
continue-on-error: true
|
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
|
- name: Build APK
|
||||||
run: flutter build apk --release
|
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">
|
<div align="center">
|
||||||
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
|
<img src="assets/images/attm_long_logo.png" alt="Assistant To The Music Logo" height="200">
|
||||||
<h1>Amass</h1>
|
|
||||||
|
---
|
||||||
|
|
||||||
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
<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>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Music Assistant Integration
|
### Music Assistant Integration
|
||||||
- **Server Connection** - Connect to your Music Assistant server via WebSocket
|
- **Server Connection** - Connect to your Music Assistant server via WebSocket
|
||||||
- **Library Browsing** - Browse artists, albums, and tracks from your server
|
- **Library Browsing** - Browse artists, albums, and tracks from your server
|
||||||
- **Album Details** - View album information and track listings
|
- **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
|
- **Auto-Reconnect** - Automatic reconnection with connection status monitoring
|
||||||
- **Settings Management** - Configure server URL with persistent storage
|
- **Settings Management** - Configure server URL with persistent storage
|
||||||
|
|
||||||
### Player Features
|
### Control Features
|
||||||
- Clean, minimalistic dark UI design
|
- Full playback controls (play/pause/skip/seek) for server players
|
||||||
- Full audio playback controls (play/pause/skip/seek)
|
|
||||||
- Progress bar with time display
|
- Progress bar with time display
|
||||||
- Volume control slider
|
- Volume control for server players
|
||||||
- Now playing display with track information
|
- Now playing display with track information
|
||||||
- Background audio playback support
|
- Queue management and viewing
|
||||||
- Queue management
|
- 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
|
||||||
|
|
||||||
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
|
## Setup
|
||||||
|
|
||||||
1. Launch the app
|
1. Launch the app
|
||||||
2. Navigate to the **Library** tab
|
2. Navigate to the **Library** tab
|
||||||
3. Tap **Configure Server** or go to **Settings**
|
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**
|
5. Tap **Connect**
|
||||||
6. Browse your library and start playing music!
|
6. Browse your library and control playback on your Music Assistant players!
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -47,7 +69,7 @@ Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Ama
|
|||||||
|
|
||||||
## About Music Assistant
|
## 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/)
|
Learn more: [music-assistant.io](https://music-assistant.io/)
|
||||||
|
|
||||||
@@ -70,8 +92,8 @@ MIT License
|
|||||||
|
|
||||||
1. Clone the repository
|
1. Clone the repository
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/CollotsSpot/Amass.git
|
git clone https://github.com/CollotsSpot/AssistantToTheMusic.git
|
||||||
cd Amass
|
cd AssistantToTheMusic
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies
|
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>
|
<summary>Technologies Used</summary>
|
||||||
|
|
||||||
- **Flutter** - Cross-platform mobile framework
|
- **Flutter** - Cross-platform mobile framework
|
||||||
- **just_audio** - Audio playback
|
- **web_socket_channel** - WebSocket communication with Music Assistant server
|
||||||
- **audio_service** - Background audio support
|
|
||||||
- **web_socket_channel** - WebSocket communication
|
|
||||||
- **provider** - State management
|
- **provider** - State management
|
||||||
- **shared_preferences** - Local settings storage
|
- **shared_preferences** - Local settings storage
|
||||||
|
- **http** - HTTP requests for API communication
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = '1.0'
|
flutterVersionName = '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def keystoreProperties = new Properties()
|
||||||
|
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "com.musicassistant.music_assistant"
|
namespace "com.musicassistant.music_assistant"
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
@@ -40,6 +46,17 @@ android {
|
|||||||
main.java.srcDirs += 'src/main/kotlin'
|
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 {
|
defaultConfig {
|
||||||
applicationId "com.musicassistant.music_assistant"
|
applicationId "com.musicassistant.music_assistant"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
@@ -50,7 +67,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.debug
|
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Amass"
|
android:label="ATTM"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true"
|
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
|
/// Hero tag for artist image
|
||||||
static const String artistImage = '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/home_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'theme/theme_provider.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
|
import 'theme/system_theme_helper.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -36,12 +39,14 @@ class MusicAssistantApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
||||||
late MusicAssistantProvider _provider;
|
late MusicAssistantProvider _musicProvider;
|
||||||
|
late ThemeProvider _themeProvider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_provider = MusicAssistantProvider();
|
_musicProvider = MusicAssistantProvider();
|
||||||
|
_themeProvider = ThemeProvider();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +63,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
// App came back to foreground - check connection and reconnect if needed
|
// App came back to foreground - check connection and reconnect if needed
|
||||||
print('📱 App resumed - checking WebSocket connection...');
|
print('📱 App resumed - checking WebSocket connection...');
|
||||||
_provider.checkAndReconnect();
|
_musicProvider.checkAndReconnect();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
print('📱 App paused (backgrounded)');
|
print('📱 App paused (backgrounded)');
|
||||||
} else if (state == AppLifecycleState.detached) {
|
} else if (state == AppLifecycleState.detached) {
|
||||||
@@ -68,30 +73,60 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider.value(
|
return MultiProvider(
|
||||||
value: _provider,
|
providers: [
|
||||||
child: MaterialApp(
|
ChangeNotifierProvider.value(value: _musicProvider),
|
||||||
title: 'Amass',
|
ChangeNotifierProvider.value(value: _themeProvider),
|
||||||
debugShowCheckedModeBanner: false,
|
],
|
||||||
theme: ThemeData(
|
child: Consumer<ThemeProvider>(
|
||||||
useMaterial3: true,
|
builder: (context, themeProvider, _) {
|
||||||
brightness: Brightness.dark,
|
return FutureBuilder<(ColorScheme, ColorScheme)?>(
|
||||||
primaryColor: const Color(0xFF1a1a1a),
|
future: themeProvider.useMaterialTheme
|
||||||
scaffoldBackgroundColor: const Color(0xFF1a1a1a),
|
? SystemThemeHelper.getSystemColorSchemes()
|
||||||
colorScheme: const ColorScheme.dark(
|
: null,
|
||||||
primary: Colors.white,
|
builder: (context, snapshot) {
|
||||||
secondary: Colors.white70,
|
// Determine which color schemes to use
|
||||||
surface: Color(0xFF2a2a2a),
|
ColorScheme? lightColorScheme;
|
||||||
background: Color(0xFF1a1a1a),
|
ColorScheme? darkColorScheme;
|
||||||
),
|
|
||||||
appBarTheme: const AppBarTheme(
|
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||||
backgroundColor: Colors.transparent,
|
// Use system color schemes
|
||||||
elevation: 0,
|
final (light, dark) = snapshot.data!;
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
lightColorScheme = light;
|
||||||
),
|
darkColorScheme = dark;
|
||||||
fontFamily: 'Roboto',
|
} else {
|
||||||
),
|
// Use brand color schemes
|
||||||
home: const AppStartup(),
|
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)
|
// Calculate current elapsed time (interpolated if playing)
|
||||||
double get currentElapsedTime {
|
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
|
// If playing, interpolate based on time since last update
|
||||||
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
||||||
final timeSinceUpdate = now - elapsedTimeLastUpdated!;
|
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) {
|
factory Player.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -52,21 +78,24 @@ class Player {
|
|||||||
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
||||||
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] 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
|
// Extract current_item_id from current_media
|
||||||
if (json.containsKey('current_media')) {
|
if (json.containsKey('current_media')) {
|
||||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
||||||
|
|
||||||
// Only use current_media elapsed_time if we don't have top-level (fallback)
|
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||||
if (elapsedTime == null) {
|
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||||
elapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
|
||||||
elapsedTimeLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
// Use current_media elapsed_time if:
|
||||||
print('⚠️ Player ${json['name']}: Falling back to current_media elapsed_time: $elapsedTime seconds');
|
// 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;
|
List<Player> get availablePlayers => _availablePlayers;
|
||||||
Track? get currentTrack => _currentTrack;
|
Track? get currentTrack => _currentTrack;
|
||||||
|
|
||||||
|
// Debug: Get ALL players including filtered ones
|
||||||
|
Future<List<Player>> getAllPlayersUnfiltered() async {
|
||||||
|
return await getPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
// API access
|
// API access
|
||||||
MusicAssistantAPI? get api => _api;
|
MusicAssistantAPI? get api => _api;
|
||||||
|
|
||||||
@@ -138,10 +143,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Load artists, albums, and tracks in parallel
|
// 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([
|
final results = await Future.wait([
|
||||||
_api!.getArtists(limit: 500),
|
_api!.getArtists(limit: 5000),
|
||||||
_api!.getAlbums(limit: 500),
|
_api!.getAlbums(limit: 5000),
|
||||||
_api!.getTracks(limit: 500),
|
_api!.getTracks(limit: 5000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
_artists = results[0] as List<Artist>;
|
_artists = results[0] as List<Artist>;
|
||||||
@@ -168,7 +174,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_artists = await _api!.getArtists(
|
_artists = await _api!.getArtists(
|
||||||
limit: limit,
|
limit: limit ?? 5000, // Default to high limit if not specified
|
||||||
offset: offset,
|
offset: offset,
|
||||||
search: search,
|
search: search,
|
||||||
);
|
);
|
||||||
@@ -198,7 +204,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_albums = await _api!.getAlbums(
|
_albums = await _api!.getAlbums(
|
||||||
limit: limit,
|
limit: limit ?? 5000, // Default to high limit
|
||||||
offset: offset,
|
offset: offset,
|
||||||
search: search,
|
search: search,
|
||||||
artistId: artistId,
|
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 {
|
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||||
try {
|
try {
|
||||||
await _api?.setVolume(playerId, volumeLevel);
|
await _api?.setVolume(playerId, volumeLevel);
|
||||||
// Refresh player state to get updated volume
|
// Don't refresh immediately - let automatic polling pick up the change
|
||||||
await refreshPlayers();
|
// This prevents the slider from snapping back before the server updates
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Set volume', e);
|
ErrorHandler.logError('Set volume', e);
|
||||||
rethrow;
|
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 {
|
Future<void> toggleShuffle(String queueId) async {
|
||||||
try {
|
try {
|
||||||
await _api?.toggleShuffle(queueId);
|
await _api?.toggleShuffle(queueId);
|
||||||
@@ -411,33 +448,54 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_playersLastFetched != null &&
|
_playersLastFetched != null &&
|
||||||
_availablePlayers.isNotEmpty &&
|
_availablePlayers.isNotEmpty &&
|
||||||
now.difference(_playersLastFetched!) < _playersCacheDuration) {
|
now.difference(_playersLastFetched!) < _playersCacheDuration) {
|
||||||
_logger.log('Using cached player list (${_availablePlayers.length} players)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Fetching fresh player list...');
|
|
||||||
final allPlayers = await getPlayers();
|
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) {
|
_availablePlayers = allPlayers.where((player) {
|
||||||
final nameLower = player.name.toLowerCase();
|
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')) {
|
if (nameLower.contains('music assistant mobile')) {
|
||||||
_logger.log('Filtering out leftover player: ${player.name}');
|
filteredCount++;
|
||||||
|
ghostPlayerIds.add('${player.playerId} (Mobile Ghost)');
|
||||||
return false;
|
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;
|
return true;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
_playersLastFetched = DateTime.now();
|
_playersLastFetched = DateTime.now();
|
||||||
_logger.log('Loaded ${_availablePlayers.length} players (filtered from ${allPlayers.length} total)');
|
|
||||||
|
|
||||||
// Log each player for debugging
|
// Log details about ghost players
|
||||||
for (final player in _availablePlayers) {
|
if (filteredCount > 0) {
|
||||||
_logger.log(' - ${player.name} (${player.playerId}) - available: ${player.available}, state: ${player.state}');
|
_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) {
|
if (_availablePlayers.isNotEmpty) {
|
||||||
@@ -484,21 +542,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('⚠️ No players available');
|
_logger.log('⚠️ No players available');
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
// Don't call notifyListeners here - selectPlayer already does it
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Load and select players', e);
|
ErrorHandler.logError('Load and select players', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select a player for playback
|
/// Select a player for playback
|
||||||
void selectPlayer(Player player) {
|
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||||
_selectedPlayer = player;
|
_selectedPlayer = player;
|
||||||
_logger.log('Selected player: ${player.name} (${player.playerId})');
|
_logger.log('Selected player: ${player.name} (${player.playerId})');
|
||||||
|
|
||||||
// Start polling for player state
|
// Start polling for player state
|
||||||
_startPlayerStatePolling();
|
_startPlayerStatePolling();
|
||||||
|
|
||||||
notifyListeners();
|
if (!skipNotify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start polling for selected player's current state
|
/// Start polling for selected player's current state
|
||||||
@@ -507,8 +567,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_selectedPlayer == null) return;
|
if (_selectedPlayer == null) return;
|
||||||
|
|
||||||
// Poll every 2 seconds
|
// Poll every 5 seconds for better performance
|
||||||
_playerStateTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
_playerStateTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||||
_updatePlayerState();
|
_updatePlayerState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -521,8 +581,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (_selectedPlayer == null || _api == null) return;
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Refresh players list to get latest player state
|
bool stateChanged = false;
|
||||||
await refreshPlayers();
|
|
||||||
|
// 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
|
// Only show tracks if player is available and not idle
|
||||||
final shouldShowTrack = _selectedPlayer!.available &&
|
final shouldShowTrack = _selectedPlayer!.available &&
|
||||||
@@ -532,7 +607,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Clear track if player is unavailable or idle
|
// Clear track if player is unavailable or idle
|
||||||
if (_currentTrack != null) {
|
if (_currentTrack != null) {
|
||||||
_currentTrack = null;
|
_currentTrack = null;
|
||||||
_logger.log('🗑️ Cleared track - player unavailable or idle (state: ${_selectedPlayer!.state}, available: ${_selectedPlayer!.available})');
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -542,23 +620,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||||
|
|
||||||
if (queue != null && queue.currentItem != null) {
|
if (queue != null && queue.currentItem != null) {
|
||||||
_currentTrack = queue.currentItem!.track;
|
// Only update if track actually changed
|
||||||
_logger.log('✅ Current track updated: ${_currentTrack!.name}');
|
if (_currentTrack == null ||
|
||||||
notifyListeners();
|
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||||
} else {
|
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||||
if (queue == null) {
|
_currentTrack = queue.currentItem!.track;
|
||||||
_logger.log('⚠️ Queue is null for player ${_selectedPlayer!.playerId}');
|
stateChanged = true;
|
||||||
} else if (queue.currentItem == null) {
|
|
||||||
_logger.log('⚠️ Queue currentItem is null (currentIndex: ${queue.currentIndex}, items: ${queue.items.length})');
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if (_currentTrack != null) {
|
if (_currentTrack != null) {
|
||||||
// Clear current track if queue is empty
|
// Clear current track if queue is empty
|
||||||
_currentTrack = null;
|
_currentTrack = null;
|
||||||
_logger.log('🗑️ Cleared current track');
|
stateChanged = true;
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only notify if something actually changed
|
||||||
|
if (stateChanged) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Error updating player state: $e');
|
_logger.log('❌ Error updating player state: $e');
|
||||||
}
|
}
|
||||||
@@ -601,21 +681,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Refresh the list of available players
|
/// Refresh the list of available players
|
||||||
Future<void> refreshPlayers() async {
|
Future<void> refreshPlayers() async {
|
||||||
|
final previousState = _selectedPlayer?.state;
|
||||||
|
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||||
|
|
||||||
await _loadAndSelectPlayers(forceRefresh: true);
|
await _loadAndSelectPlayers(forceRefresh: true);
|
||||||
|
|
||||||
// Update the selected player with fresh data
|
// Update the selected player with fresh data
|
||||||
|
bool stateChanged = false;
|
||||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final updatedPlayer = _availablePlayers.firstWhere(
|
final updatedPlayer = _availablePlayers.firstWhere(
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if state actually changed
|
||||||
|
if (updatedPlayer.state != previousState ||
|
||||||
|
updatedPlayer.volumeLevel != previousVolume) {
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
_selectedPlayer = updatedPlayer;
|
_selectedPlayer = updatedPlayer;
|
||||||
_logger.log('Updated selected player state: ${updatedPlayer.name} - ${updatedPlayer.state}');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Selected player no longer available
|
// 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 '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/palette_helper.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
|
||||||
class AlbumDetailsScreen extends StatefulWidget {
|
class AlbumDetailsScreen extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
@@ -17,12 +19,37 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
bool _isFavorite = false;
|
||||||
|
ColorScheme? _lightColorScheme;
|
||||||
|
ColorScheme? _darkColorScheme;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
_loadTracks();
|
_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 {
|
Future<void> _toggleFavorite() async {
|
||||||
@@ -136,20 +163,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
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(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: 350, // Increased height for bigger art
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Column(
|
background: Column(
|
||||||
@@ -159,11 +201,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 280, // Increased size
|
||||||
height: 200,
|
height: 280,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.3),
|
color: Colors.black.withOpacity(0.3),
|
||||||
@@ -179,10 +221,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(
|
? Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 100,
|
size: 120,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -203,9 +245,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.album.name,
|
widget.album.name,
|
||||||
style: const TextStyle(
|
style: textTheme.headlineMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -218,9 +259,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.album.artistsString,
|
widget.album.artistsString,
|
||||||
style: const TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white70,
|
color: colorScheme.onBackground.withOpacity(0.7),
|
||||||
fontSize: 16,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,17 +268,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Main Play Button
|
||||||
Expanded(
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
|
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: const Text('Play Album'),
|
label: const Text('Play'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: Colors.white38,
|
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -247,29 +289,49 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
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(
|
Container(
|
||||||
height: 50,
|
height: 50,
|
||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _isFavorite ? Colors.red : Colors.white24,
|
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: _toggleFavorite,
|
onPressed: _toggleFavorite,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
color: _isFavorite ? Colors.white : Colors.white70,
|
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Text(
|
||||||
'Tracks',
|
'Tracks',
|
||||||
style: TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -279,18 +341,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_tracks.isEmpty)
|
else if (_tracks.isEmpty)
|
||||||
const SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No tracks found',
|
'No tracks found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: colorScheme.onBackground.withOpacity(0.54),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -306,14 +368,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${track.position ?? index + 1}',
|
'${track.position ?? index + 1}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -321,8 +383,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
track.name,
|
track.name,
|
||||||
style: const TextStyle(
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -330,9 +392,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistsString,
|
track.artistsString,
|
||||||
style: const TextStyle(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -340,9 +401,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
|
|||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: const TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: 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) {
|
String _formatDuration(Duration duration) {
|
||||||
final minutes = duration.inMinutes;
|
final minutes = duration.inMinutes;
|
||||||
final seconds = duration.inSeconds % 60;
|
final seconds = duration.inSeconds % 60;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/palette_helper.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@@ -17,11 +19,36 @@ class ArtistDetailsScreen extends StatefulWidget {
|
|||||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
ColorScheme? _lightColorScheme;
|
||||||
|
ColorScheme? _darkColorScheme;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadArtistAlbums();
|
_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 {
|
Future<void> _loadArtistAlbums() async {
|
||||||
@@ -33,51 +60,94 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
print(' Provider: ${widget.artist.provider}');
|
print(' Provider: ${widget.artist.provider}');
|
||||||
print(' ItemId: ${widget.artist.itemId}');
|
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();
|
final allAlbums = await provider.api!.getAlbums();
|
||||||
|
|
||||||
// Filter albums that include this artist
|
// Filter albums that include this artist
|
||||||
final artistAlbums = allAlbums.where((album) {
|
var artistAlbums = allAlbums.where((album) {
|
||||||
if (album.artists == null) return false;
|
if (album.artists == null) return false;
|
||||||
return album.artists!.any((artist) =>
|
return album.artists!.any((artist) =>
|
||||||
artist.itemId == widget.artist.itemId &&
|
artist.itemId == widget.artist.itemId ||
|
||||||
artist.provider == widget.artist.provider
|
artist.name == widget.artist.name // Fallback to name match
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
print(' Got ${artistAlbums.length} albums for this artist (from ${allAlbums.length} total)');
|
print(' Got ${artistAlbums.length} library albums for this artist');
|
||||||
if (artistAlbums.isNotEmpty) {
|
|
||||||
print(' First album: ${artistAlbums.first.name}');
|
// 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(() {
|
if (mounted) {
|
||||||
_albums = artistAlbums;
|
setState(() {
|
||||||
_isLoading = false;
|
_albums = artistAlbums;
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_isLoading = false;
|
setState(() {
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
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(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: 300,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Column(
|
background: Column(
|
||||||
@@ -88,14 +158,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 100,
|
radius: 100,
|
||||||
backgroundColor: Colors.white12,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
imageUrl != null ? NetworkImage(imageUrl) : null,
|
imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(
|
? Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: 100,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -116,9 +186,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.artist.name,
|
widget.artist.name,
|
||||||
style: const TextStyle(
|
style: textTheme.headlineMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -127,9 +196,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Albums',
|
'Albums',
|
||||||
style: const TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -139,18 +207,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_albums.isEmpty)
|
else if (_albums.isEmpty)
|
||||||
const SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No albums found',
|
'No albums found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: colorScheme.onBackground.withOpacity(0.54),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -182,6 +250,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -198,7 +268,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -208,11 +278,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Center(
|
? Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -221,9 +291,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: const TextStyle(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -231,9 +300,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
album.artistsString,
|
album.artistsString,
|
||||||
style: const TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
|
||||||
class DebugLogScreen extends StatefulWidget {
|
class DebugLogScreen extends StatefulWidget {
|
||||||
const DebugLogScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -75,6 +167,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.speaker_group_rounded),
|
||||||
|
onPressed: _showAllPlayers,
|
||||||
|
color: Colors.white,
|
||||||
|
tooltip: 'View all players',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.copy_rounded),
|
icon: const Icon(Icons.copy_rounded),
|
||||||
onPressed: _copyLogs,
|
onPressed: _copyLogs,
|
||||||
|
|||||||
@@ -24,66 +24,78 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final theme = Theme.of(context);
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
final colorScheme = theme.colorScheme;
|
||||||
body: _screens[_selectedIndex],
|
|
||||||
bottomNavigationBar: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Mini player
|
|
||||||
const MiniPlayer(),
|
|
||||||
|
|
||||||
// Bottom navigation bar
|
return PopScope(
|
||||||
Container(
|
canPop: _selectedIndex == 0,
|
||||||
decoration: BoxDecoration(
|
onPopInvoked: (didPop) {
|
||||||
color: const Color(0xFF2a2a2a),
|
if (didPop) return;
|
||||||
boxShadow: [
|
setState(() {
|
||||||
BoxShadow(
|
_selectedIndex = 0;
|
||||||
color: Colors.black.withOpacity(0.3),
|
});
|
||||||
blurRadius: 10,
|
},
|
||||||
offset: const Offset(0, -2),
|
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 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import 'album_details_screen.dart';
|
import '../widgets/album_card.dart';
|
||||||
import '../constants/hero_tags.dart';
|
|
||||||
|
|
||||||
class LibraryAlbumsScreen extends StatelessWidget {
|
class LibraryAlbumsScreen extends StatelessWidget {
|
||||||
const LibraryAlbumsScreen({super.key});
|
const LibraryAlbumsScreen({super.key});
|
||||||
@@ -11,35 +10,38 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Albums',
|
'Albums',
|
||||||
style: TextStyle(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
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) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +50,16 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.album_outlined,
|
Icons.album_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No albums found',
|
'No albums found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,8 +69,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -77,6 +79,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await provider.loadLibrary();
|
await provider.loadLibrary();
|
||||||
},
|
},
|
||||||
@@ -91,88 +95,10 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
itemCount: provider.albums.length,
|
itemCount: provider.albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = provider.albums[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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Artists',
|
'Artists',
|
||||||
style: TextStyle(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
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) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +51,16 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.person_outline_rounded,
|
Icons.person_outline_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No artists found',
|
'No artists found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,8 +70,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -77,6 +80,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await provider.loadLibrary();
|
await provider.loadLibrary();
|
||||||
},
|
},
|
||||||
@@ -85,14 +90,16 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final artist = provider.artists[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 imageUrl = provider.getImageUrl(artist, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) => ListTile(
|
builder: (context) => ListTile(
|
||||||
@@ -100,10 +107,10 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: Colors.white12,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -113,8 +120,8 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
style: const TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|||||||
@@ -43,35 +43,38 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Playlists',
|
'Playlists',
|
||||||
style: TextStyle(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
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) {
|
if (_isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,16 +83,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.playlist_play_outlined,
|
Icons.playlist_play_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No playlists found',
|
'No playlists found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -99,8 +102,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -109,20 +112,24 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: _loadPlaylists,
|
onRefresh: _loadPlaylists,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _playlists.length,
|
itemCount: _playlists.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final playlist = _playlists[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 imageUrl = provider.api?.getImageUrl(playlist, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) => ListTile(
|
builder: (context) => ListTile(
|
||||||
@@ -130,7 +137,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -140,13 +147,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.playlist_play_rounded, color: Colors.white54)
|
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
playlist.name,
|
playlist.name,
|
||||||
style: const TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -156,7 +163,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
playlist.trackCount != null
|
playlist.trackCount != null
|
||||||
? '${playlist.trackCount} tracks'
|
? '${playlist.trackCount} tracks'
|
||||||
: playlist.owner ?? 'Playlist',
|
: playlist.owner ?? 'Playlist',
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,17 +33,18 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Library',
|
'Library',
|
||||||
style: TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -59,14 +60,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
indicatorColor: Colors.white,
|
indicatorColor: colorScheme.primary,
|
||||||
labelColor: Colors.white,
|
labelColor: colorScheme.primary,
|
||||||
unselectedLabelColor: Colors.white54,
|
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Artists'),
|
Tab(text: 'Artists'),
|
||||||
Tab(text: 'Albums'),
|
Tab(text: 'Albums'),
|
||||||
@@ -88,22 +89,24 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.cloud_off_rounded,
|
Icons.cloud_off_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Not connected to Music Assistant',
|
'Not connected to Music Assistant',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -121,8 +124,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
icon: const Icon(Icons.settings_rounded),
|
icon: const Icon(Icons.settings_rounded),
|
||||||
label: const Text('Configure Server'),
|
label: const Text('Configure Server'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -139,9 +142,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,16 +155,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.person_outline_rounded,
|
Icons.person_outline_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No artists found',
|
'No artists found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -169,8 +174,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -190,20 +195,21 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
|
|
||||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
final imageUrl = provider.getImageUrl(artist, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: Colors.white12,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -220,9 +226,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,16 +239,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.album_outlined,
|
Icons.album_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No albums found',
|
'No albums found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -250,8 +258,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -277,6 +285,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
final imageUrl = provider.getImageUrl(album, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -293,7 +302,7 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -303,11 +312,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Center(
|
? Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -316,8 +325,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -326,8 +335,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
album.artistsString,
|
album.artistsString,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -339,9 +348,11 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTracksList(MusicAssistantProvider provider) {
|
Widget _buildTracksList(MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,16 +361,16 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.music_note_outlined,
|
Icons.music_note_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No tracks found',
|
'No tracks found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -369,8 +380,8 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -392,13 +403,14 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
final imageUrl = track.album != null
|
final imageUrl = track.album != null
|
||||||
? provider.getImageUrl(track.album!, size: 128)
|
? provider.getImageUrl(track.album!, size: 128)
|
||||||
: null;
|
: null;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -408,25 +420,25 @@ class _LibraryScreenState extends State<LibraryScreen>
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
track.name,
|
track.name,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistsString,
|
track.artistsString,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -9,22 +9,23 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Tracks',
|
'Tracks',
|
||||||
style: TextStyle(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -36,9 +37,11 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildTracksList(
|
Widget _buildTracksList(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +50,16 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.music_note_outlined,
|
Icons.music_note_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'No tracks found',
|
'No tracks found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -66,8 +69,8 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Refresh'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -94,13 +97,15 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
final imageUrl = track.album != null
|
final imageUrl = track.album != null
|
||||||
? maProvider.getImageUrl(track.album!, size: 128)
|
? maProvider.getImageUrl(track.album!, size: 128)
|
||||||
: null;
|
: null;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -110,25 +115,25 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
track.name,
|
track.name,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistsString,
|
track.artistsString,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _playTrack(context, maProvider, index),
|
onTap: () => _playTrack(context, maProvider, index),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
import '../widgets/logo_text.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -47,6 +48,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
super.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 {
|
Future<void> _connect() async {
|
||||||
if (_serverUrlController.text.trim().isEmpty) {
|
if (_serverUrlController.text.trim().isEmpty) {
|
||||||
@@ -62,7 +90,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final serverUrl = _serverUrlController.text.trim();
|
final serverUrl = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||||
final port = _portController.text.trim();
|
final port = _portController.text.trim();
|
||||||
|
|
||||||
// Validate port
|
// Validate port
|
||||||
@@ -145,8 +173,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
@@ -158,44 +189,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
// Logo
|
// Logo
|
||||||
Center(
|
Center(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo.png',
|
'assets/images/attm_long_logo.png',
|
||||||
height: 80,
|
height: 160,
|
||||||
fit: BoxFit.contain,
|
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),
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
// Server URL
|
// Server URL
|
||||||
const Text(
|
Text(
|
||||||
'Server Address',
|
'Server Address',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -203,34 +210,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _serverUrlController,
|
controller: _serverUrlController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
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,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2a2a2a),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.dns_rounded,
|
Icons.dns_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ... rest of UI ...
|
||||||
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Port
|
// Port
|
||||||
const Text(
|
Text(
|
||||||
'Port',
|
'Port',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,19 +247,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '8095',
|
hintText: '8095',
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2a2a2a),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.settings_ethernet_rounded,
|
Icons.settings_ethernet_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting,
|
||||||
@@ -261,42 +270,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
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
|
// Authentication fields
|
||||||
if (_requiresAuth) ...[
|
if (_requiresAuth) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
const Text(
|
Text(
|
||||||
'Username',
|
'Username',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -304,19 +285,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Username',
|
hintText: 'Username',
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2a2a2a),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting,
|
||||||
@@ -325,11 +306,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
const Text(
|
Text(
|
||||||
'Password',
|
'Password',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -337,19 +317,19 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Password',
|
hintText: 'Password',
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2a2a2a),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.lock_rounded,
|
Icons.lock_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
@@ -367,18 +347,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.withOpacity(0.1),
|
color: colorScheme.errorContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
border: Border.all(color: colorScheme.error.withOpacity(0.3)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_rounded, color: Colors.red, size: 20),
|
Icon(Icons.error_rounded, color: colorScheme.error, size: 20),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_error!,
|
_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(
|
ElevatedButton(
|
||||||
onPressed: _isConnecting ? null : _connect,
|
onPressed: _isConnecting ? null : _connect,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -398,12 +378,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: _isConnecting
|
child: _isConnecting
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF1a1a1a)),
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Text(
|
: 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/player_selector.dart';
|
||||||
import '../widgets/album_row.dart';
|
import '../widgets/album_row.dart';
|
||||||
import '../widgets/artist_row.dart';
|
import '../widgets/artist_row.dart';
|
||||||
|
import '../widgets/logo_text.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
|
|
||||||
@@ -12,20 +13,21 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Padding(
|
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(
|
child: Image.asset(
|
||||||
'assets/images/logo.png',
|
'assets/images/attm_short_logo.png',
|
||||||
height: 72,
|
height: 32,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
@@ -46,40 +48,46 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: !maProvider.isConnected
|
child: Consumer<MusicAssistantProvider>(
|
||||||
? _buildDisconnectedView(context, maProvider)
|
builder: (context, maProvider, child) {
|
||||||
: _buildConnectedView(context, maProvider),
|
return !maProvider.isConnected
|
||||||
|
? _buildDisconnectedView(context, maProvider)
|
||||||
|
: _buildConnectedView(context, maProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(
|
Widget _buildDisconnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.cloud_off_rounded,
|
Icons.cloud_off_rounded,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Text(
|
||||||
'Not Connected',
|
'Not Connected',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
Text(
|
||||||
'Connect to your Music Assistant server to start listening',
|
'Connect to your Music Assistant server to start listening',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -97,8 +105,8 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.settings_rounded),
|
icon: const Icon(Icons.settings_rounded),
|
||||||
label: const Text('Configure Server'),
|
label: const Text('Configure Server'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 32,
|
horizontal: 32,
|
||||||
vertical: 16,
|
vertical: 16,
|
||||||
@@ -117,6 +125,7 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
Widget _buildConnectedView(
|
Widget _buildConnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -15,17 +15,18 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Library',
|
'Library',
|
||||||
style: TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -41,6 +42,7 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
const PlayerSelector(),
|
const PlayerSelector(),
|
||||||
],
|
],
|
||||||
@@ -53,22 +55,24 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildDisconnectedView(
|
Widget _buildDisconnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.cloud_off_rounded,
|
Icons.cloud_off_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Not connected to Music Assistant',
|
'Not connected to Music Assistant',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -86,8 +90,8 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.settings_rounded),
|
icon: const Icon(Icons.settings_rounded),
|
||||||
label: const Text('Configure Server'),
|
label: const Text('Configure Server'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -189,8 +193,12 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
required String subtitle,
|
required String subtitle,
|
||||||
required VoidCallback? onTap,
|
required VoidCallback? onTap,
|
||||||
}) {
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF2a2a2a),
|
elevation: 0, // Remove shadow
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -200,34 +208,32 @@ class NewLibraryScreen extends StatelessWidget {
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.1),
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
color: Colors.white,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: const TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
fontSize: 13,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: onTap != null
|
trailing: onTap != null
|
||||||
? const Icon(
|
? Icon(
|
||||||
Icons.chevron_right_rounded,
|
Icons.chevron_right_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../widgets/volume_control.dart';
|
|||||||
import 'queue_screen.dart';
|
import 'queue_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../widgets/animated_icon_button.dart';
|
import '../widgets/animated_icon_button.dart';
|
||||||
|
import '../theme/palette_helper.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
|
||||||
class NowPlayingScreen extends StatefulWidget {
|
class NowPlayingScreen extends StatefulWidget {
|
||||||
const NowPlayingScreen({super.key});
|
const NowPlayingScreen({super.key});
|
||||||
@@ -19,6 +21,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
PlayerQueue? _queue;
|
PlayerQueue? _queue;
|
||||||
bool _isLoadingQueue = true;
|
bool _isLoadingQueue = true;
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
|
double? _seekPosition; // Track seek position while dragging
|
||||||
|
ColorScheme? _lightColorScheme;
|
||||||
|
ColorScheme? _darkColorScheme;
|
||||||
|
String? _lastImageUrl; // Track last image URL to avoid re-extracting
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -27,6 +33,26 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
_startProgressTimer();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
@@ -86,11 +112,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
|
||||||
final currentTrack = maProvider.currentTrack;
|
|
||||||
|
|
||||||
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(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -115,84 +144,125 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
||||||
|
|
||||||
return Scaffold(
|
// Extract colors if adaptive theme is enabled and we have an image URL
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
||||||
appBar: AppBar(
|
_extractColors(imageUrl);
|
||||||
backgroundColor: Colors.transparent,
|
}
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
// Determine if we should use adaptive theme colors
|
||||||
selectedPlayer.name,
|
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||||
style: const TextStyle(
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: 14,
|
// Get the color scheme to use
|
||||||
fontWeight: FontWeight.w300,
|
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,
|
body: SafeArea(
|
||||||
actions: [
|
child: Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||||
icon: const Icon(Icons.queue_music),
|
child: Column(
|
||||||
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(
|
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
// Album Art with Hero animation
|
// Album Art with Hero animation
|
||||||
Hero(
|
LayoutBuilder(
|
||||||
tag: 'now_playing_art',
|
builder: (context, constraints) {
|
||||||
child: Container(
|
final size = constraints.maxWidth.clamp(200.0, 400.0);
|
||||||
width: double.infinity,
|
return Center(
|
||||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
child: Hero(
|
||||||
child: AspectRatio(
|
tag: HeroTags.nowPlayingArt,
|
||||||
aspectRatio: 1,
|
child: Container(
|
||||||
child: ClipRRect(
|
width: size,
|
||||||
borderRadius: BorderRadius.circular(16),
|
height: size,
|
||||||
child: imageUrl != null
|
child: ClipRRect(
|
||||||
? Image.network(
|
borderRadius: BorderRadius.circular(16),
|
||||||
imageUrl,
|
child: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? Image.network(
|
||||||
errorBuilder: (context, error, stackTrace) {
|
imageUrl,
|
||||||
return Container(
|
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),
|
color: const Color(0xFF2a2a2a),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.music_note_rounded,
|
Icons.music_note_rounded,
|
||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
size: 120,
|
size: 120,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
)
|
),
|
||||||
: Container(
|
|
||||||
color: const Color(0xFF2a2a2a),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.music_note_rounded,
|
|
||||||
color: Colors.white24,
|
|
||||||
size: 120,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
// Track Info
|
// Track Info
|
||||||
Text(
|
Text(
|
||||||
currentTrack.name,
|
currentTrack.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: textColor,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -203,8 +273,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
currentTrack.artistsString,
|
currentTrack.artistsString,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: textColor.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -215,8 +285,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
currentTrack.album!.name,
|
currentTrack.album!.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: textColor.withOpacity(0.54),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -228,12 +298,57 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
// Progress Bar (showing elapsed time)
|
// Progress Bar (showing elapsed time)
|
||||||
if (currentTrack.duration != null) ...[
|
if (currentTrack.duration != null) ...[
|
||||||
Slider(
|
SliderTheme(
|
||||||
value: selectedPlayer.currentElapsedTime.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
data: SliderThemeData(
|
||||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
trackHeight: 3,
|
||||||
onChanged: null, // TODO: Implement seek
|
thumbShape: const RoundSliderThumbShape(
|
||||||
activeColor: Colors.white,
|
enabledThumbRadius: 6,
|
||||||
inactiveColor: Colors.white24,
|
),
|
||||||
|
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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
@@ -241,12 +356,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(selectedPlayer.currentElapsedTime.toInt()),
|
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(currentTrack.duration!.inSeconds),
|
_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(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.shuffle,
|
Icons.shuffle,
|
||||||
color: _queue?.shuffle == true ? Colors.blue : Colors.white54,
|
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Previous
|
// Previous
|
||||||
AnimatedIconButton(
|
Hero(
|
||||||
icon: Icons.skip_previous_rounded,
|
tag: HeroTags.nowPlayingPreviousButton,
|
||||||
color: Colors.white,
|
child: Material(
|
||||||
iconSize: 42,
|
color: Colors.transparent,
|
||||||
onPressed: () async {
|
child: AnimatedIconButton(
|
||||||
try {
|
icon: Icons.skip_previous_rounded,
|
||||||
await maProvider.previousTrackSelectedPlayer();
|
color: textColor,
|
||||||
} catch (e) {
|
iconSize: 42,
|
||||||
print('❌ Error in previous track: $e');
|
onPressed: () async {
|
||||||
if (mounted) {
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await maProvider.previousTrackSelectedPlayer();
|
||||||
SnackBar(content: Text('Error: $e')),
|
} catch (e) {
|
||||||
);
|
print('❌ Error in previous track: $e');
|
||||||
}
|
if (mounted) {
|
||||||
}
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
},
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Play/Pause
|
// Play/Pause
|
||||||
_AnimatedPlayButton(
|
Hero(
|
||||||
isPlaying: selectedPlayer.isPlaying,
|
tag: HeroTags.nowPlayingPlayButton,
|
||||||
onPressed: () async {
|
child: Material(
|
||||||
try {
|
color: Colors.transparent,
|
||||||
await maProvider.playPauseSelectedPlayer();
|
child: _AnimatedPlayButton(
|
||||||
} catch (e) {
|
isPlaying: selectedPlayer.isPlaying,
|
||||||
print('❌ Error in play/pause: $e');
|
primaryColor: primaryColor,
|
||||||
if (mounted) {
|
backgroundColor: backgroundColor,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
onPressed: () async {
|
||||||
SnackBar(content: Text('Error: $e')),
|
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),
|
const SizedBox(width: 12),
|
||||||
// Next
|
// Next
|
||||||
AnimatedIconButton(
|
Hero(
|
||||||
icon: Icons.skip_next_rounded,
|
tag: HeroTags.nowPlayingNextButton,
|
||||||
color: Colors.white,
|
child: Material(
|
||||||
iconSize: 42,
|
color: Colors.transparent,
|
||||||
onPressed: () async {
|
child: AnimatedIconButton(
|
||||||
try {
|
icon: Icons.skip_next_rounded,
|
||||||
await maProvider.nextTrackSelectedPlayer();
|
color: textColor,
|
||||||
} catch (e) {
|
iconSize: 42,
|
||||||
print('❌ Error in next track: $e');
|
onPressed: () async {
|
||||||
if (mounted) {
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await maProvider.nextTrackSelectedPlayer();
|
||||||
SnackBar(content: Text('Error: $e')),
|
} catch (e) {
|
||||||
);
|
print('❌ Error in next track: $e');
|
||||||
}
|
if (mounted) {
|
||||||
}
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
},
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Repeat
|
// Repeat
|
||||||
@@ -335,8 +470,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
? Icons.repeat_one
|
? Icons.repeat_one
|
||||||
: Icons.repeat,
|
: Icons.repeat,
|
||||||
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
||||||
? Colors.blue
|
? primaryColor
|
||||||
: Colors.white54,
|
: textColor.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
||||||
@@ -352,10 +487,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(int seconds) {
|
String _formatDuration(int seconds) {
|
||||||
@@ -369,10 +507,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
/// Animated play/pause button with scale animation
|
/// Animated play/pause button with scale animation
|
||||||
class _AnimatedPlayButton extends StatefulWidget {
|
class _AnimatedPlayButton extends StatefulWidget {
|
||||||
final bool isPlaying;
|
final bool isPlaying;
|
||||||
|
final Color primaryColor;
|
||||||
|
final Color backgroundColor;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
const _AnimatedPlayButton({
|
const _AnimatedPlayButton({
|
||||||
required this.isPlaying,
|
required this.isPlaying,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.backgroundColor,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,15 +565,15 @@ class _AnimatedPlayButtonState extends State<_AnimatedPlayButton>
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: widget.primaryColor,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
),
|
),
|
||||||
color: const Color(0xFF1a1a1a),
|
color: widget.backgroundColor,
|
||||||
iconSize: 42,
|
iconSize: 42,
|
||||||
onPressed: _handleTap,
|
onPressed: _handleTap,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,23 +65,26 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: const Color(0xFF2a2a2a),
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: TextField(
|
title: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
cursorColor: colorScheme.primary,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search music...',
|
hintText: 'Search music...',
|
||||||
hintStyle: const TextStyle(color: Colors.white54),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.clear_rounded, color: Colors.white54),
|
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_performSearch('');
|
_performSearch('');
|
||||||
@@ -103,22 +106,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView() {
|
Widget _buildDisconnectedView() {
|
||||||
return const Center(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.cloud_off_rounded,
|
Icons.cloud_off_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: colorScheme.onBackground.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Not connected to Music Assistant',
|
'Not connected to Music Assistant',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: colorScheme.onBackground.withOpacity(0.7),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -130,9 +135,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchContent() {
|
Widget _buildSearchContent() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (_isSearching) {
|
if (_isSearching) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +151,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: colorScheme.onBackground.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Search for artists, albums, or tracks',
|
'Search for artists, albums, or tracks',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.5),
|
color: colorScheme.onBackground.withOpacity(0.5),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -173,13 +180,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.search_off_rounded,
|
Icons.search_off_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: colorScheme.onBackground.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No results found',
|
'No results found',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.5),
|
color: colorScheme.onBackground.withOpacity(0.5),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -217,8 +224,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$title ($count)',
|
'$title ($count)',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onBackground,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -229,28 +236,29 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
Widget _buildArtistTile(Artist artist) {
|
Widget _buildArtistTile(Artist artist) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: Colors.white12,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.person_rounded, color: Colors.white54)
|
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: const Text(
|
subtitle: Text(
|
||||||
'Artist',
|
'Artist',
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -266,13 +274,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
Widget _buildAlbumTile(Album album) {
|
Widget _buildAlbumTile(Album album) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -282,13 +291,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.album_rounded, color: Colors.white54)
|
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -296,7 +305,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
album.artistsString,
|
album.artistsString,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -316,13 +325,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
final imageUrl = track.album != null
|
final imageUrl = track.album != null
|
||||||
? maProvider.getImageUrl(track.album!, size: 128)
|
? maProvider.getImageUrl(track.album!, size: 128)
|
||||||
: null;
|
: null;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
@@ -332,13 +342,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? const Icon(Icons.music_note_rounded, color: Colors.white54)
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
track.name,
|
track.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -346,14 +356,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistsString,
|
track.artistsString,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _playTrack(track),
|
onTap: () => _playTrack(track),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../services/music_assistant_api.dart';
|
|||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
import 'debug_log_screen.dart';
|
import 'debug_log_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
@@ -64,7 +65,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and save port
|
|
||||||
final port = _portController.text.trim();
|
final port = _portController.text.trim();
|
||||||
if (port.isEmpty) {
|
if (port.isEmpty) {
|
||||||
_showError('Please enter a port number');
|
_showError('Please enter a port number');
|
||||||
@@ -84,7 +84,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Attempt authentication if username/password provided
|
|
||||||
if (_usernameController.text.trim().isNotEmpty &&
|
if (_usernameController.text.trim().isNotEmpty &&
|
||||||
_passwordController.text.trim().isNotEmpty) {
|
_passwordController.text.trim().isNotEmpty) {
|
||||||
_logger.log('🔐 Attempting login with credentials...');
|
_logger.log('🔐 Attempting login with credentials...');
|
||||||
@@ -96,7 +95,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
// Save credentials for future use
|
|
||||||
await SettingsService.setUsername(_usernameController.text.trim());
|
await SettingsService.setUsername(_usernameController.text.trim());
|
||||||
await SettingsService.setPassword(_passwordController.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>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
await provider.connectToServer(_serverUrlController.text);
|
await provider.connectToServer(_serverUrlController.text);
|
||||||
|
|
||||||
@@ -153,22 +150,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Settings',
|
'Settings',
|
||||||
style: TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -184,7 +182,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
tooltip: 'Debug Logs',
|
tooltip: 'Debug Logs',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -194,18 +192,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Connection status
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white12,
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_getStatusIcon(provider.connectionState),
|
_getStatusIcon(provider.connectionState),
|
||||||
color: _getStatusColor(provider.connectionState),
|
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -213,19 +210,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Connection Status',
|
'Connection Status',
|
||||||
style: TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
_getStatusText(provider.connectionState),
|
_getStatusText(provider.connectionState),
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: _getStatusColor(provider.connectionState),
|
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,40 +233,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Server URL input
|
Text(
|
||||||
const Text(
|
|
||||||
'Music Assistant Server',
|
'Music Assistant Server',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Enter your Music Assistant server URL or IP address',
|
'Enter your Music Assistant server URL or IP address',
|
||||||
style: TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _serverUrlController,
|
controller: _serverUrlController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
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,
|
filled: true,
|
||||||
fillColor: Colors.white12,
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.dns_rounded,
|
Icons.dns_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting,
|
||||||
@@ -279,41 +271,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Port input
|
Text(
|
||||||
const Text(
|
|
||||||
'Port',
|
'Port',
|
||||||
style: TextStyle(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onBackground,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Music Assistant WebSocket port (usually 8095)',
|
'Music Assistant WebSocket port (usually 8095)',
|
||||||
style: TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
fontSize: 12,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '8095',
|
hintText: '8095',
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white12,
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.settings_ethernet_rounded,
|
Icons.settings_ethernet_rounded,
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting,
|
||||||
@@ -321,27 +310,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Connect button
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _isConnecting ? null : _connect,
|
onPressed: _isConnecting ? null : _connect,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: Colors.white38,
|
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isConnecting
|
child: _isConnecting
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Color(0xFF1a1a1a),
|
color: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
@@ -371,8 +359,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.error,
|
||||||
side: const BorderSide(color: Colors.white38),
|
side: BorderSide(color: colorScheme.error.withOpacity(0.5)),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -390,7 +378,138 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -406,8 +525,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
icon: const Icon(Icons.bug_report_rounded),
|
icon: const Icon(Icons.bug_report_rounded),
|
||||||
label: const Text('View Debug Logs'),
|
label: const Text('View Debug Logs'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onBackground,
|
||||||
side: const BorderSide(color: Colors.white38),
|
side: BorderSide(color: colorScheme.outline),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -417,14 +536,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Info section
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.05),
|
color: colorScheme.surfaceVariant.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white.withOpacity(0.1),
|
color: colorScheme.outline.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -432,38 +550,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: Colors.white70,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Connection Info',
|
'Connection Info',
|
||||||
style: TextStyle(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
Text(
|
||||||
'• Default ports: 443 for HTTPS, 8095 for HTTP\n'
|
'• Default ports: 443 for HTTPS, 8095 for HTTP\n'
|
||||||
'• You can override the port in the WebSocket Port field\n'
|
'• You can override the port in the WebSocket Port field\n'
|
||||||
'• Use domain name or IP address for server\n'
|
'• Use domain name or IP address for server\n'
|
||||||
'• Make sure your device can reach the server\n'
|
'• Make sure your device can reach the server\n'
|
||||||
'• Check debug logs if connection fails',
|
'• Check debug logs if connection fails',
|
||||||
style: TextStyle(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white54,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 12,
|
|
||||||
height: 1.5,
|
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) {
|
switch (state) {
|
||||||
case MAConnectionState.connected:
|
case MAConnectionState.connected:
|
||||||
return Colors.green;
|
return Colors.green;
|
||||||
case MAConnectionState.connecting:
|
case MAConnectionState.connecting:
|
||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case MAConnectionState.error:
|
case MAConnectionState.error:
|
||||||
return Colors.red;
|
return colorScheme.error;
|
||||||
case MAConnectionState.disconnected:
|
case MAConnectionState.disconnected:
|
||||||
return Colors.white54;
|
return colorScheme.onSurface.withOpacity(0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ class MusicAssistantAPI {
|
|||||||
useSecure = wsUrl.startsWith('wss://');
|
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
|
// Construct WebSocket URL with proper port handling
|
||||||
final uri = Uri.parse(wsUrl);
|
final uri = Uri.parse(wsUrl);
|
||||||
|
|
||||||
@@ -85,6 +95,7 @@ class MusicAssistantAPI {
|
|||||||
host: uri.host,
|
host: uri.host,
|
||||||
port: _cachedCustomPort,
|
port: _cachedCustomPort,
|
||||||
path: '/ws',
|
path: '/ws',
|
||||||
|
queryParameters: {'client_id': clientId},
|
||||||
);
|
);
|
||||||
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
||||||
} else if (uri.hasPort) {
|
} else if (uri.hasPort) {
|
||||||
@@ -94,6 +105,7 @@ class MusicAssistantAPI {
|
|||||||
host: uri.host,
|
host: uri.host,
|
||||||
port: uri.port,
|
port: uri.port,
|
||||||
path: '/ws',
|
path: '/ws',
|
||||||
|
queryParameters: {'client_id': clientId},
|
||||||
);
|
);
|
||||||
_logger.log('Using port from URL: ${uri.port}');
|
_logger.log('Using port from URL: ${uri.port}');
|
||||||
} else {
|
} else {
|
||||||
@@ -105,6 +117,7 @@ class MusicAssistantAPI {
|
|||||||
host: uri.host,
|
host: uri.host,
|
||||||
port: 8095,
|
port: 8095,
|
||||||
path: '/ws',
|
path: '/ws',
|
||||||
|
queryParameters: {'client_id': clientId},
|
||||||
);
|
);
|
||||||
_logger.log('Using port 8095 for unsecure connection');
|
_logger.log('Using port 8095 for unsecure connection');
|
||||||
} else {
|
} else {
|
||||||
@@ -115,6 +128,7 @@ class MusicAssistantAPI {
|
|||||||
host: uri.host,
|
host: uri.host,
|
||||||
// NO PORT - let it use default 443 implicitly
|
// NO PORT - let it use default 443 implicitly
|
||||||
path: '/ws',
|
path: '/ws',
|
||||||
|
queryParameters: {'client_id': clientId},
|
||||||
);
|
);
|
||||||
_logger.log('Using implicit default port (443) for secure connection');
|
_logger.log('Using implicit default port (443) for secure connection');
|
||||||
}
|
}
|
||||||
@@ -327,7 +341,7 @@ class MusicAssistantAPI {
|
|||||||
if (offset != null) 'offset': offset,
|
if (offset != null) 'offset': offset,
|
||||||
if (search != null) 'search': search,
|
if (search != null) 'search': search,
|
||||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
if (artistId != null) 'artist': artistId,
|
if (artistId != null) 'artist_id': artistId,
|
||||||
};
|
};
|
||||||
_logger.log('Albums API args: $args');
|
_logger.log('Albums API args: $args');
|
||||||
|
|
||||||
@@ -393,12 +407,12 @@ class MusicAssistantAPI {
|
|||||||
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('Fetching recently played albums (limit=$limit)');
|
_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(
|
final response = await _sendCommand(
|
||||||
'music/albums/library_items',
|
'music/albums/library_items',
|
||||||
args: {
|
args: {
|
||||||
'limit': limit,
|
'limit': 2000, // Increase limit to capture most of the library
|
||||||
'sort': 'timestamp_played', // Sort by when last played
|
|
||||||
'order': 'desc', // Most recent first
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -408,11 +422,24 @@ class MusicAssistantAPI {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('✅ Got ${items.length} recent albums');
|
// Sort client-side by timestamp_played or last_played
|
||||||
if (items.isNotEmpty) {
|
items.sort((a, b) {
|
||||||
_logger.log(' First album: ${items[0]['name']} by ${items[0]['artists']?[0]?['name'] ?? 'Unknown'}');
|
// 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>))
|
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1000,6 +1027,16 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Player control commands
|
// 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 {
|
Future<void> pausePlayer(String playerId) async {
|
||||||
await _sendQueueCommand(playerId, 'pause');
|
await _sendQueueCommand(playerId, 'pause');
|
||||||
}
|
}
|
||||||
@@ -1025,7 +1062,7 @@ class MusicAssistantAPI {
|
|||||||
try {
|
try {
|
||||||
_logger.log('Setting volume to $volumeLevel for player $playerId');
|
_logger.log('Setting volume to $volumeLevel for player $playerId');
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_command/volume_set',
|
'players/cmd/volume_set',
|
||||||
args: {
|
args: {
|
||||||
'player_id': playerId,
|
'player_id': playerId,
|
||||||
'volume_level': volumeLevel.clamp(0, 100),
|
'volume_level': volumeLevel.clamp(0, 100),
|
||||||
@@ -1042,7 +1079,7 @@ class MusicAssistantAPI {
|
|||||||
try {
|
try {
|
||||||
_logger.log('${muted ? "Muting" : "Unmuting"} player $playerId');
|
_logger.log('${muted ? "Muting" : "Unmuting"} player $playerId');
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_command/volume_mute',
|
'players/cmd/volume_mute',
|
||||||
args: {
|
args: {
|
||||||
'player_id': playerId,
|
'player_id': playerId,
|
||||||
'muted': muted,
|
'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 {
|
Future<void> _sendQueueCommand(String queueId, String command) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('🎮 Sending queue command: $command to queue $queueId');
|
_logger.log('🎮 Sending queue command: $command to queue $queueId');
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ class SettingsService {
|
|||||||
static const String _keyUsername = 'username';
|
static const String _keyUsername = 'username';
|
||||||
static const String _keyPassword = 'password';
|
static const String _keyPassword = 'password';
|
||||||
static const String _keyBuiltinPlayerId = 'builtin_player_id';
|
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 {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -111,6 +115,47 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyBuiltinPlayerId, id);
|
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 {
|
static Future<void> clearSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
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 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/album_details_screen.dart';
|
import 'album_card.dart';
|
||||||
import '../constants/hero_tags.dart';
|
|
||||||
|
|
||||||
class AlbumRow extends StatefulWidget {
|
class AlbumRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -30,6 +29,9 @@ class _AlbumRowState extends State<AlbumRow> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -37,14 +39,14 @@ class _AlbumRowState extends State<AlbumRow> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title,
|
widget.title,
|
||||||
style: const TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 210, // Slightly increased for text
|
||||||
child: FutureBuilder<List<Album>>(
|
child: FutureBuilder<List<Album>>(
|
||||||
future: _albumsFuture,
|
future: _albumsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -60,18 +62,26 @@ class _AlbumRowState extends State<AlbumRow> {
|
|||||||
|
|
||||||
final albums = snapshot.data ?? [];
|
final albums = snapshot.data ?? [];
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('No albums found'),
|
child: Text(
|
||||||
|
'No albums found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = albums[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 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/artist_details_screen.dart';
|
import 'artist_card.dart';
|
||||||
import '../constants/hero_tags.dart';
|
|
||||||
|
|
||||||
class ArtistRow extends StatefulWidget {
|
class ArtistRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -30,6 +29,9 @@ class _ArtistRowState extends State<ArtistRow> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -37,9 +39,9 @@ class _ArtistRowState extends State<ArtistRow> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title,
|
widget.title,
|
||||||
style: const TextStyle(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -60,18 +62,26 @@ class _ArtistRowState extends State<ArtistRow> {
|
|||||||
|
|
||||||
final artists = snapshot.data ?? [];
|
final artists = snapshot.data ?? [];
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('No artists found'),
|
child: Text(
|
||||||
|
'No artists found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
itemCount: artists.length,
|
itemCount: artists.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final artist = artists[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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
|
||||||
final currentTrack = maProvider.currentTrack;
|
|
||||||
|
|
||||||
// Don't show mini player if no track is playing or no player selected
|
return Consumer<MusicAssistantProvider>(
|
||||||
if (currentTrack == null || selectedPlayer == null) {
|
builder: (context, maProvider, child) {
|
||||||
return const SizedBox.shrink();
|
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(
|
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
return GestureDetector(
|
||||||
context,
|
onTap: () {
|
||||||
MaterialPageRoute(
|
Navigator.push(
|
||||||
builder: (context) => const NowPlayingScreen(),
|
context,
|
||||||
),
|
MaterialPageRoute(
|
||||||
);
|
builder: (context) => const NowPlayingScreen(),
|
||||||
},
|
),
|
||||||
child: Container(
|
);
|
||||||
height: 64,
|
},
|
||||||
decoration: BoxDecoration(
|
child: Hero(
|
||||||
color: const Color(0xFF2a2a2a),
|
tag: HeroTags.nowPlayingBackground,
|
||||||
boxShadow: [
|
child: Material(
|
||||||
BoxShadow(
|
color: colorScheme.surface,
|
||||||
color: Colors.black.withOpacity(0.3),
|
elevation: 0,
|
||||||
blurRadius: 8,
|
child: Container(
|
||||||
offset: const Offset(0, -2),
|
height: 64,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
],
|
boxShadow: [
|
||||||
),
|
BoxShadow(
|
||||||
child: Column(
|
color: Colors.black.withOpacity(0.3),
|
||||||
children: [
|
blurRadius: 8,
|
||||||
// Status indicator bar
|
offset: const Offset(0, -2),
|
||||||
Container(
|
),
|
||||||
height: 2,
|
],
|
||||||
color: selectedPlayer.isPlaying
|
),
|
||||||
? Colors.white.withOpacity(0.3)
|
child: Column(
|
||||||
: Colors.white.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
// Player content
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
// Album art with Hero animation
|
// Status indicator bar
|
||||||
Hero(
|
Container(
|
||||||
tag: 'now_playing_art',
|
height: 2,
|
||||||
child: ClipRRect(
|
color: selectedPlayer.isPlaying
|
||||||
borderRadius: BorderRadius.circular(8),
|
? colorScheme.primary.withOpacity(0.7)
|
||||||
child: imageUrl != null
|
: colorScheme.onSurface.withOpacity(0.1),
|
||||||
? Image.network(
|
),
|
||||||
imageUrl,
|
// 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,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
child: ClipRRect(
|
||||||
errorBuilder: (context, error, stackTrace) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
return Container(
|
child: imageUrl != null
|
||||||
width: 48,
|
? Image.network(
|
||||||
height: 48,
|
imageUrl,
|
||||||
color: Colors.white12,
|
fit: BoxFit.cover,
|
||||||
child: const Icon(
|
cacheWidth: 96,
|
||||||
Icons.music_note_rounded,
|
cacheHeight: 96,
|
||||||
color: Colors.white54,
|
filterQuality: FilterQuality.medium,
|
||||||
size: 24,
|
errorBuilder: (context, error, stackTrace) {
|
||||||
),
|
return Container(
|
||||||
);
|
color: colorScheme.surfaceVariant,
|
||||||
},
|
child: Icon(
|
||||||
)
|
Icons.music_note_rounded,
|
||||||
: Container(
|
color: colorScheme.onSurfaceVariant,
|
||||||
width: 48,
|
size: 24,
|
||||||
height: 48,
|
),
|
||||||
color: Colors.white12,
|
);
|
||||||
child: const Icon(
|
},
|
||||||
Icons.music_note_rounded,
|
)
|
||||||
color: Colors.white54,
|
: Container(
|
||||||
size: 24,
|
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,
|
const SizedBox(width: 12),
|
||||||
overflow: TextOverflow.ellipsis,
|
// Track info
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(height: 2),
|
child: Column(
|
||||||
Text(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
currentTrack.artistsString,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(
|
children: [
|
||||||
color: Colors.white70,
|
Text(
|
||||||
fontSize: 12,
|
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,
|
// Queue button
|
||||||
overflow: TextOverflow.ellipsis,
|
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 maProvider = context.watch<MusicAssistantProvider>();
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
final availablePlayers = maProvider.availablePlayers;
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Stack(
|
icon: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.speaker_group_rounded),
|
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
|
||||||
if (selectedPlayer != null)
|
if (selectedPlayer != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -23,9 +24,9 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green,
|
color: Colors.green, // Keep green for "active" status or use primary
|
||||||
shape: BoxShape.circle,
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: const Color(0xFF2a2a2a),
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
isScrollControlled: true,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
isDismissible: true, // Allow tapping outside to close
|
||||||
),
|
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return Column(
|
// Use Consumer to listen for updates while sheet is open
|
||||||
mainAxisSize: MainAxisSize.min,
|
return Consumer<MusicAssistantProvider>(
|
||||||
children: [
|
builder: (context, maProvider, child) {
|
||||||
const SizedBox(height: 12),
|
// Use the fresh list of players from the provider
|
||||||
Container(
|
final currentPlayers = maProvider.availablePlayers;
|
||||||
width: 40,
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
height: 4,
|
final textTheme = Theme.of(context).textTheme;
|
||||||
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;
|
|
||||||
|
|
||||||
return ListTile(
|
return DraggableScrollableSheet(
|
||||||
leading: Icon(
|
initialChildSize: 0.7,
|
||||||
_getPlayerIcon(player.name),
|
minChildSize: 0.4,
|
||||||
color: player.available ? Colors.white : Colors.white38,
|
maxChildSize: 0.9,
|
||||||
),
|
builder: (context, scrollController) {
|
||||||
title: Text(
|
return Container(
|
||||||
player.name,
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: colorScheme.surface,
|
||||||
color: player.available ? Colors.white : Colors.white38,
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
subtitle: Text(
|
Padding(
|
||||||
player.available
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
? (player.isPlaying ? 'Playing' : 'Available')
|
child: Row(
|
||||||
: 'Unavailable',
|
children: [
|
||||||
style: TextStyle(
|
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
|
||||||
color: player.isPlaying
|
const SizedBox(width: 12),
|
||||||
? Colors.greenAccent
|
Text(
|
||||||
: (player.available ? Colors.white54 : Colors.white24),
|
'Select Player',
|
||||||
fontSize: 12,
|
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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
trailing: isSelected
|
Expanded(
|
||||||
? const Icon(Icons.check_circle_rounded, color: Colors.white)
|
child: currentPlayers.isEmpty
|
||||||
: null,
|
? Center(
|
||||||
enabled: player.available,
|
child: Text(
|
||||||
onTap: () {
|
'No players available',
|
||||||
provider.selectPlayer(player);
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.54)),
|
||||||
Navigator.pop(context);
|
),
|
||||||
},
|
)
|
||||||
);
|
: GridView.builder(
|
||||||
},
|
controller: scrollController,
|
||||||
),
|
padding: const EdgeInsets.all(16),
|
||||||
const SizedBox(height: 20),
|
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(
|
child: Slider(
|
||||||
value: currentVolume.clamp(0.0, 1.0),
|
value: currentVolume.clamp(0.0, 1.0),
|
||||||
onChanged: isMuted ? null : (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingVolume = value * 100;
|
_pendingVolume = value * 100;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) async {
|
onChangeEnd: (value) async {
|
||||||
final volumeLevel = (value * 100).round();
|
final volumeLevel = (value * 100).round();
|
||||||
|
print('🔊 Volume slider released at $volumeLevel');
|
||||||
try {
|
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);
|
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) {
|
} catch (e) {
|
||||||
// Error already logged by provider
|
print('❌ Error setting volume: $e');
|
||||||
} finally {
|
} finally {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_pendingVolume = null;
|
setState(() {
|
||||||
});
|
_pendingVolume = null;
|
||||||
|
});
|
||||||
|
print('🔊 Cleared pending volume');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ dependencies:
|
|||||||
# UI
|
# UI
|
||||||
cupertino_icons: ^1.0.6
|
cupertino_icons: ^1.0.6
|
||||||
google_fonts: ^6.1.0
|
google_fonts: ^6.1.0
|
||||||
|
dynamic_color: 1.6.8
|
||||||
# Audio playback
|
palette_generator: ^0.3.3+3
|
||||||
just_audio: ^0.9.36
|
|
||||||
audio_service: ^0.18.12
|
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
@@ -28,13 +26,7 @@ dependencies:
|
|||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
|
|
||||||
# File handling
|
|
||||||
file_picker: ^6.1.1
|
|
||||||
path_provider: ^2.1.1
|
|
||||||
permission_handler: ^11.1.0
|
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
rxdart: ^0.27.7
|
|
||||||
uuid: ^4.2.1
|
uuid: ^4.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@@ -49,7 +41,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|
||||||
flutter_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
ios: false
|
ios: false
|
||||||
image_path: "assets/images/icon.png"
|
image_path: "assets/images/icon.png"
|
||||||
|
|||||||