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
|
||||
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
|
||||
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">
|
||||
<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>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>
|
||||
|
||||
---
|
||||
@@ -13,31 +13,39 @@
|
||||
- **Server Connection** - Connect to your Music Assistant server via WebSocket
|
||||
- **Library Browsing** - Browse artists, albums, and tracks from your server
|
||||
- **Album Details** - View album information and track listings
|
||||
- **Music Streaming** - Stream audio directly from your Music Assistant server
|
||||
- **Playback Control** - Control playback on your Music Assistant server players
|
||||
- **Auto-Reconnect** - Automatic reconnection with connection status monitoring
|
||||
- **Settings Management** - Configure server URL with persistent storage
|
||||
|
||||
### Player Features
|
||||
### Control Features
|
||||
- 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
|
||||
- Volume control slider
|
||||
- Volume control for server players
|
||||
- Now playing display with track information
|
||||
- Background audio playback support
|
||||
- Queue management
|
||||
- Queue management and viewing
|
||||
- Multi-player support
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
|
||||
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Amass/actions) - look for the "amass-apk" artifact in successful builds.
|
||||
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Amass/releases).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Launch the app
|
||||
2. Navigate to the **Library** tab
|
||||
3. Tap **Configure Server** or go to **Settings**
|
||||
4. Enter your Music Assistant server URL (e.g., `music.serverscloud.org` or `192.168.1.100`)
|
||||
4. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
||||
5. Tap **Connect**
|
||||
6. Browse your library and start playing music!
|
||||
6. Browse your library and control playback on your Music Assistant players!
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +55,7 @@ Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Ama
|
||||
|
||||
## About Music Assistant
|
||||
|
||||
Amass is a client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app.
|
||||
Amass Your Music is a remote control client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app. Music playback happens on your Music Assistant server's configured players (speakers, smart devices, etc.), not on your mobile device.
|
||||
|
||||
Learn more: [music-assistant.io](https://music-assistant.io/)
|
||||
|
||||
@@ -97,10 +105,9 @@ The APK will be available at `build/app/outputs/flutter-apk/app-release.apk`
|
||||
<summary>Technologies Used</summary>
|
||||
|
||||
- **Flutter** - Cross-platform mobile framework
|
||||
- **just_audio** - Audio playback
|
||||
- **audio_service** - Background audio support
|
||||
- **web_socket_channel** - WebSocket communication
|
||||
- **web_socket_channel** - WebSocket communication with Music Assistant server
|
||||
- **provider** - State management
|
||||
- **shared_preferences** - Local settings storage
|
||||
- **http** - HTTP requests for API communication
|
||||
|
||||
</details>
|
||||
|
||||
@@ -22,6 +22,12 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
@@ -40,6 +46,17 @@ android {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
@@ -50,7 +67,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
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
|
||||
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)
|
||||
double get currentElapsedTime {
|
||||
if (elapsedTime == null || elapsedTimeLastUpdated == null) return 0;
|
||||
if (elapsedTime == null) {
|
||||
print('⚠️ elapsedTime is null, returning 0');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isPlaying) return elapsedTime!;
|
||||
if (!isPlaying || elapsedTimeLastUpdated == null) {
|
||||
print('🕐 Not playing or no last updated time, returning base: $elapsedTime');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
// If playing, interpolate based on time since last update
|
||||
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
||||
final timeSinceUpdate = now - elapsedTimeLastUpdated!;
|
||||
return elapsedTime! + timeSinceUpdate;
|
||||
|
||||
print('🕐 Time calculation:');
|
||||
print(' Base elapsed: $elapsedTime seconds');
|
||||
print(' Last updated: $elapsedTimeLastUpdated (Unix timestamp)');
|
||||
print(' Now: $now (Unix timestamp)');
|
||||
print(' Delta: $timeSinceUpdate seconds');
|
||||
|
||||
// Safety check: if time since update is negative or too large, just return elapsed time
|
||||
if (timeSinceUpdate < 0) {
|
||||
print('⚠️ Negative time delta! Clock skew detected.');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
if (timeSinceUpdate > 30) {
|
||||
print('⚠️ Time delta too large (${timeSinceUpdate}s), using base elapsed time');
|
||||
return elapsedTime!;
|
||||
}
|
||||
|
||||
final calculatedTime = elapsedTime! + timeSinceUpdate;
|
||||
print(' Result: $calculatedTime seconds');
|
||||
return calculatedTime;
|
||||
}
|
||||
|
||||
factory Player.fromJson(Map<String, dynamic> json) {
|
||||
@@ -52,21 +78,24 @@ class Player {
|
||||
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
||||
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
|
||||
if (elapsedTime != null) {
|
||||
print('✅ Player ${json['name']}: Using top-level elapsed_time: $elapsedTime seconds (updated: $elapsedTimeLastUpdated)');
|
||||
}
|
||||
|
||||
// Extract current_item_id from current_media
|
||||
if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
||||
|
||||
// Only use current_media elapsed_time if we don't have top-level (fallback)
|
||||
if (elapsedTime == null) {
|
||||
elapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||
elapsedTimeLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
print('⚠️ Player ${json['name']}: Falling back to current_media elapsed_time: $elapsedTime seconds');
|
||||
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
||||
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
||||
|
||||
// Use current_media elapsed_time if:
|
||||
// 1. We don't have top-level elapsed_time, OR
|
||||
// 2. Top-level is 0 but current_media has a real value
|
||||
if (elapsedTime == null || (elapsedTime == 0 && currentMediaElapsedTime != null && currentMediaElapsedTime > 0)) {
|
||||
print('⚠️ Player ${json['name']}: Using current_media elapsed_time: $currentMediaElapsedTime (top-level was: $elapsedTime)');
|
||||
elapsedTime = currentMediaElapsedTime;
|
||||
elapsedTimeLastUpdated = currentMediaLastUpdated;
|
||||
} else if (elapsedTime != null) {
|
||||
print('✅ Player ${json['name']}: Using top-level elapsed_time: $elapsedTime seconds (updated: $elapsedTimeLastUpdated)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
|
||||
// Debug: Get ALL players including filtered ones
|
||||
Future<List<Player>> getAllPlayersUnfiltered() async {
|
||||
return await getPlayers();
|
||||
}
|
||||
|
||||
// API access
|
||||
MusicAssistantAPI? get api => _api;
|
||||
|
||||
@@ -335,8 +340,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
try {
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
// Refresh player state to get updated volume
|
||||
await refreshPlayers();
|
||||
// Don't refresh immediately - let automatic polling pick up the change
|
||||
// This prevents the slider from snapping back before the server updates
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set volume', e);
|
||||
rethrow;
|
||||
@@ -354,6 +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 {
|
||||
try {
|
||||
await _api?.toggleShuffle(queueId);
|
||||
@@ -411,33 +426,41 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_playersLastFetched != null &&
|
||||
_availablePlayers.isNotEmpty &&
|
||||
now.difference(_playersLastFetched!) < _playersCacheDuration) {
|
||||
_logger.log('Using cached player list (${_availablePlayers.length} players)');
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('Fetching fresh player list...');
|
||||
final allPlayers = await getPlayers();
|
||||
|
||||
// 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
|
||||
int filteredCount = 0;
|
||||
final List<String> ghostPlayerIds = [];
|
||||
|
||||
_availablePlayers = allPlayers.where((player) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
// Exclude players that are exactly "music assistant mobile" or similar
|
||||
// but keep "this device" and other players
|
||||
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 true;
|
||||
}).toList();
|
||||
|
||||
_playersLastFetched = DateTime.now();
|
||||
_logger.log('Loaded ${_availablePlayers.length} players (filtered from ${allPlayers.length} total)');
|
||||
|
||||
// Log each player for debugging
|
||||
for (final player in _availablePlayers) {
|
||||
_logger.log(' - ${player.name} (${player.playerId}) - available: ${player.available}, state: ${player.state}');
|
||||
// Log details about ghost players
|
||||
if (filteredCount > 0) {
|
||||
_logger.log('🧹 Filtered out $filteredCount ghost "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) {
|
||||
@@ -484,21 +507,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('⚠️ No players available');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
// Don't call notifyListeners here - selectPlayer already does it
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a player for playback
|
||||
void selectPlayer(Player player) {
|
||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||
_selectedPlayer = player;
|
||||
_logger.log('Selected player: ${player.name} (${player.playerId})');
|
||||
|
||||
// Start polling for player state
|
||||
_startPlayerStatePolling();
|
||||
|
||||
notifyListeners();
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start polling for selected player's current state
|
||||
@@ -507,8 +532,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
// Poll every 2 seconds
|
||||
_playerStateTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
// Poll every 5 seconds for better performance
|
||||
_playerStateTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
_updatePlayerState();
|
||||
});
|
||||
|
||||
@@ -521,8 +546,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
// Refresh players list to get latest player state
|
||||
await refreshPlayers();
|
||||
bool stateChanged = false;
|
||||
|
||||
// Fetch only the selected player's state instead of all players
|
||||
final allPlayers = await getPlayers();
|
||||
final updatedPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
// Check if player state actually changed
|
||||
if (updatedPlayer.state != _selectedPlayer!.state ||
|
||||
updatedPlayer.volumeLevel != _selectedPlayer!.volumeLevel ||
|
||||
updatedPlayer.volumeMuted != _selectedPlayer!.volumeMuted ||
|
||||
updatedPlayer.available != _selectedPlayer!.available) {
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
// Only show tracks if player is available and not idle
|
||||
final shouldShowTrack = _selectedPlayer!.available &&
|
||||
@@ -532,7 +572,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Clear track if player is unavailable or idle
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
_logger.log('🗑️ Cleared track - player unavailable or idle (state: ${_selectedPlayer!.state}, available: ${_selectedPlayer!.available})');
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
@@ -542,23 +585,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
_logger.log('✅ Current track updated: ${_currentTrack!.name}');
|
||||
notifyListeners();
|
||||
} else {
|
||||
if (queue == null) {
|
||||
_logger.log('⚠️ Queue is null for player ${_selectedPlayer!.playerId}');
|
||||
} else if (queue.currentItem == null) {
|
||||
_logger.log('⚠️ Queue currentItem is null (currentIndex: ${queue.currentIndex}, items: ${queue.items.length})');
|
||||
// Only update if track actually changed
|
||||
if (_currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
// Clear current track if queue is empty
|
||||
_currentTrack = null;
|
||||
_logger.log('🗑️ Cleared current track');
|
||||
notifyListeners();
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify if something actually changed
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error updating player state: $e');
|
||||
}
|
||||
@@ -601,21 +646,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
/// Refresh the list of available players
|
||||
Future<void> refreshPlayers() async {
|
||||
final previousState = _selectedPlayer?.state;
|
||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||
|
||||
await _loadAndSelectPlayers(forceRefresh: true);
|
||||
|
||||
// Update the selected player with fresh data
|
||||
bool stateChanged = false;
|
||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||
try {
|
||||
final updatedPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
|
||||
// Check if state actually changed
|
||||
if (updatedPlayer.state != previousState ||
|
||||
updatedPlayer.volumeLevel != previousVolume) {
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
_logger.log('Updated selected player state: ${updatedPlayer.name} - ${updatedPlayer.state}');
|
||||
} catch (e) {
|
||||
// Selected player no longer available
|
||||
_logger.log('Selected player no longer in list');
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify if state changed
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/player.dart';
|
||||
|
||||
class DebugLogScreen extends StatefulWidget {
|
||||
const DebugLogScreen({super.key});
|
||||
@@ -53,6 +56,95 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAllPlayers() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final allPlayers = await maProvider.getAllPlayersUnfiltered();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF2a2a2a),
|
||||
title: const Text(
|
||||
'All Players (Including Hidden)',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: allPlayers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = allPlayers[index];
|
||||
final isGhost = player.name.toLowerCase().contains('music assistant mobile');
|
||||
|
||||
return Card(
|
||||
color: isGhost ? Colors.red.withOpacity(0.2) : Colors.white.withOpacity(0.1),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: isGhost ? Colors.red[300] : Colors.white,
|
||||
fontWeight: isGhost ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ID: ${player.playerId}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
Text(
|
||||
'Available: ${player.available} | State: ${player.state}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isGhost
|
||||
? const Icon(Icons.warning, color: Colors.red, size: 20)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final text = allPlayers.map((p) =>
|
||||
'Name: ${p.name}\nID: ${p.playerId}\nAvailable: ${p.available}\nState: ${p.state}\n---'
|
||||
).join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Player list copied to clipboard!')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy List'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error loading players: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -75,6 +167,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.speaker_group_rounded),
|
||||
onPressed: _showAllPlayers,
|
||||
color: Colors.white,
|
||||
tooltip: 'View all players',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
onPressed: _copyLogs,
|
||||
|
||||
@@ -48,6 +48,31 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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 {
|
||||
if (_serverUrlController.text.trim().isEmpty) {
|
||||
setState(() {
|
||||
@@ -62,7 +87,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final serverUrl = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||
final port = _portController.text.trim();
|
||||
|
||||
// Validate port
|
||||
@@ -261,33 +286,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Authentication toggle
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _requiresAuth,
|
||||
onChanged: _isConnecting ? null : (value) {
|
||||
setState(() {
|
||||
_requiresAuth = value ?? false;
|
||||
});
|
||||
},
|
||||
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return Colors.white24;
|
||||
}
|
||||
return states.contains(WidgetState.selected)
|
||||
? Colors.white
|
||||
: Colors.white54;
|
||||
}),
|
||||
checkColor: const Color(0xFF1a1a1a),
|
||||
),
|
||||
const Text(
|
||||
'Requires authentication',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Authentication fields
|
||||
if (_requiresAuth) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -12,8 +12,6 @@ class NewHomeScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
@@ -46,9 +44,13 @@ class NewHomeScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: !maProvider.isConnected
|
||||
? _buildDisconnectedView(context, maProvider)
|
||||
: _buildConnectedView(context, maProvider),
|
||||
child: Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
return !maProvider.isConnected
|
||||
? _buildDisconnectedView(context, maProvider)
|
||||
: _buildConnectedView(context, maProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -117,6 +119,7 @@ class NewHomeScreen extends StatelessWidget {
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -19,6 +19,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
PlayerQueue? _queue;
|
||||
bool _isLoadingQueue = true;
|
||||
Timer? _progressTimer;
|
||||
double? _seekPosition; // Track seek position while dragging
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -86,11 +87,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
@@ -115,76 +117,85 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
selectedPlayer.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
return Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
selectedPlayer.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
// Album Art with Hero animation
|
||||
Hero(
|
||||
tag: 'now_playing_art',
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth.clamp(200.0, 400.0);
|
||||
return Center(
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 1024,
|
||||
cacheHeight: 1024,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white24,
|
||||
size: 120,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
@@ -228,12 +239,57 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
|
||||
// Progress Bar (showing elapsed time)
|
||||
if (currentTrack.duration != null) ...[
|
||||
Slider(
|
||||
value: selectedPlayer.currentElapsedTime.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||
onChanged: null, // TODO: Implement seek
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white24,
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 14,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: (_seekPosition ?? selectedPlayer.currentElapsedTime).clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_seekPosition = value;
|
||||
});
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
setState(() {
|
||||
_seekPosition = value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
final targetPosition = value.round();
|
||||
print('🎯 Seek slider released at $targetPosition seconds');
|
||||
try {
|
||||
await maProvider.seek(selectedPlayer.playerId, targetPosition);
|
||||
print('✅ Seek command completed');
|
||||
|
||||
// Wait briefly for server to update position
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
} catch (e) {
|
||||
print('❌ Error seeking: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error seeking: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_seekPosition = null;
|
||||
print('🎯 Cleared seek position');
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white24,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
@@ -241,7 +297,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(selectedPlayer.currentElapsedTime.toInt()),
|
||||
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
@@ -274,58 +330,76 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Previous
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Play/Pause
|
||||
_AnimatedPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _AnimatedPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Next
|
||||
AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 42,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Repeat
|
||||
@@ -352,10 +426,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
|
||||
@@ -1025,7 +1025,7 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('Setting volume to $volumeLevel for player $playerId');
|
||||
await _sendCommand(
|
||||
'player_command/volume_set',
|
||||
'players/cmd/volume_set',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'volume_level': volumeLevel.clamp(0, 100),
|
||||
@@ -1042,7 +1042,7 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('${muted ? "Muting" : "Unmuting"} player $playerId');
|
||||
await _sendCommand(
|
||||
'player_command/volume_mute',
|
||||
'players/cmd/volume_mute',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'muted': muted,
|
||||
@@ -1085,6 +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 {
|
||||
try {
|
||||
_logger.log('🎮 Sending queue command: $command to queue $queueId');
|
||||
|
||||
@@ -68,10 +68,13 @@ class _AlbumRowState extends State<AlbumRow> {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return _AlbumCard(album: album);
|
||||
return RepaintBoundary(
|
||||
child: _AlbumCard(album: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -120,6 +123,9 @@ class _AlbumCard extends StatelessWidget {
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 300,
|
||||
cacheHeight: 300,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 150,
|
||||
|
||||
@@ -68,10 +68,13 @@ class _ArtistRowState extends State<ArtistRow> {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index];
|
||||
return _ArtistCard(artist: artist);
|
||||
return RepaintBoundary(
|
||||
child: _ArtistCard(artist: artist),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,196 +12,224 @@ class MiniPlayer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
// Don't show mini player if no track is playing or no player selected
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Don't show mini player if no track is playing or no player selected
|
||||
if (currentTrack == null || selectedPlayer == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NowPlayingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Status indicator bar
|
||||
Container(
|
||||
height: 2,
|
||||
color: selectedPlayer.isPlaying
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
// Player content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NowPlayingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: HeroTags.nowPlayingBackground,
|
||||
child: Material(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: 'now_playing_art',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
// Status indicator bar
|
||||
Container(
|
||||
height: 2,
|
||||
color: selectedPlayer.isPlaying
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
// Player content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art with Hero animation
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingArt,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 96,
|
||||
cacheHeight: 96,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
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,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: const TextStyle(
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artistsString,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Queue button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
iconSize: 22,
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QueueScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
// Volume control (compact mute button)
|
||||
const VolumeControl(compact: true),
|
||||
// Playback controls for selected player
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPreviousButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 26,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.previousTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in previous track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingPlayButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: selectedPlayer.isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 32,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.playPauseSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in play/pause: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: HeroTags.nowPlayingNextButton,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedIconButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: Colors.white,
|
||||
iconSize: 28,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.nextTrackSelectedPlayer();
|
||||
} catch (e) {
|
||||
print('❌ Error in next track: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: isMuted ? null : (value) {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pendingVolume = value * 100;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
final volumeLevel = (value * 100).round();
|
||||
print('🔊 Volume slider released at $volumeLevel');
|
||||
try {
|
||||
// Unmute if changing volume while muted
|
||||
if (isMuted) {
|
||||
print('🔇 Unmuting player before setting volume');
|
||||
await maProvider.setMute(player.playerId, false);
|
||||
}
|
||||
print('🔊 Calling setVolume with $volumeLevel');
|
||||
await maProvider.setVolume(player.playerId, volumeLevel);
|
||||
print('✅ Volume set complete');
|
||||
|
||||
// Wait for the player state to update by polling
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted) {
|
||||
final updatedPlayer = maProvider.selectedPlayer;
|
||||
print('🔊 Poll $i: player volume = ${updatedPlayer?.volume}, target = $volumeLevel');
|
||||
if (updatedPlayer != null && (updatedPlayer.volume - volumeLevel).abs() <= 2) {
|
||||
print('✅ Volume updated on server');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
print('❌ Error setting volume: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
print('🔊 Cleared pending volume');
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,10 +14,6 @@ dependencies:
|
||||
cupertino_icons: ^1.0.6
|
||||
google_fonts: ^6.1.0
|
||||
|
||||
# Audio playback
|
||||
just_audio: ^0.9.36
|
||||
audio_service: ^0.18.12
|
||||
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
|
||||
@@ -28,13 +24,7 @@ dependencies:
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
|
||||
# File handling
|
||||
file_picker: ^6.1.1
|
||||
path_provider: ^2.1.1
|
||||
permission_handler: ^11.1.0
|
||||
|
||||
# Utilities
|
||||
rxdart: ^0.27.7
|
||||
uuid: ^4.2.1
|
||||
|
||||
dev_dependencies:
|
||||
@@ -49,7 +39,7 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
flutter_icons:
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: false
|
||||
image_path: "assets/images/icon.png"
|
||||
|
||||