Compare commits

...
20 Commits
Author SHA1 Message Date
Your NameandClaude 69030d8ed7 Security: Replace specific domain with generic example in README
Changed music.serverscloud.org to music.example.com in setup instructions
to use a generic example domain instead of a specific one.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 20:00:23 +00:00
Your NameandClaude a08ea90ef2 Add screenshots to README
Added screenshots section showing:
- Home screen with library browsing
- Now playing screen with playback controls
- Player selection interface

Screenshots are displayed in a centered layout for better presentation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:55:28 +00:00
Your NameandClaude daed4c6fcb Update README download section to point to releases page
Changed download instructions to direct users to the releases page
instead of GitHub Actions artifacts for easier access.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:51:04 +00:00
Your NameandClaude 5bd4a61956 Fix icon generation config and auto-add protocol to URLs
Icon Generation Fix:
- Changed deprecated flutter_icons to flutter_launcher_icons in pubspec.yaml
- Icons should now properly update with Amass branding

Connection URL Auto-Protocol:
- Auto-adds http:// for IP addresses and localhost
- Auto-adds https:// for domain names
- Preserves existing protocol if already specified
- Removes trailing slashes automatically

Examples:
  "192.168.4.120" → "http://192.168.4.120"
  "ma.serverscloud.org" → "https://ma.serverscloud.org"
  "http://192.168.4.120" → unchanged

Note on Play Protect Warning:
The "Play Protect hasn't seen this app before" message is expected for
sideloaded APKs not from Play Store. The app is properly signed (as of
previous build), so severe warnings should not appear. Users can tap
"Install anyway" to proceed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:44:34 +00:00
Your NameandClaude e34a89e990 Fix seekbar tap, remove auth checkbox, add app signing & icon generation
UI Improvements:
- Fixed seekbar tap not moving slider by adding onChangeStart handler
- Added 200ms delay after seek to let server update position
- Removed "Requires authentication" checkbox from login screen

App Signing & Icon:
- Configured automatic keystore generation in GitHub workflow
- Updated build.gradle to use key.properties for release signing
- This eliminates the security warning when installing APK
- Workflow now generates launcher icons from assets/images/icon.png
- Icons will use Amass branding with #1a1a1a background

Technical Changes:
- Keystore created during CI/CD with 10000 day validity
- Signing config falls back to debug if key.properties doesn't exist
- key.properties and *.jks already in gitignore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:29:46 +00:00
Your NameandClaude 76114fe6d2 Fix volume/mute API commands and progress bar seek logging
CRITICAL FIX: Volume commands were using wrong API endpoint
- Changed player_command/volume_set to players/cmd/volume_set
- Changed player_command/volume_mute to players/cmd/volume_mute
- This was causing "Invalid command" errors (error code 12)

Progress bar improvements:
- Added logging for seek operations
- Added mounted check before clearing seek position
- Logs now show when seek position is cleared

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:12:34 +00:00
Your NameandClaude 1c7844af0f Fix elapsed time using current_media and volume slider snap-back
- Fixed elapsed time to use current_media when top-level is 0
- This fixes progress bar being stuck at beginning
- Removed immediate player refresh after volume change
- Added polling loop to wait for volume update before clearing UI state
- Volume slider now stays at set position until server confirms
- Added detailed logging for volume change process

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 18:58:08 +00:00
Your NameandClaude 50841b7d86 Add debug logging and fix progress bar interaction
- Added local state tracking for seek position during drag
- Progress bar now shows visual feedback while dragging
- Time display updates during seek
- Volume control: added delay before clearing pending state
- Added comprehensive logging for volume changes
- Added detailed logging for elapsed time calculation to debug display issues
- Fixed progress bar slider to be fully interactive

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 18:23:59 +00:00
Your NameandClaude 9793da1a00 Fix player controls: volume slider, seek, elapsed time, and animation
- Fixed volume slider to always be interactive, unmutes player when adjusted
- Implemented seek functionality for progress bar in Now Playing screen
- Added seek command to API service and provider
- Improved elapsed time calculation with safety checks and debug logging
- Reduced animation jank by removing Material elevation from mini player
- Added proper BoxShadow to maintain visual depth without elevation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 17:32:26 +00:00
Your NameandClaude 8f1a8634d1 Fix Hero animations for seamless player bar transitions
- Restructured mini player to have GestureDetector outside Hero widget
- Wrapped entire Scaffold in Hero for smooth background expansion
- Made album art containers match structure (both use Container -> ClipRRect -> Image)
- Fixed all indentation to proper Flutter style
- Background now smoothly expands from bottom bar to full screen
- Album art smoothly scales from 48px to 400px without disappearing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 17:18:23 +00:00
Your NameandClaude 688f4c82f0 Fix syntax errors in mini_player.dart and now_playing_screen.dart
Fixed missing closing parentheses in both files that were causing the Flutter build to fail:
- mini_player.dart:243 - Added missing closing parenthesis for GestureDetector widget
- now_playing_screen.dart:396 - Added missing closing parenthesis for Scaffold widget

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 14:06:38 +00:00
Your NameandClaude efa1b79606 Add automated cleanup script for ghost players with server findings
Server Investigation Results:
- Found 26 ghost "Music Assistant Mobile" players in settings.json
- All are provider: builtin_player with available: true
- Created Nov 22-23, 2025 during app development
- UUIDs like 202f4c01-abbf-4a46-83f8-9954570e7e50
- Stored in settings.json but hidden from web UI

Cleanup Script (cleanup_ghost_players.sh):
- Safely removes all ghost players from settings.json
- Creates automatic backup before making changes
- Interactive confirmation before removal
- Colorized output showing what will be removed
- Instructions for restarting Music Assistant

Updated CLEANUP_GHOST_PLAYERS.md:
- Added actual server findings (26 players, not 22)
- Documented automated cleanup process
- Added script usage instructions
- Explained why ghosts don't show in UI

Usage:
  cd /home/home-server/Amass
  ./cleanup_ghost_players.sh
  docker restart music_assistant

This will permanently remove the ghost players from the server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:41:31 +00:00
Your NameandClaude e4c68aa9f9 Update app icon with new minimalist design
Replace logo.png duplicate with proper app icon (A||| symbol).
The icon is now distinct from the logo and better suited for app launchers.

Icon design: White "A|||" symbol on dark background, representing
the Amass brand in a simple, recognizable format for app icons.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:30:41 +00:00
Your Name 10aae85b33 Update cleanup guide with new player diagnostics tool 2025-11-24 13:27:40 +00:00
Your NameandClaude eadbb2023c Add player diagnostics tool to investigate ghost players
Changes:
- Enhanced logging to show first 5 ghost player IDs and their availability status
- Added "View all players" button (speaker icon) to Debug Log screen
- New dialog shows ALL players from API including filtered "Music Assistant Mobile" ones
- Ghost players highlighted in red with warning icon
- Shows player ID, availability status, and state for each player
- "Copy List" button to export all player data for debugging

Purpose:
- User reports 22+ ghost players but can't see them in Music Assistant UI
- Hypothesis: Ghost players are marked as available: false (hidden from UI)
- This tool lets users see actual player IDs and states from the API
- User can copy player IDs to manually remove them via Music Assistant API

Usage:
1. Open Settings → Debug Logs
2. Tap the speaker icon (top right)
3. See all players including hidden ghost players
4. Copy player IDs for manual cleanup if needed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:27:13 +00:00
Your NameandClaude cd37b74aea Remove unused audio playback dependencies and document ghost player cleanup
Background:
- Early app versions had local playback (just_audio, audio_service)
- These versions registered the device as a "Music Assistant Mobile" player
- Multiple logins created 22+ ghost players persisting on the server
- Current app correctly filters these out, but they still exist server-side

Changes:
- Remove unused dependencies: just_audio, audio_service, file_picker,
  path_provider, permission_handler, rxdart
- Add CLEANUP_GHOST_PLAYERS.md with instructions for removing old players
  from Music Assistant server via web UI, Home Assistant, or CLI

Impact:
- Smaller app size and fewer dependencies
- Clear documentation for users to clean up server-side ghost players
- App will continue to filter out any remaining ghost players

Note: Current app does NOT register as a player - it's remote control only.
Users need to manually clean up old players on their Music Assistant server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:23:18 +00:00
Your NameandClaude 215c05fd7e Fix choppy animations by optimizing state management
Performance optimizations:
- Increase polling interval from 2s to 5s to reduce update frequency
- Add state change detection - only call notifyListeners when data actually changes
- Optimize _updatePlayerState to compare state before triggering rebuilds
- Replace context.watch with Consumer for more selective widget rebuilds
- Remove double notifyListeners calls in _loadAndSelectPlayers
- Cache filtered player list more aggressively
- Reduce excessive logging (22 "Filtering out" messages → 1 summary)
- Only fetch selected player state instead of all players on every update

These changes dramatically reduce unnecessary widget rebuilds that were causing choppy animations. The app now only rebuilds when the actual state changes, not on every poll cycle.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:18:47 +00:00
Your NameandClaude 2a2a762cce Update README branding and clarify remote control functionality
- Change title from "Amass" to "Amass Your Music"
- Clarify that Amass is a remote control client for Music Assistant servers
- Update description to emphasize server control rather than local playback
- Remove references to device audio playback to avoid confusion
- Clarify that music plays on Music Assistant server's configured players
- Update features list to reflect remote control functionality
- Update technologies list to reflect actual dependencies

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:11:21 +00:00
Your NameandClaude 918aa8a032 Optimize animations and performance for buttery smooth experience
- Add Hero animations for previous, play, and next buttons between mini player and now playing screen
- Implement custom flightShuttleBuilder with fastOutSlowIn curves for album art transitions
- Add image caching with cacheWidth/cacheHeight for optimized memory usage
- Add BouncingScrollPhysics to all scrollable lists for smooth scrolling
- Wrap list items in RepaintBoundary to prevent unnecessary repaints
- Configure appropriate cache sizes for different image contexts (96x96 for mini player, 1024x1024 for now playing, 300x300 for album cards)
- Add FilterQuality.medium for better image rendering performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 13:05:31 +00:00
Your Name 542e80e52d Update logo and icon source files 2025-11-24 11:46:52 +00:00
24 changed files with 1027 additions and 401 deletions
+18 -2
View File
@@ -34,14 +34,30 @@ jobs:
- name: Get dependencies
run: flutter pub get
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
- name: Analyze code
run: flutter analyze
continue-on-error: true
- name: Create signing keystore
run: |
# Create a keystore for signing (valid for 10000 days)
keytool -genkey -v -keystore android/app/upload-keystore.jks \
-storetype JKS -keyalg RSA -keysize 2048 -validity 10000 \
-storepass android -keypass android \
-alias upload -dname "CN=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
+153
View File
@@ -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
+22 -15
View File
@@ -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>
+18 -1
View File
@@ -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
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+93
View File
@@ -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"
+7
View File
@@ -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';
}
+41 -12
View File
@@ -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)');
}
}
}
+90 -30
View File
@@ -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();
}
}
// ============================================================================
+98
View File
@@ -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,
+26 -28
View File
@@ -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),
+8 -5
View File
@@ -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: [
+192 -115
View File
@@ -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) {
+19 -2
View File
@@ -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');
+7 -1
View File
@@ -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,
+4 -1
View File
@@ -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),
);
},
);
},
+201 -173
View File
@@ -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')),
);
}
}
},
),
],
),
),
),
],
),
),
),
);
},
);
}
}
+29 -5
View File
@@ -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');
}
}
},
),
+1 -11
View File
@@ -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"