Compare commits

...
63 Commits
Author SHA1 Message Date
Your NameandClaude 92a15ba37c Update README with theming features and fix download link
- Added new Theming Options section with Material You, Adaptive Theming, and Light/Dark Mode
- Added Play on... button to control features
- Added device selector mention
- Fixed download link to point to correct repository

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:36:12 +00:00
Your NameandClaude 1df539028f Update connection screen and fix play on button
- Increased logo size to 160px on connection screen
- Removed Welcome text, subtitle, and help text from connection screen
- Added selectPlayer() call when using play on button to make chosen player active
- This ensures now playing bar appears when playing on selected device

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:20:28 +00:00
Your NameandClaude fa53ab06f4 Increase logo size on connection screen to 120px
- Changed logo height from 80 to 120 pixels
- Makes logo more prominent on login screen

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:05:47 +00:00
CollotsSpot 3be160660f Increase logo height in README
Updated logo height in README from 160 to 200.
2025-11-25 17:50:02 +00:00
CollotsSpot dfc18c28ea Update README.md 2025-11-25 17:49:49 +00:00
CollotsSpot 5de45ad228 Update README.md 2025-11-25 17:49:35 +00:00
Your Name 12ce53dda9 Use new long logo for login screen and readme 2025-11-25 17:48:04 +00:00
Your Name 39ff3558c5 Restore adaptive theming in details screens and update logo asset 2025-11-25 17:47:00 +00:00
Your Name dfe6903a16 Fix SettingsScreen build error by restoring class body 2025-11-25 17:34:36 +00:00
Your Name 6b07f403d1 Remove shadows from library menu cards 2025-11-25 17:17:24 +00:00
Your Name 2993288f6d Refactor home and library rows to use shared cards 2025-11-25 17:16:11 +00:00
Your Name 075f1531e4 Update home screen logo to ATTM short version and remove layout debug 2025-11-25 17:11:52 +00:00
Your Name 8b8d1f0269 Implement Layout Debugger and refactor NewHomeScreen 2025-11-25 16:43:02 +00:00
Your Name 93552f5008 Adjust logo vertical alignment 2025-11-25 16:22:15 +00:00
CollotsSpot 624cd32b86 Update README with logo and project description
Added a logo image to the README and updated the description.
2025-11-25 16:08:50 +00:00
CollotsSpot cf6f85a818 Update README.md 2025-11-25 16:08:28 +00:00
CollotsSpot 97ef755c90 Add description for Music Assistant mobile client 2025-11-25 16:08:03 +00:00
CollotsSpot d149955425 Update README.md 2025-11-25 16:07:37 +00:00
Your Name a71437e7fd Rename logo to attm_logo.png to force cache update 2025-11-25 16:06:01 +00:00
Your Name fd4efdf82c Update logo asset 2025-11-25 15:57:11 +00:00
Your Name 1ee710fe0a Fix logo alignment, update library screens to use theming 2025-11-25 15:45:49 +00:00
Your Name dca2001301 Fix Library screens theming 2025-11-25 15:31:16 +00:00
Your Name 42ede54b3b Revert logo adjustments and enforce light theme contrast 2025-11-25 15:26:05 +00:00
Your Name c16548e8b0 Fix home screen alignment and enforce light theme background 2025-11-25 15:12:09 +00:00
Your Name 7fdb1c0511 Update keystore CN 2025-11-25 14:47:39 +00:00
Your Name 2c2049cae2 Rename project to AssistantToTheMusic and update README 2025-11-25 14:47:20 +00:00
Your Name 91504c75a9 Final polish: theming, logo updates, library limits, and back navigation 2025-11-25 14:43:48 +00:00
Your Name 970ece0fe5 App name, logo font, nav, theming, and recent albums fixes 2025-11-25 14:24:58 +00:00
Your Name 45dc50834f Fix missing _normalizeServerUrl method in LoginScreen 2025-11-25 13:51:27 +00:00
Your Name b0a109f196 Restore missing connect method and fix type casting 2025-11-25 13:36:55 +00:00
Your Name 5120112435 Fix build errors: syntax, undefined method, and type check 2025-11-25 13:30:31 +00:00
Your Name 519248afcf Rebranding, Theming overhaul, and Album fallback search 2025-11-25 13:22:13 +00:00
Your Name 4ab5b02156 Fix devices list update and recent albums sorting 2025-11-25 12:37:29 +00:00
Your Name 50a2bd922d Fix power toggle refresh and revert to client-side album filtering 2025-11-25 12:12:54 +00:00
Your Name 999c1b63b1 Fix player power command and theming 2025-11-25 11:44:29 +00:00
Your Name b8b16957f8 Fix album sorting, player power control, and ghost filtering 2025-11-25 11:29:39 +00:00
Your Name 9472d154d9 Fix ghost devices, update devices UI, and optimize artist albums 2025-11-25 10:54:44 +00:00
Your Name 873ac3034a Fix settings screen theme colors 2025-11-25 10:01:43 +00:00
Your NameandClaude 67caa5946d Fix theme consistency across app - part 1
Applied theme colors throughout the main UI:
- Home screen navigation and bottom bar
- New home screen with recent/discover sections
- Album and artist row widgets
- Mini player widget

All hardcoded dark colors (Color(0xFF1a1a1a), Colors.white, etc.)
have been replaced with theme-aware colors (colorScheme.background,
colorScheme.onSurface, etc.) so they properly respond to light/dark
theme mode changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:58:38 +00:00
Your NameandClaude 00485736bb Implement adaptive theme with album artwork color extraction
Major Changes:
- Removed high contrast theme feature (menu toggle and all related code)
- Added adaptive theme toggle in settings (enabled by default)
- Added palette_generator package for color extraction from artwork
- Created PaletteHelper utility for extracting color schemes from images

Adaptive Theme Implementation:
- Album details screen: Extracts colors from album artwork
- Artist details screen: Extracts colors from artist images
- Now playing screen: Extracts colors from currently playing track

The adaptive theme dynamically changes background, text, and accent colors
based on the dominant colors in album/artist artwork. When disabled, falls
back to the original dark theme colors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:02:40 +00:00
Your NameandClaude 020b22e5d5 Pin dynamic_color to exact version 1.6.8
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 07:13:17 +00:00
Your NameandClaude bd4ba27da6 Fix Flutter 3.24.0 compatibility issues
- Remove .harmonized() calls (not available in Flutter 3.24.0)
- Downgrade dynamic_color to 1.6.8 for compatibility
- Keep high contrast functionality without harmonization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 06:54:44 +00:00
Your NameandClaude 1df8465133 Add comprehensive theming system inspired by Vaani
Implemented a full-featured theming system with the following capabilities:
- Theme mode switching (Light/Dark/System)
- High contrast mode for accessibility
- Material You dynamic colors (Android 12+)
- Persistent theme preferences via SharedPreferences
- Real-time theme updates without app restart

New files:
- lib/theme/app_theme.dart: Brand colors and theme generation
- lib/theme/theme_provider.dart: Theme state management
- lib/theme/system_theme_helper.dart: Material You integration

Modified files:
- lib/main.dart: Integrated dynamic theming with MultiProvider
- lib/services/settings_service.dart: Added theme preference storage
- lib/screens/settings_screen.dart: Added theme controls UI
- pubspec.yaml: Added dynamic_color dependency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 06:50:22 +00:00
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
50 changed files with 2872 additions and 1316 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=AssistantToTheMusic, OU=HomeServer, O=Personal, L=Home, S=Home, C=US"
# Create key.properties file
cat > android/key.properties << EOF
storePassword=android
keyPassword=android
keyAlias=upload
storeFile=upload-keystore.jks
EOF
- name: Build APK
run: flutter build apk --release
+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
+40 -19
View File
@@ -1,43 +1,65 @@
<div align="center">
<img src="assets/images/logo.png" alt="Amass Logo" height="120">
<h1>Amass</h1>
<img src="assets/images/attm_long_logo.png" alt="Assistant To The Music Logo" height="200">
---
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
<p>Stream your entire music library from your Music Assistant server to your mobile device.</p>
<p>Control your Music Assistant server and manage your entire music library from your mobile device.</p>
</div>
---
## Disclaimer
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
---
## Features
### Music Assistant Integration
- **Server Connection** - Connect to your Music Assistant server via WebSocket
- **Library Browsing** - Browse artists, albums, and tracks from your server
- **Album Details** - View album information and track listings
- **Music Streaming** - Stream audio directly from your Music Assistant server
- **Playback Control** - Control playback on your Music Assistant server players
- **Auto-Reconnect** - Automatic reconnection with connection status monitoring
- **Settings Management** - Configure server URL with persistent storage
### Player Features
- Clean, minimalistic dark UI design
- Full audio playback controls (play/pause/skip/seek)
### Control Features
- Full playback controls (play/pause/skip/seek) for server players
- Progress bar with time display
- Volume control slider
- Volume control for server players
- Now playing display with track information
- Background audio playback support
- Queue management
- Queue management and viewing
- Multi-player support with device selector
- Play on... button for quick device selection
### Theming Options
- **Material You** - Dynamic theming based on your device's wallpaper
- **Adaptive Theming** - Album artwork-based color schemes
- **Light/Dark Mode** - System-aware or manual theme selection
- Clean, minimalistic UI design
## Screenshots
<div align="center">
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
</div>
## Download
Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Amass/actions) - look for the "amass-apk" artifact in successful builds.
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Assistant-To-The-Music/releases).
## Setup
1. Launch the app
2. Navigate to the **Library** tab
3. Tap **Configure Server** or go to **Settings**
4. Enter your Music Assistant server URL (e.g., `music.serverscloud.org` or `192.168.1.100`)
4. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
5. Tap **Connect**
6. Browse your library and start playing music!
6. Browse your library and control playback on your Music Assistant players!
## Requirements
@@ -47,7 +69,7 @@ Download the latest APK from [GitHub Actions](https://github.com/CollotsSpot/Ama
## About Music Assistant
Amass is a client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app.
Amass Your Music is a remote control client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app. Music playback happens on your Music Assistant server's configured players (speakers, smart devices, etc.), not on your mobile device.
Learn more: [music-assistant.io](https://music-assistant.io/)
@@ -70,8 +92,8 @@ MIT License
1. Clone the repository
```bash
git clone https://github.com/CollotsSpot/Amass.git
cd Amass
git clone https://github.com/CollotsSpot/AssistantToTheMusic.git
cd AssistantToTheMusic
```
2. Install dependencies
@@ -97,10 +119,9 @@ The APK will be available at `build/app/outputs/flutter-apk/app-release.apk`
<summary>Technologies Used</summary>
- **Flutter** - Cross-platform mobile framework
- **just_audio** - Audio playback
- **audio_service** - Background audio support
- **web_socket_channel** - WebSocket communication
- **web_socket_channel** - WebSocket communication with Music Assistant server
- **provider** - State management
- **shared_preferences** - Local settings storage
- **http** - HTTP requests for API communication
</details>
+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
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:label="Amass"
android:label="ATTM"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 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"
BIN
View File
Binary file not shown.
+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';
}
+62 -27
View File
@@ -5,6 +5,9 @@ import 'providers/music_assistant_provider.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/settings_service.dart';
import 'theme/theme_provider.dart';
import 'theme/app_theme.dart';
import 'theme/system_theme_helper.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@@ -36,12 +39,14 @@ class MusicAssistantApp extends StatefulWidget {
}
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _provider;
late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider;
@override
void initState() {
super.initState();
_provider = MusicAssistantProvider();
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
WidgetsBinding.instance.addObserver(this);
}
@@ -58,7 +63,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
if (state == AppLifecycleState.resumed) {
// App came back to foreground - check connection and reconnect if needed
print('📱 App resumed - checking WebSocket connection...');
_provider.checkAndReconnect();
_musicProvider.checkAndReconnect();
} else if (state == AppLifecycleState.paused) {
print('📱 App paused (backgrounded)');
} else if (state == AppLifecycleState.detached) {
@@ -68,30 +73,60 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _provider,
child: MaterialApp(
title: 'Amass',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primaryColor: const Color(0xFF1a1a1a),
scaffoldBackgroundColor: const Color(0xFF1a1a1a),
colorScheme: const ColorScheme.dark(
primary: Colors.white,
secondary: Colors.white70,
surface: Color(0xFF2a2a2a),
background: Color(0xFF1a1a1a),
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
fontFamily: 'Roboto',
),
home: const AppStartup(),
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _musicProvider),
ChangeNotifierProvider.value(value: _themeProvider),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return FutureBuilder<(ColorScheme, ColorScheme)?>(
future: themeProvider.useMaterialTheme
? SystemThemeHelper.getSystemColorSchemes()
: null,
builder: (context, snapshot) {
// Determine which color schemes to use
ColorScheme? lightColorScheme;
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
} else {
// Use brand color schemes
lightColorScheme = brandLightColorScheme;
darkColorScheme = brandDarkColorScheme;
}
// Update system UI overlay style based on theme mode
final isDark = themeProvider.themeMode == ThemeMode.dark ||
(themeProvider.themeMode == ThemeMode.system &&
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? darkColorScheme.background
: lightColorScheme.background,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
return MaterialApp(
title: 'ATTM', // Updated title
debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
home: const AppStartup(),
);
},
);
},
),
);
}
+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)');
}
}
}
+135 -40
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;
@@ -138,10 +143,11 @@ class MusicAssistantProvider with ChangeNotifier {
notifyListeners();
// Load artists, albums, and tracks in parallel
// Use a high limit to fetch "all" items (assuming library < 5000 items per type)
final results = await Future.wait([
_api!.getArtists(limit: 500),
_api!.getAlbums(limit: 500),
_api!.getTracks(limit: 500),
_api!.getArtists(limit: 5000),
_api!.getAlbums(limit: 5000),
_api!.getTracks(limit: 5000),
]);
_artists = results[0] as List<Artist>;
@@ -168,7 +174,7 @@ class MusicAssistantProvider with ChangeNotifier {
notifyListeners();
_artists = await _api!.getArtists(
limit: limit,
limit: limit ?? 5000, // Default to high limit if not specified
offset: offset,
search: search,
);
@@ -198,7 +204,7 @@ class MusicAssistantProvider with ChangeNotifier {
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit,
limit: limit ?? 5000, // Default to high limit
offset: offset,
search: search,
artistId: artistId,
@@ -332,11 +338,32 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> togglePower(String playerId) async {
try {
// Find the player to get current power state
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
// Toggle the state
await _api?.setPower(playerId, !player.powered);
// Immediately refresh players to update UI
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Toggle power', e);
// Don't rethrow to avoid crashing UI, just log
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
await _api?.setVolume(playerId, volumeLevel);
// Refresh player state to get updated volume
await refreshPlayers();
// Don't refresh immediately - let automatic polling pick up the change
// This prevents the slider from snapping back before the server updates
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
@@ -354,6 +381,16 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> seek(String playerId, int position) async {
try {
await _api?.seek(playerId, position);
// Player state will be updated on next poll
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId) async {
try {
await _api?.toggleShuffle(queueId);
@@ -411,33 +448,54 @@ class MusicAssistantProvider with ChangeNotifier {
_playersLastFetched != null &&
_availablePlayers.isNotEmpty &&
now.difference(_playersLastFetched!) < _playersCacheDuration) {
_logger.log('Using cached player list (${_availablePlayers.length} players)');
return;
}
_logger.log('Fetching fresh player list...');
final allPlayers = await getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
// Filter out ghost players and duplicates
// 1. "Music Assistant Mobile" players are legacy/ghosts
// 2. "This Device" players: Only keep the one matching our client ID
int filteredCount = 0;
final List<String> ghostPlayerIds = [];
// Filter out leftover "Music Assistant Mobile" players from old app builds
// These were registered during development but are no longer used
// Keep "this device" (web UI player) and all other legitimate players
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
// Exclude players that are exactly "music assistant mobile" or similar
// but keep "this device" and other players
// Legacy ghosts
if (nameLower.contains('music assistant mobile')) {
_logger.log('Filtering out leftover player: ${player.name}');
filteredCount++;
ghostPlayerIds.add('${player.playerId} (Mobile Ghost)');
return false;
}
// Filter "This Device" duplicates
if (nameLower.contains('this device')) {
// If we have a builtin ID, only keep the player that matches it
if (builtinPlayerId != null && player.playerId != builtinPlayerId) {
filteredCount++;
ghostPlayerIds.add('${player.playerId} (Duplicate This Device)');
return false;
}
// If we don't have a builtin ID yet (rare), or this matches, keep it
}
return true;
}).toList();
_playersLastFetched = DateTime.now();
_logger.log('Loaded ${_availablePlayers.length} players (filtered from ${allPlayers.length} total)');
// Log each player for debugging
for (final player in _availablePlayers) {
_logger.log(' - ${player.name} (${player.playerId}) - available: ${player.available}, state: ${player.state}');
// Log details about ghost players
if (filteredCount > 0) {
_logger.log('🧹 Filtered out $filteredCount ghost players:');
for (int i = 0; i < ghostPlayerIds.length && i < 5; i++) {
_logger.log(' - ${ghostPlayerIds[i]}');
}
if (ghostPlayerIds.length > 5) {
_logger.log(' ... and ${ghostPlayerIds.length - 5} more');
}
_logger.log('📊 Result: ${_availablePlayers.length} valid players from ${allPlayers.length} total');
}
if (_availablePlayers.isNotEmpty) {
@@ -484,21 +542,23 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('⚠️ No players available');
}
notifyListeners();
// Don't call notifyListeners here - selectPlayer already does it
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
/// Select a player for playback
void selectPlayer(Player player) {
void selectPlayer(Player player, {bool skipNotify = false}) {
_selectedPlayer = player;
_logger.log('Selected player: ${player.name} (${player.playerId})');
// Start polling for player state
_startPlayerStatePolling();
notifyListeners();
if (!skipNotify) {
notifyListeners();
}
}
/// Start polling for selected player's current state
@@ -507,8 +567,8 @@ class MusicAssistantProvider with ChangeNotifier {
if (_selectedPlayer == null) return;
// Poll every 2 seconds
_playerStateTimer = Timer.periodic(const Duration(seconds: 2), (_) {
// Poll every 5 seconds for better performance
_playerStateTimer = Timer.periodic(const Duration(seconds: 5), (_) {
_updatePlayerState();
});
@@ -521,8 +581,23 @@ class MusicAssistantProvider with ChangeNotifier {
if (_selectedPlayer == null || _api == null) return;
try {
// Refresh players list to get latest player state
await refreshPlayers();
bool stateChanged = false;
// Fetch only the selected player's state instead of all players
final allPlayers = await getPlayers();
final updatedPlayer = allPlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
orElse: () => _selectedPlayer!,
);
// Check if player state actually changed
if (updatedPlayer.state != _selectedPlayer!.state ||
updatedPlayer.volumeLevel != _selectedPlayer!.volumeLevel ||
updatedPlayer.volumeMuted != _selectedPlayer!.volumeMuted ||
updatedPlayer.available != _selectedPlayer!.available) {
_selectedPlayer = updatedPlayer;
stateChanged = true;
}
// Only show tracks if player is available and not idle
final shouldShowTrack = _selectedPlayer!.available &&
@@ -532,7 +607,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Clear track if player is unavailable or idle
if (_currentTrack != null) {
_currentTrack = null;
_logger.log('🗑️ Cleared track - player unavailable or idle (state: ${_selectedPlayer!.state}, available: ${_selectedPlayer!.available})');
stateChanged = true;
}
if (stateChanged) {
notifyListeners();
}
return;
@@ -542,23 +620,25 @@ class MusicAssistantProvider with ChangeNotifier {
final queue = await getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
_currentTrack = queue.currentItem!.track;
_logger.log('✅ Current track updated: ${_currentTrack!.name}');
notifyListeners();
} else {
if (queue == null) {
_logger.log('⚠️ Queue is null for player ${_selectedPlayer!.playerId}');
} else if (queue.currentItem == null) {
_logger.log('⚠️ Queue currentItem is null (currentIndex: ${queue.currentIndex}, items: ${queue.items.length})');
// Only update if track actually changed
if (_currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name) {
_currentTrack = queue.currentItem!.track;
stateChanged = true;
}
} else {
if (_currentTrack != null) {
// Clear current track if queue is empty
_currentTrack = null;
_logger.log('🗑️ Cleared current track');
notifyListeners();
stateChanged = true;
}
}
// Only notify if something actually changed
if (stateChanged) {
notifyListeners();
}
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
@@ -601,21 +681,36 @@ class MusicAssistantProvider with ChangeNotifier {
/// Refresh the list of available players
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await _loadAndSelectPlayers(forceRefresh: true);
// Update the selected player with fresh data
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
// Check if state actually changed
if (updatedPlayer.state != previousState ||
updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
_logger.log('Updated selected player state: ${updatedPlayer.name} - ${updatedPlayer.state}');
} catch (e) {
// Selected player no longer available
_logger.log('Selected player no longer in list');
stateChanged = true;
}
}
// Only notify if state changed
if (stateChanged) {
notifyListeners();
}
}
// ============================================================================
+159 -42
View File
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
@@ -17,12 +19,37 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@override
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_extractColors();
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
NetworkImage(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
print('⚠️ Failed to extract colors for album: $e');
}
}
Future<void> _toggleFavorite() async {
@@ -136,20 +163,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final themeProvider = context.watch<ThemeProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
// Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use
ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
}
// Use adaptive scheme if available, otherwise use global theme
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
expandedHeight: 350, // Increased height for bigger art
pinned: true,
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
@@ -159,11 +201,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId),
child: Container(
width: 200,
height: 200,
width: 280, // Increased size
height: 280,
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(12),
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16), // Slightly more rounded
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
@@ -179,10 +221,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
: null,
),
child: imageUrl == null
? const Icon(
? Icon(
Icons.album_rounded,
size: 100,
color: Colors.white54,
size: 120,
color: colorScheme.onSurfaceVariant,
)
: null,
),
@@ -203,9 +245,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
color: Colors.transparent,
child: Text(
widget.album.name,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
@@ -218,9 +259,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
color: Colors.transparent,
child: Text(
widget.album.artistsString,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
),
@@ -228,17 +268,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
const SizedBox(height: 24),
Row(
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play Album'),
label: const Text('Play'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
disabledBackgroundColor: Colors.white38,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -247,29 +289,49 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
),
const SizedBox(width: 12),
// "Play on..." Button
Expanded(
flex: 1,
child: SizedBox(
height: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
),
const SizedBox(width: 12),
// Favorite Button
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: _isFavorite ? Colors.red : Colors.white24,
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.white : Colors.white70,
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 24),
const Text(
Text(
'Tracks',
style: TextStyle(
color: Colors.white,
fontSize: 18,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
@@ -279,18 +341,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
),
if (_isLoading)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: Colors.white),
child: CircularProgressIndicator(color: colorScheme.primary),
),
)
else if (_tracks.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Text(
'No tracks found',
style: TextStyle(
color: Colors.white54,
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
),
),
@@ -306,14 +368,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
@@ -321,8 +383,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
title: Text(
track.name,
style: const TextStyle(
color: Colors.white,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -330,9 +392,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
),
subtitle: Text(
track.artistsString,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -340,9 +401,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
@@ -357,6 +417,63 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
);
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'Play on...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
// Set this as the active player
maProvider.selectPlayer(player);
// Play on this specific player
maProvider.playTracks(player.playerId, _tracks);
},
);
},
),
),
const SizedBox(height: 32),
],
),
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
+108 -40
View File
@@ -4,6 +4,8 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -17,11 +19,36 @@ class ArtistDetailsScreen extends StatefulWidget {
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _albums = [];
bool _isLoading = true;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@override
void initState() {
super.initState();
_loadArtistAlbums();
_extractColors();
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
NetworkImage(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
print('⚠️ Failed to extract colors for artist: $e');
}
}
Future<void> _loadArtistAlbums() async {
@@ -33,51 +60,94 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
print(' Provider: ${widget.artist.provider}');
print(' ItemId: ${widget.artist.itemId}');
// Get all albums and filter by this artist
// Get all albums and filter locally (API filtering not reliable yet)
final allAlbums = await provider.api!.getAlbums();
// Filter albums that include this artist
final artistAlbums = allAlbums.where((album) {
var artistAlbums = allAlbums.where((album) {
if (album.artists == null) return false;
return album.artists!.any((artist) =>
artist.itemId == widget.artist.itemId &&
artist.provider == widget.artist.provider
artist.itemId == widget.artist.itemId ||
artist.name == widget.artist.name // Fallback to name match
);
}).toList();
print(' Got ${artistAlbums.length} albums for this artist (from ${allAlbums.length} total)');
if (artistAlbums.isNotEmpty) {
print(' First album: ${artistAlbums.first.name}');
print(' Got ${artistAlbums.length} library albums for this artist');
// Fallback: If no library albums, try searching the provider
if (artistAlbums.isEmpty && widget.artist.name.isNotEmpty) {
print('🔍 No library albums found. Searching provider for "${widget.artist.name}"...');
try {
final searchResults = await provider.search(widget.artist.name);
final searchAlbums = searchResults['albums'] ?? [];
// Filter search results to ensure they are actually for this artist
final validSearchAlbums = searchAlbums.where((item) {
// Ensure item is an Album and check artist match
if (item is! Album) return false;
return item.artists?.any((a) =>
a.name.toLowerCase() == widget.artist.name.toLowerCase()
) ?? false;
}).cast<Album>().toList();
if (validSearchAlbums.isNotEmpty) {
print('✅ Found ${validSearchAlbums.length} albums via search fallback');
artistAlbums = validSearchAlbums;
} else {
print('⚠️ Search returned no valid albums for this artist');
}
} catch (e) {
print('❌ Error searching for artist albums: $e');
}
}
setState(() {
_albums = artistAlbums;
_isLoading = false;
});
if (mounted) {
setState(() {
_albums = artistAlbums;
_isLoading = false;
});
}
} else {
setState(() {
_isLoading = false;
});
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final themeProvider = context.watch<ThemeProvider>();
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
// Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use
ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
}
// Use adaptive scheme if available, otherwise use global theme
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
@@ -88,14 +158,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
child: CircleAvatar(
radius: 100,
backgroundColor: Colors.white12,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage:
imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? const Icon(
? Icon(
Icons.person_rounded,
size: 100,
color: Colors.white54,
color: colorScheme.onSurfaceVariant,
)
: null,
),
@@ -116,9 +186,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
color: Colors.transparent,
child: Text(
widget.artist.name,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
@@ -127,9 +196,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
const SizedBox(height: 24),
Text(
'Albums',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
@@ -139,18 +207,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
if (_isLoading)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: Colors.white),
child: CircularProgressIndicator(color: colorScheme.primary),
),
)
else if (_albums.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Text(
'No albums found',
style: TextStyle(
color: Colors.white54,
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
),
),
@@ -182,6 +250,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(album, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return InkWell(
onTap: () {
@@ -198,7 +268,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
image: imageUrl != null
? DecorationImage(
@@ -208,11 +278,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
: null,
),
child: imageUrl == null
? const Center(
? Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurfaceVariant,
),
)
: null,
@@ -221,9 +291,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
const SizedBox(height: 8),
Text(
album.name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -231,9 +300,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
Text(
album.artistsString,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+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,
+70 -58
View File
@@ -24,66 +24,78 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
body: _screens[_selectedIndex],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Mini player
const MiniPlayer(),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Bottom navigation bar
Container(
decoration: BoxDecoration(
color: const Color(0xFF2a2a2a),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
return PopScope(
canPop: _selectedIndex == 0,
onPopInvoked: (didPop) {
if (didPop) return;
setState(() {
_selectedIndex = 0;
});
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: _screens[_selectedIndex],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Mini player
const MiniPlayer(),
// Bottom navigation bar
Container(
decoration: BoxDecoration(
color: colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
backgroundColor: Colors.transparent,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
backgroundColor: Colors.transparent,
selectedItemColor: Colors.white,
unselectedItemColor: Colors.white54,
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
),
],
],
),
),
);
}
+24 -98
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../widgets/album_card.dart';
class LibraryAlbumsScreen extends StatelessWidget {
const LibraryAlbumsScreen({super.key});
@@ -11,35 +10,38 @@ class LibraryAlbumsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
title: const Text(
title: Text(
'Albums',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
),
body: _buildAlbumsList(provider),
body: _buildAlbumsList(context, provider),
);
}
Widget _buildAlbumsList(MusicAssistantProvider provider) {
Widget _buildAlbumsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -48,16 +50,16 @@ class LibraryAlbumsScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.album_outlined,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No albums found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -67,8 +69,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -77,6 +79,8 @@ class LibraryAlbumsScreen extends StatelessWidget {
}
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: () async {
await provider.loadLibrary();
},
@@ -91,88 +95,10 @@ class LibraryAlbumsScreen extends StatelessWidget {
itemCount: provider.albums.length,
itemBuilder: (context, index) {
final album = provider.albums[index];
return _buildAlbumCard(context, album, provider);
return AlbumCard(album: album);
},
),
);
}
Widget _buildAlbumCard(
BuildContext context, Album album, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(album, size: 256);
return InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
child: Container(
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(12),
image: imageUrl != null
? DecorationImage(
image: NetworkImage(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? const Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: Colors.white54,
),
)
: null,
),
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Hero(
tag: HeroTags.artistName + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.artistsString,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
}
+29 -22
View File
@@ -11,35 +11,38 @@ class LibraryArtistsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
title: const Text(
title: Text(
'Artists',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
),
body: _buildArtistsList(provider),
body: _buildArtistsList(context, provider),
);
}
Widget _buildArtistsList(MusicAssistantProvider provider) {
Widget _buildArtistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -48,16 +51,16 @@ class LibraryArtistsScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.person_outline_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No artists found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -67,8 +70,8 @@ class LibraryArtistsScreen extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -77,6 +80,8 @@ class LibraryArtistsScreen extends StatelessWidget {
}
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: () async {
await provider.loadLibrary();
},
@@ -85,14 +90,16 @@ class LibraryArtistsScreen extends StatelessWidget {
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final artist = provider.artists[index];
return _buildArtistTile(artist, provider);
return _buildArtistTile(context, artist, provider);
},
),
);
}
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
Widget _buildArtistTile(BuildContext context, Artist artist, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Builder(
builder: (context) => ListTile(
@@ -100,10 +107,10 @@ class LibraryArtistsScreen extends StatelessWidget {
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
child: CircleAvatar(
radius: 24,
backgroundColor: Colors.white12,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? const Icon(Icons.person_rounded, color: Colors.white54)
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
),
@@ -113,8 +120,8 @@ class LibraryArtistsScreen extends StatelessWidget {
color: Colors.transparent,
child: Text(
artist.name,
style: const TextStyle(
color: Colors.white,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
+32 -23
View File
@@ -43,35 +43,38 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
title: const Text(
title: Text(
'Playlists',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
),
body: _buildPlaylistsList(provider),
body: _buildPlaylistsList(context, provider),
);
}
Widget _buildPlaylistsList(MusicAssistantProvider provider) {
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -80,16 +83,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.playlist_play_outlined,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No playlists found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -99,8 +102,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -109,20 +112,24 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
}
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: _loadPlaylists,
child: ListView.builder(
itemCount: _playlists.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final playlist = _playlists[index];
return _buildPlaylistTile(playlist, provider);
return _buildPlaylistTile(context, playlist, provider);
},
),
);
}
Widget _buildPlaylistTile(Playlist playlist, MusicAssistantProvider provider) {
Widget _buildPlaylistTile(BuildContext context, Playlist playlist, MusicAssistantProvider provider) {
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Builder(
builder: (context) => ListTile(
@@ -130,7 +137,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
@@ -140,13 +147,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
: null,
),
child: imageUrl == null
? const Icon(Icons.playlist_play_rounded, color: Colors.white54)
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: const TextStyle(
color: Colors.white,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -156,7 +163,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
playlist.trackCount != null
? '${playlist.trackCount} tracks'
: playlist.owner ?? 'Playlist',
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
+67 -55
View File
@@ -33,17 +33,18 @@ class _LibraryScreenState extends State<LibraryScreen>
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text(
title: Text(
'Library',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
@@ -59,14 +60,14 @@ class _LibraryScreenState extends State<LibraryScreen>
),
);
},
color: Colors.white,
color: colorScheme.onBackground,
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white54,
indicatorColor: colorScheme.primary,
labelColor: colorScheme.primary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
tabs: const [
Tab(text: 'Artists'),
Tab(text: 'Albums'),
@@ -88,22 +89,24 @@ class _LibraryScreenState extends State<LibraryScreen>
}
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.cloud_off_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
@@ -121,8 +124,8 @@ class _LibraryScreenState extends State<LibraryScreen>
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
@@ -139,9 +142,11 @@ class _LibraryScreenState extends State<LibraryScreen>
}
Widget _buildArtistsList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -150,16 +155,16 @@ class _LibraryScreenState extends State<LibraryScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.person_outline_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No artists found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -169,8 +174,8 @@ class _LibraryScreenState extends State<LibraryScreen>
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -190,20 +195,21 @@ class _LibraryScreenState extends State<LibraryScreen>
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
radius: 24,
backgroundColor: Colors.white12,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? const Icon(Icons.person_rounded, color: Colors.white54)
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
artist.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -220,9 +226,11 @@ class _LibraryScreenState extends State<LibraryScreen>
}
Widget _buildAlbumsList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -231,16 +239,16 @@ class _LibraryScreenState extends State<LibraryScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.album_outlined,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No albums found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -250,8 +258,8 @@ class _LibraryScreenState extends State<LibraryScreen>
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -277,6 +285,7 @@ class _LibraryScreenState extends State<LibraryScreen>
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
final imageUrl = provider.getImageUrl(album, size: 256);
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
@@ -293,7 +302,7 @@ class _LibraryScreenState extends State<LibraryScreen>
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
image: imageUrl != null
? DecorationImage(
@@ -303,11 +312,11 @@ class _LibraryScreenState extends State<LibraryScreen>
: null,
),
child: imageUrl == null
? const Center(
? Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurfaceVariant,
),
)
: null,
@@ -316,8 +325,8 @@ class _LibraryScreenState extends State<LibraryScreen>
const SizedBox(height: 8),
Text(
album.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -326,8 +335,8 @@ class _LibraryScreenState extends State<LibraryScreen>
),
Text(
album.artistsString,
style: const TextStyle(
color: Colors.white54,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 12,
),
maxLines: 1,
@@ -339,9 +348,11 @@ class _LibraryScreenState extends State<LibraryScreen>
}
Widget _buildTracksList(MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -350,16 +361,16 @@ class _LibraryScreenState extends State<LibraryScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.music_note_outlined,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No tracks found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -369,8 +380,8 @@ class _LibraryScreenState extends State<LibraryScreen>
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -392,13 +403,14 @@ class _LibraryScreenState extends State<LibraryScreen>
final imageUrl = track.album != null
? provider.getImageUrl(track.album!, size: 128)
: null;
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
@@ -408,25 +420,25 @@ class _LibraryScreenState extends State<LibraryScreen>
: null,
),
child: imageUrl == null
? const Icon(Icons.music_note_rounded, color: Colors.white54)
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
track.name,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
)
: null,
onTap: () {
+24 -19
View File
@@ -9,22 +9,23 @@ class LibraryTracksScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
title: const Text(
title: Text(
'Tracks',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
@@ -36,9 +37,11 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTracksList(
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -47,16 +50,16 @@ class LibraryTracksScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.music_note_outlined,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'No tracks found',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
),
@@ -66,8 +69,8 @@ class LibraryTracksScreen extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
@@ -94,13 +97,15 @@ class LibraryTracksScreen extends StatelessWidget {
final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128)
: null;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
@@ -110,25 +115,25 @@ class LibraryTracksScreen extends StatelessWidget {
: null,
),
child: imageUrl == null
? const Icon(Icons.music_note_rounded, color: Colors.white54)
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
track.name,
style: const TextStyle(color: Colors.white),
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
)
: null,
onTap: () => _playTrack(context, maProvider, index),
+79 -111
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/auth_service.dart';
import '../widgets/logo_text.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
@@ -47,6 +48,33 @@ class _LoginScreenState extends State<LoginScreen> {
_passwordController.dispose();
super.dispose();
}
// ... existing code ...
String _normalizeServerUrl(String url) {
// Remove any trailing slashes
url = url.trim();
while (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
// If URL already has a protocol, return it
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// If it's an IP address or localhost, default to http://
if (url.startsWith('192.') ||
url.startsWith('10.') ||
url.startsWith('172.') ||
url == 'localhost' ||
url.startsWith('127.')) {
return 'http://$url';
}
// For domain names, default to https://
return 'https://$url';
}
Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) {
@@ -62,7 +90,7 @@ class _LoginScreenState extends State<LoginScreen> {
});
try {
final serverUrl = _serverUrlController.text.trim();
final serverUrl = _normalizeServerUrl(_serverUrlController.text.trim());
final port = _portController.text.trim();
// Validate port
@@ -145,8 +173,11 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
@@ -158,44 +189,20 @@ class _LoginScreenState extends State<LoginScreen> {
// Logo
Center(
child: Image.asset(
'assets/images/logo.png',
height: 80,
'assets/images/attm_long_logo.png',
height: 160,
fit: BoxFit.contain,
color: colorScheme.onBackground, // Tints the logo to match theme
),
),
const SizedBox(height: 16),
// Welcome text
const Text(
'Welcome to Amass',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Connect to your Music Assistant server',
style: TextStyle(
color: Colors.white54,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Server URL
const Text(
Text(
'Server Address',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
@@ -203,34 +210,36 @@ class _LoginScreenState extends State<LoginScreen> {
TextField(
controller: _serverUrlController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., music.example.com or 192.168.1.100',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: const Color(0xFF2a2a2a),
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.dns_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
// ... rest of UI ...
const SizedBox(height: 24),
// Port
const Text(
Text(
'Port',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
@@ -238,19 +247,19 @@ class _LoginScreenState extends State<LoginScreen> {
TextField(
controller: _portController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: '8095',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: const Color(0xFF2a2a2a),
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.settings_ethernet_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
@@ -261,42 +270,14 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 24),
// Authentication toggle
Row(
children: [
Checkbox(
value: _requiresAuth,
onChanged: _isConnecting ? null : (value) {
setState(() {
_requiresAuth = value ?? false;
});
},
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return Colors.white24;
}
return states.contains(WidgetState.selected)
? Colors.white
: Colors.white54;
}),
checkColor: const Color(0xFF1a1a1a),
),
const Text(
'Requires authentication',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
// Authentication fields
if (_requiresAuth) ...[
const SizedBox(height: 16),
const Text(
Text(
'Username',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
@@ -304,19 +285,19 @@ class _LoginScreenState extends State<LoginScreen> {
TextField(
controller: _usernameController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Username',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: const Color(0xFF2a2a2a),
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.person_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
@@ -325,11 +306,10 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
const Text(
Text(
'Password',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
@@ -337,19 +317,19 @@ class _LoginScreenState extends State<LoginScreen> {
TextField(
controller: _passwordController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Password',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: const Color(0xFF2a2a2a),
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.lock_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
obscureText: true,
@@ -367,18 +347,18 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withOpacity(0.3)),
border: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_rounded, color: Colors.red, size: 20),
Icon(Icons.error_rounded, color: colorScheme.error, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
_error!,
style: const TextStyle(color: Colors.red, fontSize: 14),
style: TextStyle(color: colorScheme.onErrorContainer, fontSize: 14),
),
),
],
@@ -389,8 +369,8 @@ class _LoginScreenState extends State<LoginScreen> {
ElevatedButton(
onPressed: _isConnecting ? null : _connect,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -398,12 +378,12 @@ class _LoginScreenState extends State<LoginScreen> {
elevation: 0,
),
child: _isConnecting
? const SizedBox(
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF1a1a1a)),
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
)
: const Text(
@@ -414,18 +394,6 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
const SizedBox(height: 32),
// Help text
const Text(
'Need help? Make sure your Music Assistant server is running and accessible from this device.',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
+25 -16
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
import '../widgets/logo_text.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -12,20 +13,21 @@ class NewHomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
padding: const EdgeInsets.only(left: 8.0, top: 8.0, bottom: 8.0),
child: Image.asset(
'assets/images/logo.png',
height: 72,
'assets/images/attm_short_logo.png',
height: 32,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
color: colorScheme.onBackground,
),
),
titleSpacing: 0,
@@ -46,40 +48,46 @@ class NewHomeScreen extends StatelessWidget {
],
),
body: SafeArea(
child: !maProvider.isConnected
? _buildDisconnectedView(context, maProvider)
: _buildConnectedView(context, maProvider),
child: Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
return !maProvider.isConnected
? _buildDisconnectedView(context, maProvider)
: _buildConnectedView(context, maProvider);
},
),
),
);
}
Widget _buildDisconnectedView(
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.cloud_off_rounded,
size: 80,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 24),
const Text(
Text(
'Not Connected',
style: TextStyle(
color: Colors.white,
color: colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w300,
),
),
const SizedBox(height: 12),
const Text(
Text(
'Connect to your Music Assistant server to start listening',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 14,
),
textAlign: TextAlign.center,
@@ -97,8 +105,8 @@ class NewHomeScreen extends StatelessWidget {
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
@@ -117,6 +125,7 @@ class NewHomeScreen extends StatelessWidget {
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+28 -22
View File
@@ -15,17 +15,18 @@ class NewLibraryScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text(
title: Text(
'Library',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
@@ -41,6 +42,7 @@ class NewLibraryScreen extends StatelessWidget {
),
);
},
color: colorScheme.onBackground,
),
const PlayerSelector(),
],
@@ -53,22 +55,24 @@ class NewLibraryScreen extends StatelessWidget {
Widget _buildDisconnectedView(
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.cloud_off_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(height: 16),
const Text(
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
@@ -86,8 +90,8 @@ class NewLibraryScreen extends StatelessWidget {
icon: const Icon(Icons.settings_rounded),
label: const Text('Configure Server'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
@@ -189,8 +193,12 @@ class NewLibraryScreen extends StatelessWidget {
required String subtitle,
required VoidCallback? onTap,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
color: const Color(0xFF2a2a2a),
elevation: 0, // Remove shadow
color: colorScheme.surfaceVariant.withOpacity(0.3),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -200,34 +208,32 @@ class NewLibraryScreen extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: Colors.white,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
title: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
subtitle,
style: const TextStyle(
color: Colors.white54,
fontSize: 13,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
trailing: onTap != null
? const Icon(
? Icon(
Icons.chevron_right_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.5),
)
: null,
onTap: onTap,
+271 -129
View File
@@ -7,6 +7,8 @@ import '../widgets/volume_control.dart';
import 'queue_screen.dart';
import '../constants/hero_tags.dart';
import '../widgets/animated_icon_button.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
class NowPlayingScreen extends StatefulWidget {
const NowPlayingScreen({super.key});
@@ -19,6 +21,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
PlayerQueue? _queue;
bool _isLoadingQueue = true;
Timer? _progressTimer;
double? _seekPosition; // Track seek position while dragging
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String? _lastImageUrl; // Track last image URL to avoid re-extracting
@override
void initState() {
@@ -27,6 +33,26 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
_startProgressTimer();
}
Future<void> _extractColors(String imageUrl) async {
if (_lastImageUrl == imageUrl) return; // Skip if same image
_lastImageUrl = imageUrl;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
NetworkImage(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
print('⚠️ Failed to extract colors for now playing: $e');
}
}
@override
void dispose() {
_progressTimer?.cancel();
@@ -86,11 +112,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
final themeProvider = context.watch<ThemeProvider>();
if (currentTrack == null || selectedPlayer == null) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
if (currentTrack == null || selectedPlayer == null) {
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
appBar: AppBar(
@@ -115,84 +144,125 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
selectedPlayer.name,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w300,
// Extract colors if adaptive theme is enabled and we have an image URL
if (themeProvider.adaptiveTheme && imageUrl != null) {
_extractColors(imageUrl);
}
// Determine if we should use adaptive theme colors
final useAdaptiveTheme = themeProvider.adaptiveTheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Get the color scheme to use
ColorScheme? adaptiveScheme;
if (useAdaptiveTheme) {
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
}
// Determine colors to use
final backgroundColor = useAdaptiveTheme && adaptiveScheme != null
? adaptiveScheme.background
: const Color(0xFF1a1a1a);
final surfaceColor = useAdaptiveTheme && adaptiveScheme != null
? adaptiveScheme.surface
: const Color(0xFF2a2a2a);
final primaryColor = useAdaptiveTheme && adaptiveScheme != null
? adaptiveScheme.primary
: Colors.white;
final textColor = useAdaptiveTheme && adaptiveScheme != null
? adaptiveScheme.onSurface
: Colors.white;
return Hero(
tag: HeroTags.nowPlayingBackground,
child: Scaffold(
backgroundColor: backgroundColor,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
selectedPlayer.name,
style: TextStyle(
color: textColor.withOpacity(0.7),
fontSize: 14,
fontWeight: FontWeight.w300,
),
),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.queue_music, color: textColor),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const QueueScreen(),
),
);
},
),
],
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.queue_music),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const QueueScreen(),
),
);
},
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
children: [
const Spacer(),
// Album Art with Hero animation
Hero(
tag: 'now_playing_art',
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth.clamp(200.0, 400.0);
return Center(
child: Hero(
tag: HeroTags.nowPlayingArt,
child: Container(
width: size,
height: size,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
cacheWidth: 1024,
cacheHeight: 1024,
filterQuality: FilterQuality.medium,
errorBuilder: (context, error, stackTrace) {
return Container(
color: const Color(0xFF2a2a2a),
child: const Icon(
Icons.music_note_rounded,
color: Colors.white24,
size: 120,
),
);
},
)
: Container(
color: const Color(0xFF2a2a2a),
child: const Icon(
Icons.music_note_rounded,
color: Colors.white24,
size: 120,
),
);
},
)
: Container(
color: const Color(0xFF2a2a2a),
child: const Icon(
Icons.music_note_rounded,
color: Colors.white24,
size: 120,
),
),
),
),
),
),
),
),
);
},
),
const SizedBox(height: 40),
// Track Info
Text(
currentTrack.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
),
@@ -203,8 +273,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
const SizedBox(height: 8),
Text(
currentTrack.artistsString,
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: textColor.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
@@ -215,8 +285,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
const SizedBox(height: 4),
Text(
currentTrack.album!.name,
style: const TextStyle(
color: Colors.white54,
style: TextStyle(
color: textColor.withOpacity(0.54),
fontSize: 14,
),
textAlign: TextAlign.center,
@@ -228,12 +298,57 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
// Progress Bar (showing elapsed time)
if (currentTrack.duration != null) ...[
Slider(
value: selectedPlayer.currentElapsedTime.clamp(0, currentTrack.duration!.inSeconds.toDouble()),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: null, // TODO: Implement seek
activeColor: Colors.white,
inactiveColor: Colors.white24,
SliderTheme(
data: SliderThemeData(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 14,
),
),
child: Slider(
value: (_seekPosition ?? selectedPlayer.currentElapsedTime).clamp(0, currentTrack.duration!.inSeconds.toDouble()),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) {
setState(() {
_seekPosition = value;
});
},
onChangeStart: (value) {
setState(() {
_seekPosition = value;
});
},
onChangeEnd: (value) async {
final targetPosition = value.round();
print('🎯 Seek slider released at $targetPosition seconds');
try {
await maProvider.seek(selectedPlayer.playerId, targetPosition);
print('✅ Seek command completed');
// Wait briefly for server to update position
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
print('❌ Error seeking: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_seekPosition = null;
print('🎯 Cleared seek position');
});
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor.withOpacity(0.24),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
@@ -241,12 +356,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(selectedPlayer.currentElapsedTime.toInt()),
style: const TextStyle(color: Colors.white54, fontSize: 12),
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
),
Text(
_formatDuration(currentTrack.duration!.inSeconds),
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
),
],
),
@@ -267,65 +382,85 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
IconButton(
icon: Icon(
Icons.shuffle,
color: _queue?.shuffle == true ? Colors.blue : Colors.white54,
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.54),
),
iconSize: 24,
onPressed: _isLoadingQueue ? null : _toggleShuffle,
),
const SizedBox(width: 12),
// Previous
AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: Colors.white,
iconSize: 42,
onPressed: () async {
try {
await maProvider.previousTrackSelectedPlayer();
} catch (e) {
print('❌ Error in previous track: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
Hero(
tag: HeroTags.nowPlayingPreviousButton,
child: Material(
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: textColor,
iconSize: 42,
onPressed: () async {
try {
await maProvider.previousTrackSelectedPlayer();
} catch (e) {
print('❌ Error in previous track: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
const SizedBox(width: 12),
// Play/Pause
_AnimatedPlayButton(
isPlaying: selectedPlayer.isPlaying,
onPressed: () async {
try {
await maProvider.playPauseSelectedPlayer();
} catch (e) {
print('❌ Error in play/pause: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
Hero(
tag: HeroTags.nowPlayingPlayButton,
child: Material(
color: Colors.transparent,
child: _AnimatedPlayButton(
isPlaying: selectedPlayer.isPlaying,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
onPressed: () async {
try {
await maProvider.playPauseSelectedPlayer();
} catch (e) {
print('❌ Error in play/pause: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
const SizedBox(width: 12),
// Next
AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: Colors.white,
iconSize: 42,
onPressed: () async {
try {
await maProvider.nextTrackSelectedPlayer();
} catch (e) {
print('❌ Error in next track: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
Hero(
tag: HeroTags.nowPlayingNextButton,
child: Material(
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: textColor,
iconSize: 42,
onPressed: () async {
try {
await maProvider.nextTrackSelectedPlayer();
} catch (e) {
print('❌ Error in next track: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
const SizedBox(width: 12),
// Repeat
@@ -335,8 +470,8 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
? Icons.repeat_one
: Icons.repeat,
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
? Colors.blue
: Colors.white54,
? primaryColor
: textColor.withOpacity(0.54),
),
iconSize: 24,
onPressed: _isLoadingQueue ? null : _cycleRepeat,
@@ -352,10 +487,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
),
const Spacer(),
],
),
),
),
),
);
},
);
}
String _formatDuration(int seconds) {
@@ -369,10 +507,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
/// Animated play/pause button with scale animation
class _AnimatedPlayButton extends StatefulWidget {
final bool isPlaying;
final Color primaryColor;
final Color backgroundColor;
final VoidCallback onPressed;
const _AnimatedPlayButton({
required this.isPlaying,
required this.primaryColor,
required this.backgroundColor,
required this.onPressed,
});
@@ -423,15 +565,15 @@ class _AnimatedPlayButtonState extends State<_AnimatedPlayButton>
child: Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
color: Colors.white,
decoration: BoxDecoration(
color: widget.primaryColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
),
color: const Color(0xFF1a1a1a),
color: widget.backgroundColor,
iconSize: 42,
onPressed: _handleTap,
),
+45 -35
View File
@@ -65,23 +65,26 @@ class _SearchScreenState extends State<SearchScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: const Color(0xFF2a2a2a),
backgroundColor: colorScheme.surface,
elevation: 0,
title: TextField(
controller: _searchController,
focusNode: _focusNode,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
decoration: InputDecoration(
hintText: 'Search music...',
hintStyle: const TextStyle(color: Colors.white54),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear_rounded, color: Colors.white54),
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
_performSearch('');
@@ -103,22 +106,24 @@ class _SearchScreenState extends State<SearchScreen> {
}
Widget _buildDisconnectedView() {
return const Center(
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: EdgeInsets.all(32.0),
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_rounded,
size: 64,
color: Colors.white54,
color: colorScheme.onBackground.withOpacity(0.54),
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
'Not connected to Music Assistant',
style: TextStyle(
color: Colors.white70,
color: colorScheme.onBackground.withOpacity(0.7),
fontSize: 16,
),
textAlign: TextAlign.center,
@@ -130,9 +135,11 @@ class _SearchScreenState extends State<SearchScreen> {
}
Widget _buildSearchContent() {
final colorScheme = Theme.of(context).colorScheme;
if (_isSearching) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
@@ -144,13 +151,13 @@ class _SearchScreenState extends State<SearchScreen> {
Icon(
Icons.search_rounded,
size: 80,
color: Colors.white.withOpacity(0.3),
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'Search for artists, albums, or tracks',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
color: colorScheme.onBackground.withOpacity(0.5),
fontSize: 16,
),
),
@@ -173,13 +180,13 @@ class _SearchScreenState extends State<SearchScreen> {
Icon(
Icons.search_off_rounded,
size: 64,
color: Colors.white.withOpacity(0.3),
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No results found',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
color: colorScheme.onBackground.withOpacity(0.5),
fontSize: 16,
),
),
@@ -217,8 +224,8 @@ class _SearchScreenState extends State<SearchScreen> {
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
'$title ($count)',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).colorScheme.onBackground,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -229,28 +236,29 @@ class _SearchScreenState extends State<SearchScreen> {
Widget _buildArtistTile(Artist artist) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
radius: 24,
backgroundColor: Colors.white12,
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? const Icon(Icons.person_rounded, color: Colors.white54)
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
artist.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: const Text(
subtitle: Text(
'Artist',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
Navigator.push(
@@ -266,13 +274,14 @@ class _SearchScreenState extends State<SearchScreen> {
Widget _buildAlbumTile(Album album) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(album, size: 128);
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
@@ -282,13 +291,13 @@ class _SearchScreenState extends State<SearchScreen> {
: null,
),
child: imageUrl == null
? const Icon(Icons.album_rounded, color: Colors.white54)
? Icon(Icons.album_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
album.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -296,7 +305,7 @@ class _SearchScreenState extends State<SearchScreen> {
),
subtitle: Text(
album.artistsString,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -316,13 +325,14 @@ class _SearchScreenState extends State<SearchScreen> {
final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128)
: null;
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
@@ -332,13 +342,13 @@ class _SearchScreenState extends State<SearchScreen> {
: null,
),
child: imageUrl == null
? const Icon(Icons.music_note_rounded, color: Colors.white54)
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
track.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
@@ -346,14 +356,14 @@ class _SearchScreenState extends State<SearchScreen> {
),
subtitle: Text(
track.artistsString,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
+193 -76
View File
@@ -5,6 +5,7 @@ import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/auth_service.dart';
import '../services/debug_logger.dart';
import '../theme/theme_provider.dart';
import 'debug_log_screen.dart';
class SettingsScreen extends StatefulWidget {
@@ -64,7 +65,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
return;
}
// Validate and save port
final port = _portController.text.trim();
if (port.isEmpty) {
_showError('Please enter a port number');
@@ -84,7 +84,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
try {
// Attempt authentication if username/password provided
if (_usernameController.text.trim().isNotEmpty &&
_passwordController.text.trim().isNotEmpty) {
_logger.log('🔐 Attempting login with credentials...');
@@ -96,7 +95,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
if (token != null) {
// Save credentials for future use
await SettingsService.setUsername(_usernameController.text.trim());
await SettingsService.setPassword(_passwordController.text.trim());
@@ -117,7 +115,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
// Connect to Music Assistant
final provider = context.read<MusicAssistantProvider>();
await provider.connectToServer(_serverUrlController.text);
@@ -153,22 +150,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
backgroundColor: colorScheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
color: Colors.white,
color: colorScheme.onBackground,
),
title: const Text(
title: Text(
'Settings',
style: TextStyle(
color: Colors.white,
fontSize: 20,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
),
),
@@ -184,7 +182,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
},
color: Colors.white,
color: colorScheme.onBackground,
tooltip: 'Debug Logs',
),
],
@@ -194,18 +192,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Connection status
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white12,
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_getStatusIcon(provider.connectionState),
color: _getStatusColor(provider.connectionState),
color: _getStatusColor(provider.connectionState, colorScheme),
size: 24,
),
const SizedBox(width: 12),
@@ -213,19 +210,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Connection Status',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
_getStatusText(provider.connectionState),
style: TextStyle(
color: _getStatusColor(provider.connectionState),
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(provider.connectionState, colorScheme),
fontWeight: FontWeight.bold,
),
),
@@ -238,40 +233,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Server URL input
const Text(
Text(
'Music Assistant Server',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
Text(
'Enter your Music Assistant server URL or IP address',
style: TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
TextField(
controller: _serverUrlController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., music.example.com or 192.168.1.100',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: Colors.white12,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.dns_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
@@ -279,41 +271,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
// Port input
const Text(
Text(
'Port',
style: TextStyle(
color: Colors.white,
fontSize: 16,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
Text(
'Music Assistant WebSocket port (usually 8095)',
style: TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
TextField(
controller: _portController,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '8095',
hintStyle: const TextStyle(color: Colors.white38),
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: Colors.white12,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(
prefixIcon: Icon(
Icons.settings_ethernet_rounded,
color: Colors.white54,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
@@ -321,27 +310,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Connect button
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isConnecting ? null : _connect,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
disabledBackgroundColor: Colors.white38,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isConnecting
? const SizedBox(
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF1a1a1a),
color: colorScheme.onPrimary,
),
)
: const Text(
@@ -371,8 +359,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error.withOpacity(0.5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -390,7 +378,138 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Debug logs button
Text(
'Theme',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Mode',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.auto_mode_rounded),
),
],
selected: {themeProvider.themeMode},
onSelectionChanged: (Set<ThemeMode> newSelection) {
themeProvider.setThemeMode(newSelection.first);
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return colorScheme.primaryContainer;
}
return Colors.transparent;
}),
foregroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return colorScheme.onPrimaryContainer;
}
return colorScheme.onSurfaceVariant;
}),
),
);
},
),
],
),
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Material You',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Use system colors (Android 12+)',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Adaptive Theme',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Extract colors from album and artist artwork',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -406,8 +525,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: const Icon(Icons.bug_report_rounded),
label: const Text('View Debug Logs'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
foregroundColor: colorScheme.onBackground,
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -417,14 +536,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Info section
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
color: colorScheme.surfaceVariant.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.1),
color: colorScheme.outline.withOpacity(0.3),
),
),
child: Column(
@@ -432,38 +550,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Row(
children: [
const Icon(
Icon(
Icons.info_outline_rounded,
color: Colors.white70,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
const Text(
Text(
'Connection Info',
style: TextStyle(
color: Colors.white,
fontSize: 14,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
const Text(
Text(
'• Default ports: 443 for HTTPS, 8095 for HTTP\n'
'• You can override the port in the WebSocket Port field\n'
'• Use domain name or IP address for server\n'
'• Make sure your device can reach the server\n'
'• Check debug logs if connection fails',
style: TextStyle(
color: Colors.white54,
fontSize: 12,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 32),
],
),
),
@@ -483,16 +600,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Color _getStatusColor(MAConnectionState state) {
Color _getStatusColor(MAConnectionState state, ColorScheme colorScheme) {
switch (state) {
case MAConnectionState.connected:
return Colors.green;
case MAConnectionState.connecting:
return Colors.orange;
case MAConnectionState.error:
return Colors.red;
return colorScheme.error;
case MAConnectionState.disconnected:
return Colors.white54;
return colorScheme.onSurface.withOpacity(0.5);
}
}
+64 -10
View File
@@ -74,6 +74,16 @@ class MusicAssistantAPI {
useSecure = wsUrl.startsWith('wss://');
}
// Get or generate a persistent client ID to prevent ghost players
var clientId = await SettingsService.getBuiltinPlayerId();
if (clientId == null) {
clientId = _uuid.v4();
await SettingsService.setBuiltinPlayerId(clientId);
_logger.log('Generated and saved new client ID: $clientId');
} else {
_logger.log('Using existing client ID: $clientId');
}
// Construct WebSocket URL with proper port handling
final uri = Uri.parse(wsUrl);
@@ -85,6 +95,7 @@ class MusicAssistantAPI {
host: uri.host,
port: _cachedCustomPort,
path: '/ws',
queryParameters: {'client_id': clientId},
);
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
} else if (uri.hasPort) {
@@ -94,6 +105,7 @@ class MusicAssistantAPI {
host: uri.host,
port: uri.port,
path: '/ws',
queryParameters: {'client_id': clientId},
);
_logger.log('Using port from URL: ${uri.port}');
} else {
@@ -105,6 +117,7 @@ class MusicAssistantAPI {
host: uri.host,
port: 8095,
path: '/ws',
queryParameters: {'client_id': clientId},
);
_logger.log('Using port 8095 for unsecure connection');
} else {
@@ -115,6 +128,7 @@ class MusicAssistantAPI {
host: uri.host,
// NO PORT - let it use default 443 implicitly
path: '/ws',
queryParameters: {'client_id': clientId},
);
_logger.log('Using implicit default port (443) for secure connection');
}
@@ -327,7 +341,7 @@ class MusicAssistantAPI {
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist': artistId,
if (artistId != null) 'artist_id': artistId,
};
_logger.log('Albums API args: $args');
@@ -393,12 +407,12 @@ class MusicAssistantAPI {
Future<List<Album>> getRecentAlbums({int limit = 10}) async {
try {
_logger.log('Fetching recently played albums (limit=$limit)');
// Fetch a large number of albums to ensure we include recently played ones,
// then sort client-side since server-side sorting can be unreliable.
final response = await _sendCommand(
'music/albums/library_items',
args: {
'limit': limit,
'sort': 'timestamp_played', // Sort by when last played
'order': 'desc', // Most recent first
'limit': 2000, // Increase limit to capture most of the library
},
);
@@ -408,11 +422,24 @@ class MusicAssistantAPI {
return [];
}
_logger.log('✅ Got ${items.length} recent albums');
if (items.isNotEmpty) {
_logger.log(' First album: ${items[0]['name']} by ${items[0]['artists']?[0]?['name'] ?? 'Unknown'}');
// Sort client-side by timestamp_played or last_played
items.sort((a, b) {
// Try to find timestamp in different possible locations
final tA = (a['timestamp_played'] ?? a['last_played'] ?? a['metadata']?['timestamp_played'] ?? a['metadata']?['last_played'] ?? 0) as num;
final tB = (b['timestamp_played'] ?? b['last_played'] ?? b['metadata']?['timestamp_played'] ?? b['metadata']?['last_played'] ?? 0) as num;
return tB.compareTo(tA); // Descending
});
_logger.log('✅ Got ${items.length} albums, sorting by timestamp_played');
// Take top 'limit'
final topItems = items.take(limit).toList();
if (topItems.isNotEmpty) {
_logger.log(' First recent album: ${topItems[0]['name']}');
}
return items
return topItems
.map((item) => Album.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
@@ -1000,6 +1027,16 @@ class MusicAssistantAPI {
}
// Player control commands
Future<void> setPower(String playerId, bool powered) async {
await _sendCommand(
'players/cmd/power',
args: {
'player_id': playerId,
'powered': powered,
},
);
}
Future<void> pausePlayer(String playerId) async {
await _sendQueueCommand(playerId, 'pause');
}
@@ -1025,7 +1062,7 @@ class MusicAssistantAPI {
try {
_logger.log('Setting volume to $volumeLevel for player $playerId');
await _sendCommand(
'player_command/volume_set',
'players/cmd/volume_set',
args: {
'player_id': playerId,
'volume_level': volumeLevel.clamp(0, 100),
@@ -1042,7 +1079,7 @@ class MusicAssistantAPI {
try {
_logger.log('${muted ? "Muting" : "Unmuting"} player $playerId');
await _sendCommand(
'player_command/volume_mute',
'players/cmd/volume_mute',
args: {
'player_id': playerId,
'muted': muted,
@@ -1085,6 +1122,23 @@ class MusicAssistantAPI {
}
}
/// Seek to position in seconds
Future<void> seek(String queueId, int position) async {
try {
_logger.log('Seeking to $position seconds for queue $queueId');
await _sendCommand(
'player_queues/seek',
args: {
'queue_id': queueId,
'position': position,
},
);
} catch (e) {
_logger.log('Error seeking: $e');
rethrow;
}
}
Future<void> _sendQueueCommand(String queueId, String command) async {
try {
_logger.log('🎮 Sending queue command: $command to queue $queueId');
+45
View File
@@ -8,6 +8,10 @@ class SettingsService {
static const String _keyUsername = 'username';
static const String _keyPassword = 'password';
static const String _keyBuiltinPlayerId = 'builtin_player_id';
static const String _keyThemeMode = 'theme_mode';
static const String _keyUseMaterialTheme = 'use_material_theme';
static const String _keyAdaptiveTheme = 'adaptive_theme';
static const String _keyCustomColor = 'custom_color';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
@@ -111,6 +115,47 @@ class SettingsService {
await prefs.setString(_keyBuiltinPlayerId, id);
}
// Theme settings
static Future<String?> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyThemeMode) ?? 'system';
}
static Future<void> saveThemeMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyThemeMode, mode);
}
static Future<bool> getUseMaterialTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyUseMaterialTheme) ?? false;
}
static Future<void> saveUseMaterialTheme(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyUseMaterialTheme, enabled);
}
static Future<bool> getAdaptiveTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyAdaptiveTheme) ?? true; // Default to true
}
static Future<void> saveAdaptiveTheme(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAdaptiveTheme, enabled);
}
static Future<String?> getCustomColor() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyCustomColor);
}
static Future<void> saveCustomColor(String color) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyCustomColor, color);
}
static Future<void> clearSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
// Brand colors for Amass
const Color brandPrimaryColor = Color(0xFF1a1a1a);
const Color brandAccentColor = Color(0xFF604CEC);
// Brand color schemes
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.light,
background: const Color(0xFFFAFAFA),
onBackground: const Color(0xFF1a1a1a), // Ensure dark text
surface: const Color(0xFFFFFFFF),
onSurface: const Color(0xFF1a1a1a), // Ensure dark text
);
final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.dark,
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
class AppTheme {
// Generate light theme
static ThemeData lightTheme({ColorScheme? colorScheme}) {
final scheme = colorScheme ?? brandLightColorScheme;
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.background,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
color: scheme.surface,
elevation: 2,
),
fontFamily: 'Roboto',
);
}
// Generate dark theme
static ThemeData darkTheme({ColorScheme? colorScheme}) {
final scheme = colorScheme ?? brandDarkColorScheme;
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.background,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
color: scheme.surface,
elevation: 2,
),
fontFamily: 'Roboto',
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
class PaletteHelper {
/// Extract a color palette from an image
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
try {
final palette = await PaletteGenerator.fromImageProvider(
imageProvider,
maximumColorCount: 20,
);
return palette;
} catch (e) {
print('⚠️ Failed to extract palette: $e');
return null;
}
}
/// Generate color schemes from a palette
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
if (palette == null) return null;
// Get the dominant color as the seed color
Color seedColor = palette.dominantColor?.color ??
palette.vibrantColor?.color ??
palette.mutedColor?.color ??
const Color(0xFF604CEC); // Fallback to brand color
// Generate light and dark color schemes from the seed color
final lightScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
final darkScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
// Use darker background colors for better contrast
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
return (lightScheme, darkScheme);
}
/// Extract color schemes from an image provider in one call
static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async {
final palette = await extractPalette(imageProvider);
return generateColorSchemes(palette);
}
/// Get primary color for use in UI elements
static Color? getPrimaryColor(PaletteGenerator? palette) {
if (palette == null) return null;
return palette.vibrantColor?.color ??
palette.dominantColor?.color ??
palette.lightVibrantColor?.color;
}
/// Get background color for use in UI
static Color? getBackgroundColor(PaletteGenerator? palette, {required bool isDark}) {
if (palette == null) return null;
if (isDark) {
return palette.darkMutedColor?.color ??
palette.mutedColor?.color?.withOpacity(0.3) ??
const Color(0xFF1a1a1a);
} else {
return palette.lightMutedColor?.color ??
palette.mutedColor?.color?.withOpacity(0.9) ??
Colors.white;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
class SystemThemeHelper {
/// Attempts to get system color schemes from the device (Android 12+)
/// Returns a tuple of (light, dark) ColorScheme if successful, null otherwise
static Future<(ColorScheme, ColorScheme)?> getSystemColorSchemes() async {
try {
// Try to get dynamic color schemes from the system
final corePalette = await DynamicColorPlugin.getCorePalette();
if (corePalette != null) {
// Generate color schemes from the core palette
final lightScheme = corePalette.toColorScheme(brightness: Brightness.light);
final darkScheme = corePalette.toColorScheme(brightness: Brightness.dark);
return (lightScheme, darkScheme);
} else {
// Fallback: try to get just the accent color
final accentColor = await DynamicColorPlugin.getAccentColor();
if (accentColor != null) {
// Generate color schemes from the accent color
final lightScheme = ColorScheme.fromSeed(
seedColor: accentColor,
brightness: Brightness.light,
);
final darkScheme = ColorScheme.fromSeed(
seedColor: accentColor,
brightness: Brightness.dark,
);
return (lightScheme, darkScheme);
}
}
} catch (e) {
print('⚠️ Failed to get system colors: $e');
}
return null;
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
bool _useMaterialTheme = false;
bool _adaptiveTheme = true;
Color _customColor = const Color(0xFF604CEC);
ThemeProvider() {
_loadSettings();
}
ThemeMode get themeMode => _themeMode;
bool get useMaterialTheme => _useMaterialTheme;
bool get adaptiveTheme => _adaptiveTheme;
Color get customColor => _customColor;
Future<void> _loadSettings() async {
final themeModeString = await SettingsService.getThemeMode();
_themeMode = _parseThemeMode(themeModeString);
_useMaterialTheme = await SettingsService.getUseMaterialTheme();
_adaptiveTheme = await SettingsService.getAdaptiveTheme();
final colorString = await SettingsService.getCustomColor();
if (colorString != null) {
_customColor = _parseColor(colorString);
}
notifyListeners();
}
ThemeMode _parseThemeMode(String? mode) {
switch (mode) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
}
Color _parseColor(String colorString) {
try {
// Remove # if present
final hex = colorString.replaceAll('#', '');
// Add FF for alpha if not present
final hexWithAlpha = hex.length == 6 ? 'FF$hex' : hex;
return Color(int.parse(hexWithAlpha, radix: 16));
} catch (e) {
return const Color(0xFF604CEC); // Default color
}
}
String _colorToString(Color color) {
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
await SettingsService.saveThemeMode(_themeModeToString(mode));
notifyListeners();
}
String _themeModeToString(ThemeMode mode) {
switch (mode) {
case ThemeMode.light:
return 'light';
case ThemeMode.dark:
return 'dark';
case ThemeMode.system:
return 'system';
}
}
Future<void> setUseMaterialTheme(bool enabled) async {
_useMaterialTheme = enabled;
await SettingsService.saveUseMaterialTheme(enabled);
notifyListeners();
}
Future<void> setAdaptiveTheme(bool enabled) async {
_adaptiveTheme = enabled;
await SettingsService.saveAdaptiveTheme(enabled);
notifyListeners();
}
Future<void> setCustomColor(Color color) async {
_customColor = color;
await SettingsService.saveCustomColor(_colorToString(color));
notifyListeners();
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart';
class AlbumCard extends StatelessWidget {
final Album album;
final VoidCallback? onTap;
const AlbumCard({
super.key,
required this.album,
this.onTap,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return GestureDetector(
onTap: onTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album artwork with Hero animation
Expanded(
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: colorScheme.surfaceVariant,
image: imageUrl != null
? DecorationImage(
image: NetworkImage(imageUrl),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null
? Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
)
: null,
),
),
),
const SizedBox(height: 8),
// Album title with Hero animation
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
// Artist name with Hero animation
Hero(
tag: HeroTags.artistName + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
),
),
],
),
);
}
}
+18 -103
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart';
import 'album_card.dart';
class AlbumRow extends StatefulWidget {
final String title;
@@ -30,6 +29,9 @@ class _AlbumRowState extends State<AlbumRow> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,14 +39,14 @@ class _AlbumRowState extends State<AlbumRow> {
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: const TextStyle(
fontSize: 20,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
SizedBox(
height: 200,
height: 210, // Slightly increased for text
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
@@ -60,18 +62,26 @@ class _AlbumRowState extends State<AlbumRow> {
final albums = snapshot.data ?? [];
if (albums.isEmpty) {
return const Center(
child: Text('No albums found'),
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
physics: const BouncingScrollPhysics(),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return _AlbumCard(album: album);
return Container(
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(album: album),
);
},
);
},
@@ -82,98 +92,3 @@ class _AlbumRowState extends State<AlbumRow> {
}
}
class _AlbumCard extends StatelessWidget {
final Album album;
const _AlbumCard({required this.album});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(album, size: 200);
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(
album: album,
),
),
);
},
child: Container(
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album artwork with Hero animation
Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: imageUrl != null
? Image.network(
imageUrl,
width: 150,
height: 150,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 150,
height: 150,
color: Colors.grey[800],
child: const Icon(Icons.album, size: 64),
);
},
)
: Container(
width: 150,
height: 150,
color: Colors.grey[800],
child: const Icon(Icons.album, size: 64),
),
),
),
const SizedBox(height: 8),
// Album title with Hero animation
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
),
// Artist name with Hero animation
if (album.artists != null && album.artists!.isNotEmpty)
Hero(
tag: HeroTags.artistName + (album.uri ?? album.itemId),
child: Material(
color: Colors.transparent,
child: Text(
album.artists!.first.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey[400],
),
),
),
),
],
),
),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
class ArtistCard extends StatelessWidget {
final Artist artist;
final VoidCallback? onTap;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return GestureDetector(
onTap: onTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(artist: artist),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular with Hero animation
Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
child: CircleAvatar(
radius: 60, // Fixed size radius for row, or flexible? Row uses fixed width 120.
// In Grid we might want flexible.
// Let's make it responsive to container width if possible, or just use AspectRatio.
// But for CircleAvatar, radius is explicit.
// Better to use Container with BoxShape.circle
backgroundColor: colorScheme.surfaceVariant,
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant)
: null,
),
),
const SizedBox(height: 12),
// Artist name with Hero animation
Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
child: Material(
color: Colors.transparent,
child: Text(
artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
+17 -71
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import 'artist_card.dart';
class ArtistRow extends StatefulWidget {
final String title;
@@ -30,6 +29,9 @@ class _ArtistRowState extends State<ArtistRow> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,9 +39,9 @@ class _ArtistRowState extends State<ArtistRow> {
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: const TextStyle(
fontSize: 20,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
@@ -60,18 +62,26 @@ class _ArtistRowState extends State<ArtistRow> {
final artists = snapshot.data ?? [];
if (artists.isEmpty) {
return const Center(
child: Text('No artists found'),
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
physics: const BouncingScrollPhysics(),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return _ArtistCard(artist: artist);
return Container(
width: 120,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(artist: artist),
);
},
);
},
@@ -81,67 +91,3 @@ class _ArtistRowState extends State<ArtistRow> {
);
}
}
class _ArtistCard extends StatelessWidget {
final Artist artist;
const _ArtistCard({required this.artist});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(artist, size: 200);
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistDetailsScreen(
artist: artist,
),
),
);
},
child: Container(
width: 120,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular with Hero animation
Hero(
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
child: CircleAvatar(
radius: 60,
backgroundColor: Colors.grey[800],
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
child: imageUrl == null
? const Icon(Icons.person_rounded, size: 60, color: Colors.white54)
: null,
),
),
const SizedBox(height: 12),
// Artist name with Hero animation
Hero(
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
child: Material(
color: Colors.transparent,
child: Text(
artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
),
],
),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class LogoText extends StatelessWidget {
final double fontSize;
final bool lightMode;
const LogoText({
super.key,
this.fontSize = 24,
this.lightMode = false
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textColor = lightMode ? Colors.white : colorScheme.onBackground;
return Text(
'Assistant To The Music',
style: GoogleFonts.specialElite(
textStyle: TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
// Typewriter fonts often don't need extra letter spacing
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}
+204 -174
View File
@@ -12,196 +12,226 @@ class MiniPlayer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
final colorScheme = Theme.of(context).colorScheme;
// Don't show mini player if no track is playing or no player selected
if (currentTrack == null || selectedPlayer == null) {
return const SizedBox.shrink();
}
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
// Don't show mini player if no track is playing or no player selected
if (currentTrack == null || selectedPlayer == null) {
return const SizedBox.shrink();
}
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NowPlayingScreen(),
),
);
},
child: Container(
height: 64,
decoration: BoxDecoration(
color: const Color(0xFF2a2a2a),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// Status indicator bar
Container(
height: 2,
color: selectedPlayer.isPlaying
? Colors.white.withOpacity(0.3)
: Colors.white.withOpacity(0.1),
),
// Player content
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
final imageUrl = maProvider.getImageUrl(currentTrack, size: 96);
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NowPlayingScreen(),
),
);
},
child: Hero(
tag: HeroTags.nowPlayingBackground,
child: Material(
color: colorScheme.surface,
elevation: 0,
child: Container(
height: 64,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// Album art with Hero animation
Hero(
tag: 'now_playing_art',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: imageUrl != null
? Image.network(
imageUrl,
// Status indicator bar
Container(
height: 2,
color: selectedPlayer.isPlaying
? colorScheme.primary.withOpacity(0.7)
: colorScheme.onSurface.withOpacity(0.1),
),
// Player content
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
children: [
// Album art with Hero animation
Hero(
tag: HeroTags.nowPlayingArt,
child: Container(
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 48,
height: 48,
color: Colors.white12,
child: const Icon(
Icons.music_note_rounded,
color: Colors.white54,
size: 24,
),
);
},
)
: Container(
width: 48,
height: 48,
color: Colors.white12,
child: const Icon(
Icons.music_note_rounded,
color: Colors.white54,
size: 24,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
cacheWidth: 96,
cacheHeight: 96,
filterQuality: FilterQuality.medium,
errorBuilder: (context, error, stackTrace) {
return Container(
color: colorScheme.surfaceVariant,
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
},
)
: Container(
color: colorScheme.surfaceVariant,
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
),
),
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentTrack.name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
currentTrack.artistsString,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentTrack.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
currentTrack.artistsString,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
// Queue button
IconButton(
icon: const Icon(Icons.queue_music),
color: colorScheme.onSurface.withOpacity(0.7),
iconSize: 22,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const QueueScreen(),
),
);
},
),
// Volume control (compact mute button)
const VolumeControl(compact: true),
// Playback controls for selected player
Hero(
tag: HeroTags.nowPlayingPreviousButton,
child: Material(
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: colorScheme.onSurface,
iconSize: 26,
onPressed: () async {
try {
await maProvider.previousTrackSelectedPlayer();
} catch (e) {
print('❌ Error in previous track: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
Hero(
tag: HeroTags.nowPlayingPlayButton,
child: Material(
color: Colors.transparent,
child: AnimatedIconButton(
icon: selectedPlayer.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: colorScheme.onSurface,
iconSize: 32,
onPressed: () async {
try {
await maProvider.playPauseSelectedPlayer();
} catch (e) {
print('❌ Error in play/pause: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
Hero(
tag: HeroTags.nowPlayingNextButton,
child: Material(
color: Colors.transparent,
child: AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: colorScheme.onSurface,
iconSize: 28,
onPressed: () async {
try {
await maProvider.nextTrackSelectedPlayer();
} catch (e) {
print('❌ Error in next track: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
),
),
],
),
),
),
// Queue button
IconButton(
icon: const Icon(Icons.queue_music),
color: Colors.white70,
iconSize: 22,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const QueueScreen(),
),
);
},
),
// Volume control (compact mute button)
const VolumeControl(compact: true),
// Playback controls for selected player
AnimatedIconButton(
icon: Icons.skip_previous_rounded,
color: Colors.white,
iconSize: 26,
onPressed: () async {
try {
await maProvider.previousTrackSelectedPlayer();
} catch (e) {
print('❌ Error in previous track: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
AnimatedIconButton(
icon: selectedPlayer.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white,
iconSize: 32,
onPressed: () async {
try {
await maProvider.playPauseSelectedPlayer();
} catch (e) {
print('❌ Error in play/pause: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
AnimatedIconButton(
icon: Icons.skip_next_rounded,
color: Colors.white,
iconSize: 28,
onPressed: () async {
try {
await maProvider.nextTrackSelectedPlayer();
} catch (e) {
print('❌ Error in next track: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
},
),
],
),
),
),
],
),
),
),
);
},
);
}
}
+180 -94
View File
@@ -10,11 +10,12 @@ class PlayerSelector extends StatelessWidget {
final maProvider = context.watch<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
return IconButton(
icon: Stack(
children: [
const Icon(Icons.speaker_group_rounded),
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
if (selectedPlayer != null)
Positioned(
right: 0,
@@ -23,9 +24,9 @@ class PlayerSelector extends StatelessWidget {
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
color: Colors.green, // Keep green for "active" status or use primary
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF1a1a1a), width: 1),
border: Border.all(color: colorScheme.background, width: 1),
),
),
),
@@ -43,101 +44,186 @@ class PlayerSelector extends StatelessWidget {
) {
showModalBottomSheet(
context: context,
backgroundColor: const Color(0xFF2a2a2a),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true, // Allow tapping outside to close
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const Icon(Icons.speaker_group_rounded, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Select Player',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
onPressed: () async {
await provider.refreshPlayers();
},
),
],
),
),
const SizedBox(height: 12),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text(
'No players available',
style: TextStyle(color: Colors.white54),
),
)
else
ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
final isSelected = player.playerId == provider.selectedPlayer?.playerId;
// Use Consumer to listen for updates while sheet is open
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
// Use the fresh list of players from the provider
final currentPlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
leading: Icon(
_getPlayerIcon(player.name),
color: player.available ? Colors.white : Colors.white38,
),
title: Text(
player.name,
style: TextStyle(
color: player.available ? Colors.white : Colors.white38,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
),
subtitle: Text(
player.available
? (player.isPlaying ? 'Playing' : 'Available')
: 'Unavailable',
style: TextStyle(
color: player.isPlaying
? Colors.greenAccent
: (player.available ? Colors.white54 : Colors.white24),
fontSize: 12,
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Icon(Icons.speaker_group_rounded, color: colorScheme.onSurface),
const SizedBox(width: 12),
Text(
'Select Player',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: colorScheme.onSurfaceVariant),
onPressed: () async {
await maProvider.refreshPlayers();
},
),
],
),
),
),
trailing: isSelected
? const Icon(Icons.check_circle_rounded, color: Colors.white)
: null,
enabled: player.available,
onTap: () {
provider.selectPlayer(player);
Navigator.pop(context);
},
);
},
),
const SizedBox(height: 20),
],
const SizedBox(height: 12),
Expanded(
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.54)),
),
)
: GridView.builder(
controller: scrollController,
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.6, // Shorter cards
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: currentPlayers.length,
itemBuilder: (context, index) {
final player = currentPlayers[index];
final isSelected =
player.playerId == maProvider.selectedPlayer?.playerId;
// isOn checks if the player is powered on
final isOn = player.available && player.powered;
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
maProvider.selectPlayer(player);
Navigator.pop(context);
},
child: Container(
decoration: BoxDecoration(
// Tint whole card based on theme if selected
color: isSelected
? colorScheme.primary.withOpacity(0.15)
: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
// No border, just tint
),
child: Stack(
children: [
// Power/Status Indicator (Functional)
Positioned(
top: 0,
right: 0,
child: IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
size: 24,
color: player.available
? (isOn ? colorScheme.primary : colorScheme.onSurfaceVariant.withOpacity(0.5))
: colorScheme.onSurface.withOpacity(0.1),
),
onPressed: player.available
? () {
maProvider.togglePower(player.playerId);
// Don't close the sheet
}
: null,
),
),
// Content
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(
_getPlayerIcon(player.name),
color: player.available
? (isSelected ? colorScheme.primary : colorScheme.onSurface)
: colorScheme.onSurface.withOpacity(0.38),
size: 28,
),
const SizedBox(height: 12),
Text(
player.name,
style: TextStyle(
color: player.available
? colorScheme.onSurface
: colorScheme.onSurface.withOpacity(0.38),
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
player.available
? (player.state == 'playing'
? 'Playing'
: (player.state == 'paused'
? 'Paused'
: 'Idle'))
: 'Unavailable',
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
: colorScheme.onSurface.withOpacity(0.24),
fontSize: 12,
),
),
],
),
),
],
),
),
);
},
),
),
],
),
);
},
);
},
);
},
);
+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');
}
}
},
),
+3 -11
View File
@@ -13,10 +13,8 @@ dependencies:
# UI
cupertino_icons: ^1.0.6
google_fonts: ^6.1.0
# Audio playback
just_audio: ^0.9.36
audio_service: ^0.18.12
dynamic_color: 1.6.8
palette_generator: ^0.3.3+3
# State management
provider: ^6.1.1
@@ -28,13 +26,7 @@ dependencies:
# Storage
shared_preferences: ^2.2.2
# File handling
file_picker: ^6.1.1
path_provider: ^2.1.1
permission_handler: ^11.1.0
# Utilities
rxdart: ^0.27.7
uuid: ^4.2.1
dev_dependencies:
@@ -49,7 +41,7 @@ flutter:
assets:
- assets/images/
flutter_icons:
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/images/icon.png"