Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69030d8ed7 | ||
|
|
a08ea90ef2 | ||
|
|
daed4c6fcb | ||
|
|
5bd4a61956 | ||
|
|
e34a89e990 | ||
|
|
76114fe6d2 | ||
|
|
1c7844af0f | ||
|
|
50841b7d86 | ||
|
|
9793da1a00 | ||
|
|
8f1a8634d1 | ||
|
|
688f4c82f0 | ||
|
|
efa1b79606 | ||
|
|
e4c68aa9f9 | ||
|
|
10aae85b33 | ||
|
|
eadbb2023c | ||
|
|
cd37b74aea | ||
|
|
215c05fd7e | ||
|
|
2a2a762cce | ||
|
|
918aa8a032 | ||
|
|
542e80e52d |
@@ -42,6 +42,22 @@ jobs:
|
|||||||
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=Amass, 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,8 +1,8 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
|
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
|
||||||
<h1>Amass</h1>
|
<h1>Amass Your Music</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>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -13,31 +13,39 @@
|
|||||||
- **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
|
- Clean, minimalistic dark UI design
|
||||||
- Full audio playback controls (play/pause/skip/seek)
|
- Full playback controls (play/pause/skip/seek) for server players
|
||||||
- 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
|
||||||
|
|
||||||
|
## 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/Amass/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 +55,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/)
|
||||||
|
|
||||||
@@ -97,10 +105,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 88 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -335,8 +340,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||||
try {
|
try {
|
||||||
await _api?.setVolume(playerId, volumeLevel);
|
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 +359,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 +426,41 @@ 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();
|
||||||
|
|
||||||
// Filter out leftover "Music Assistant Mobile" players from old app builds
|
// Filter out leftover "Music Assistant Mobile" players from old app builds
|
||||||
// These were registered during development but are no longer used
|
// These were registered during development but are no longer used
|
||||||
// Keep "this device" (web UI player) and all other legitimate players
|
// Keep "this device" (web UI player) and all other legitimate players
|
||||||
|
int filteredCount = 0;
|
||||||
|
final List<String> ghostPlayerIds = [];
|
||||||
|
|
||||||
_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
|
// Exclude players that are exactly "music assistant mobile" or similar
|
||||||
// but keep "this device" and other players
|
// but keep "this device" and other players
|
||||||
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} (available: ${player.available})');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
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 "Music Assistant Mobile" 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 +507,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 +532,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 +546,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 +572,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 +585,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 +646,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -62,7 +87,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
|
||||||
@@ -261,33 +286,6 @@ 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),
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -46,9 +44,13 @@ 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,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: [
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -86,11 +87,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
return Consumer<MusicAssistantProvider>(
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
builder: (context, maProvider, child) {
|
||||||
final currentTrack = maProvider.currentTrack;
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
if (currentTrack == null || selectedPlayer == null) {
|
if (currentTrack == null || selectedPlayer == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -115,76 +117,85 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
|
|
||||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
||||||
|
|
||||||
return Scaffold(
|
return Hero(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
tag: HeroTags.nowPlayingBackground,
|
||||||
appBar: AppBar(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
elevation: 0,
|
appBar: AppBar(
|
||||||
title: Text(
|
backgroundColor: Colors.transparent,
|
||||||
selectedPlayer.name,
|
elevation: 0,
|
||||||
style: const TextStyle(
|
title: Text(
|
||||||
color: Colors.white70,
|
selectedPlayer.name,
|
||||||
fontSize: 14,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w300,
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.queue_music),
|
||||||
|
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),
|
||||||
|
|
||||||
@@ -228,12 +239,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: Colors.white,
|
||||||
|
inactiveColor: Colors.white24,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
@@ -241,7 +297,7 @@ 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: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -274,58 +330,76 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
),
|
),
|
||||||
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: Colors.white,
|
||||||
} 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');
|
onPressed: () async {
|
||||||
if (mounted) {
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await maProvider.playPauseSelectedPlayer();
|
||||||
SnackBar(content: Text('Error: $e')),
|
} 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: Colors.white,
|
||||||
} 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
|
||||||
@@ -352,10 +426,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(int seconds) {
|
String _formatDuration(int seconds) {
|
||||||
|
|||||||
@@ -1025,7 +1025,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 +1042,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 +1085,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');
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ class _AlbumRowState extends State<AlbumRow> {
|
|||||||
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 RepaintBoundary(
|
||||||
|
child: _AlbumCard(album: album),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -120,6 +123,9 @@ class _AlbumCard extends StatelessWidget {
|
|||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: 300,
|
||||||
|
cacheHeight: 300,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 150,
|
width: 150,
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ class _ArtistRowState extends State<ArtistRow> {
|
|||||||
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 RepaintBoundary(
|
||||||
|
child: _ArtistCard(artist: artist),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,196 +12,224 @@ class MiniPlayer extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
return Consumer<MusicAssistantProvider>(
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
builder: (context, maProvider, child) {
|
||||||
final currentTrack = maProvider.currentTrack;
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
// Don't show mini player if no track is playing or no player selected
|
// Don't show mini player if no track is playing or no player selected
|
||||||
if (currentTrack == null || selectedPlayer == null) {
|
if (currentTrack == null || selectedPlayer == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const NowPlayingScreen(),
|
builder: (context) => const NowPlayingScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Hero(
|
||||||
height: 64,
|
tag: HeroTags.nowPlayingBackground,
|
||||||
decoration: BoxDecoration(
|
child: Material(
|
||||||
color: const Color(0xFF2a2a2a),
|
color: const Color(0xFF2a2a2a),
|
||||||
boxShadow: [
|
elevation: 0,
|
||||||
BoxShadow(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.3),
|
height: 64,
|
||||||
blurRadius: 8,
|
decoration: BoxDecoration(
|
||||||
offset: const Offset(0, -2),
|
boxShadow: [
|
||||||
),
|
BoxShadow(
|
||||||
],
|
color: Colors.black.withOpacity(0.3),
|
||||||
),
|
blurRadius: 8,
|
||||||
child: Column(
|
offset: const Offset(0, -2),
|
||||||
children: [
|
),
|
||||||
// Status indicator bar
|
],
|
||||||
Container(
|
),
|
||||||
height: 2,
|
child: Column(
|
||||||
color: selectedPlayer.isPlaying
|
|
||||||
? Colors.white.withOpacity(0.3)
|
|
||||||
: Colors.white.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
// Player content
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
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),
|
? Colors.white.withOpacity(0.3)
|
||||||
child: imageUrl != null
|
: Colors.white.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: Colors.white12,
|
||||||
},
|
child: const Icon(
|
||||||
)
|
Icons.music_note_rounded,
|
||||||
: Container(
|
color: Colors.white54,
|
||||||
width: 48,
|
size: 24,
|
||||||
height: 48,
|
),
|
||||||
color: Colors.white12,
|
);
|
||||||
child: const Icon(
|
},
|
||||||
Icons.music_note_rounded,
|
)
|
||||||
color: Colors.white54,
|
: Container(
|
||||||
size: 24,
|
color: Colors.white12,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
color: Colors.white54,
|
||||||
|
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: [
|
||||||
|
Text(
|
||||||
|
currentTrack.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
currentTrack.artistsString,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Queue button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.queue_music),
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
fontSize: 12,
|
iconSize: 22,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const QueueScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
// Volume control (compact mute button)
|
||||||
overflow: TextOverflow.ellipsis,
|
const VolumeControl(compact: true),
|
||||||
),
|
// Playback controls for selected player
|
||||||
],
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingPreviousButton,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
iconSize: 26,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.previousTrackSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in previous track: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingPlayButton,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: selectedPlayer.isPlaying
|
||||||
|
? Icons.pause_rounded
|
||||||
|
: Icons.play_arrow_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
iconSize: 32,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.playPauseSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in play/pause: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.nowPlayingNextButton,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: AnimatedIconButton(
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
iconSize: 28,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await maProvider.nextTrackSelectedPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error in next track: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 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')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.6
|
cupertino_icons: ^1.0.6
|
||||||
google_fonts: ^6.1.0
|
google_fonts: ^6.1.0
|
||||||
|
|
||||||
# Audio playback
|
|
||||||
just_audio: ^0.9.36
|
|
||||||
audio_service: ^0.18.12
|
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
|
|
||||||
@@ -28,13 +24,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 +39,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"
|
||||||
|
|||||||