Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 d7bfb66fab fix: Reduce play button nudge to add space from skip
Play: +3px, Skip: +6px (more space between play and skip)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:56:19 +00:00
Your NameandClaude Opus 4.5 222a66db61 fix: Nudge play and skip buttons toward power
Move play and skip 6px right instead of moving power left

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:38:55 +00:00
Your NameandClaude Opus 4.5 8eec9599f3 fix: Revert to Material Icons, use Transform to close gap
- Reverted MDI icons back to Material Icons
- Used Transform.translate(-6, 0) to nudge power button left
- This closes the gap between skip and power without changing icon pack

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:37:41 +00:00
Your NameandClaude Opus 4.5 fe45a34c35 feat: Switch to MDI icons for tighter spacing
Replace Material Icons with Material Design Icons (MDI) in:
- Device list: play/pause, skip next, power
- Mini player: skip previous, play/pause, skip next
- Expanded player: shuffle, repeat, skip controls, audiobook controls

MDI icons have less built-in whitespace for more compact layouts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:21:54 +00:00
Your NameandClaude Opus 4.5 232862b164 fix: Remove gap between skip and power buttons
Set padding to zero on both buttons for minimal spacing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:07:37 +00:00
Your NameandClaude Opus 4.5 dc6004ac00 feat: Asymmetric device list animation timing
- Expand: 200ms (slower, more deliberate reveal)
- Collapse: 150ms (snappier dismiss)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:00:34 +00:00
Your NameandClaude Opus 4.5 62fc5ab2f0 fix: Reduce gap between skip and power buttons
Reduced power button horizontal padding from 4 to 2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:51:39 +00:00
Your NameandClaude Opus 4.5 807bebbab9 fix: Restore skip button to original size
Keep skip next at 28, only reduce play (28) and power (20)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:05:29 +00:00
Your NameandClaude Opus 4.5 7edf7bbfc8 fix: Reduce device selector button sizes for better alignment
- Play/Pause: 34→28 icon size, smaller container
- Skip next: 28→24 icon, reduced horizontal padding
- Power button: 28→20 (smallest of the three)
- Reduced gaps between buttons for more compact layout

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:03:26 +00:00
Your NameandClaude Opus 4.5 c4ad0a6279 fix: Match play button sizing exactly to mini player
Use SizedBox wrapper with zero padding for play button, matching
the mini player's Container approach. Also restore right margin to 4px.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:39:12 +00:00
Your NameandClaude Opus 4.5 6ebf2ed15b feat: Update device list buttons to play/pause, next, power
- Change button order from prev/play/next to play/pause/next/power
- Match mini player button sizes (play=34, skip/power=28)
- Power button always visible when player available (dimmed when off)
- Play/pause and next only shown when powered with content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:23:16 +00:00
Your NameandClaude Opus 4.5 91b713aab8 fix: Back gesture priority for device list over navigation
Add device list (player reveal) to didPopRoute() priority chain.
Previously back gesture would pop navigation routes before dismissing
the device list when on screens other than home.

Priority order is now: device list > expanded player > navigation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:55:26 +00:00
Your NameandClaude Opus 4.5 8ce567a1cf perf: Revert TweenSequence to simple CurvedAnimation for smoother FPS
TweenSequence with chained curves was causing low FPS on the mini player
to expanded player animation. Reverted to simple CurvedAnimation which
is more performant:

- Replace 3-phase TweenSequence with single CurvedAnimation (easeOutCubic)
- Reduce expand duration from 350ms to 280ms
- Simplify bounce animation back to sine-wave calculation
- Keep gesture-driven expand/collapse functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:41:23 +00:00
Your NameandClaude Opus 4.5 2081cf8552 fix: Device list swipe-down and back gesture handling
- Fix swipe-down gesture on active mini player to reveal device list
  (was blocked by gesture tracking starting immediately)
- Fix back gesture priority: device list now closes before underlying
  album/artist screens by handling PopScope at GlobalPlayerOverlay level

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:37:10 +00:00
Your NameandClaude Opus 4.5 8dae062663 fix: Correct swipe direction and faster collapse
- Fix inverted drag: swipe down now moves cards down (toward dismiss)
- Faster collapse animations:
  - Mini player: 250ms → 200ms
  - Player list: 200ms → 150ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:33:32 +00:00
Your NameandClaude Opus 4.5 59eb80c11b fix: Animation refinements based on testing feedback
- Faster collapse (250ms vs 350ms expand) for snappier feel
- Simplified bounce to single clean motion (down 10px → back)
  - Removed overshoot phases that looked like multiple bounces
  - Duration reduced to 200ms
- Fixed swipe dismiss on player list vanishing halfway
  - Reset dragOffset before dismiss() so animation starts correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:13:55 +00:00
Your NameandClaude Opus 4.5 d118dd8fe1 feat: Gesture-driven expand/collapse with keyframe animations
- Replace threshold-based expand/collapse with finger-tracking
  - Mini player now follows finger during vertical swipe
  - Snap decision based on velocity (800px/s) and position (30%)

- Add TweenSequence for phased animations
  - Phase 1 (0-40%): Quick acceleration with easeOut
  - Phase 2 (40-75%): Consistent cruise with linear
  - Phase 3 (75-100%): Settle with slight overshoot (easeOutBack)

- Fix position calculation discontinuity
  - Removed abrupt switch at t=0.1
  - Now smoothly fades out slide/bounce offsets over first 15%

- Add settle bounce when cards collapse into mini player
  - TweenSequence bounce: push down 12px → overshoot -4px → settle
  - Triggered when cards are 70% collapsed for natural "landing" feel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
Your NameandClaude Opus 4.5 3708b24d55 feat(i18n): add Spanish language support with improved picker UI
- Add complete Spanish translations (app_es.arb)
- Replace cramped SegmentedButton with ListTile + Dialog picker
- Localize player states (Off, Idle, Unavailable, Selected)
- Localize Material You description
- Scales better for multiple languages
- Bump version to 2.3.2-beta+24

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:23 +00:00
Your NameandClaude Opus 4.5 e0b4a0a184 chore: bump version to 2.3.1-beta+23
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 08:05:19 +00:00
Your NameandClaude Opus 4.5 407ac9fa7c Complete German translations for remaining hardcoded strings
- Add 14 new localization keys: search, metadataApisDescription,
  lastFmApiKey, lastFmApiKeyHint, swipeToSwitchDevice, chapterNumber,
  pcmAudio, playOn, addAlbumToQueueOn, addToQueueOn, startRadioOn,
  book, audiobookSingular, trackCount
- Update settings_screen.dart: metadata API description, Last.fm fields
- Update global_player_overlay.dart: bottom navigation bar labels
- Update expandable_player.dart: unknown author, PCM audio, swipe hint
- Update album_details_screen.dart: play on/add to queue sheet titles
- Update audiobook_detail_screen.dart: play on sheet title
- Update artist_details_screen.dart: start radio on sheet title
- Update new_library_screen.dart: singular book/audiobook forms
- Update device_selector_bar.dart: swipe to switch device hint

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:36:14 +00:00
Your NameandClaude Opus 4.5 493520ec1e Complete German translations for all remaining hardcoded strings
- Add 20+ new translation keys to app_en.arb and app_de.arb
- Update playlist_details_screen.dart: Tracks header, empty state
- Update audiobook_detail_screen.dart: Resume, About, Chapters, narrator, progress
- Update audiobook_series_screen.dart: No books in series, author comparison
- Update audiobook_author_screen.dart: Narrator display strings
- Update album_details_screen.dart: Tracks header, empty tracks state
- Update settings_screen.dart: API key labels, audiobook libraries section
- Update search_screen.dart: Artist label, no player selected
- Update queue_screen.dart: No upcoming tracks message
- Update new_library_screen.dart: Series empty state, playlist fallback
- Update library_playlists_screen.dart: Track count, playlist fallback
- Update debug_log_screen.dart: Bug report subject

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:04:10 +00:00
Your NameandClaude Opus 4.5 1ea2fa2cb6 fix(i18n): pass l10n parameter to _buildPodcastsComingSoonTab
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:34:05 +00:00
Your NameandClaude Opus 4.5 734ef3f8ed feat(i18n): add more German translations and fix picker styles
- Updated theme/language pickers to match library picker style (border radius 8, opacity 0.5)
- Added 35+ new translation keys for tooltips, settings, and UI elements
- Replaced hardcoded strings in settings, artist, album, audiobook screens
- Fixed sort/view mode tooltips across all detail screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:22:42 +00:00
Your NameandClaude Opus 4.5 2ffa0e1754 fix(i18n): add context to remaining DisconnectedState calls
Fixed DisconnectedState.full and DisconnectedState.withSettingsAction
calls in new_home_screen.dart and new_library_screen.dart.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:58:08 +00:00
Your NameandClaude Opus 4.5 c2e313dfb2 fix(i18n): add missing context parameter to EmptyState/DisconnectedState calls
Fixed all call sites that were missing the required context parameter
after the localization refactor.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:49:59 +00:00
Your NameandClaude Opus 4.5 cecea7c263 feat(i18n): localize all remaining screens and widgets
Localized files:
- screens: album_details, artist_details, audiobook_*, debug_log,
  home, library_*, new_library, playlist_details, queue
- widgets: disconnected_state, empty_state, expandable_player,
  chapters_panel

All hardcoded English strings replaced with S.of(context)! calls.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:36:51 +00:00
Your NameandClaude Opus 4.5 94085e8ed4 fix(settings): restore full-width segmented buttons for theme/language pickers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:18:14 +00:00
Your NameandClaude Opus 4.5 94101898d1 feat(i18n): add language picker and replace hardcoded strings
- Add LocaleProvider for managing app locale
- Add language picker in settings (System/EN/DE) matching library picker style
- Update theme picker to use same compact icon-bar style
- Replace hardcoded strings in login, settings, home, and search screens
- Persist language preference in SharedPreferences

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:04:31 +00:00
Your NameandClaude Opus 4.5 48bca80157 feat(i18n): add German language support infrastructure
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration for code generation
- Add English (app_en.arb) and German (app_de.arb) translation files
- Configure MaterialApp with localization delegates
- Support 150+ translatable strings across the app

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:41:26 +00:00
Your NameandClaude Opus 4.5 682cba577e fix: lazy load cached players for instant UI on app resume
- Make availablePlayers getter load from cache when empty
- Make selectedPlayer getter load from cache when null
- Update connecting banner to check hasCachedPlayers (cache service)
  instead of availablePlayers.isEmpty (timing-dependent)

This ensures mini player and device selector appear instantly when
resuming the app, without waiting for checkAndReconnect to run.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:13:05 +00:00
Your NameandClaude Opus 4.5 f38d2cba4f feat: instant player display on app resume with caching
Cache players in memory so mini player and device selector appear
instantly when resuming the app, without waiting for reconnection.

- Add player caching to CacheService (players list + selected player)
- Load cached players immediately in checkAndReconnect before connecting
- Hide "Connecting..." banner when cached players are available
- Background reconnection happens silently while UI is already usable

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:06:50 +00:00
Your NameandClaude Opus 4.5 20edbc2804 perf(player): skip builtin_player for MA 2.7.0b20+ servers
Instead of attempting 3 retries of builtin_player/register (which fail
on MA 2.7.0b20+) before falling back to Sendspin, detect the server
version upfront and go directly to Sendspin.

This saves ~2 seconds on app resume by avoiding:
- 3 failed builtin_player/register attempts
- Exponential backoff delays (500ms, 1s)
- Error handling overhead

The version check parses server_version (e.g., "2.8.0b2") and compares
against the 2.7.0b20 threshold where Sendspin was introduced.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:38:59 +00:00
Your NameandClaude Opus 4.5 e48b94e9c8 perf(player): optimize player loading on app resume
- Remove duplicate _preloadAdjacentPlayers call in checkAndReconnect
  (already called via refreshPlayers -> _loadAndSelectPlayers)
- Don't await preload in _loadAndSelectPlayers - UI should show
  immediately with cached data, preload happens in background

This fixes slow device button and mini player loading when returning
to the app, especially noticeable on slower network connections.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:34:35 +00:00
Your NameandClaude Opus 4.5 73ca6f230e chore: bump version to 2.3.0-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:19:31 +00:00
Your NameandClaude Opus 4.5 d0ab9ff618 feat(sendspin): add proxy authentication support for MA 2.7.1+
- Add auth token field and setAuthToken() method to SendspinService
- Send auth message before client/hello when connecting via HTTPS proxy
- Wait for auth_ok response before proceeding with handshake
- Provider now sets auth token from SettingsService before connecting
- Allows Sendspin to work through reverse proxies without exposing port 8927

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:02:31 +00:00
Your NameandClaude Opus 4.5 0989b322a2 fix(player): preload all tracks on app resume + preserve cached images
- Preload all player tracks in checkAndReconnect() after app resumes
- Preserve cached tracks that have images even if new event lacks image
- This ensures album art is available when swiping between players

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:06:09 +00:00
Your NameandClaude Opus 4.5 433eacfc43 fix(player): correct text width and artist parsing
Text overlap fix:
- Use collapsedWidth (with margins) instead of screenSize.width
- collapsedWidth is 24px narrower, fixing the overlap

Stale track data fix:
- Parse artist from title when title contains " - " and artist is unknown
- Don't overwrite cache if existing track has proper artist info
- Only set _currentTrack from event if not already set

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:02:41 +00:00
Your NameandClaude Opus 4.5 e24df29239 fix(player): proper text truncation in mini player
- Fix width calculation to use titleLeft position for accurate constraint
- Add softWrap: false in collapsed state to ensure ellipsis truncation
- Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (margin) = 114px
- Reserve 122px from right edge for 8px gap to controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 21:38:42 +00:00
Your NameandClaude Opus 4.5 2736b4478a fix(player): await fresh track data on player select
When resuming app after background, cached track data from player_updated
events may have incomplete artist info (e.g., "Artist - Title" in name field
with null artist). Now awaits _updatePlayerState() before notifying UI to
ensure fresh queue data is displayed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:59:11 +00:00
Your NameandClaude Opus 4.5 020a440f11 fix(player): consistent 14px secondary text and 8px control gap
- Set secondary text to 14px in both mini player and device selector bar
- Adjust text width for consistent 8px gap before player controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:45:55 +00:00
Your NameandClaude Opus 4.5 2e7682facd fix(player): match mini player artist font size to device selector bar
Change artist font size from 14px to 12px in collapsed mini player to match
the DeviceSelectorBar styling for visual consistency.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:06 +00:00
Your NameandClaude Opus 4.5 f5221b3160 chore: bump version to 2.2.1-beta+21 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:45:21 +00:00
Your NameandClaude Opus 4.5 4265b8a544 fix: Clear audiobook context when switching to music playback
When switching from audiobook to music track playback, the audiobook
context (_currentAudiobook) was not being cleared. This caused the
player UI to remain in audiobook mode (30-second skip buttons, author
instead of artist, chapters in queue) even when playing regular music.

The fix detects when media_type changes to 'track' in player_updated
events and clears the audiobook context, allowing the UI to correctly
switch to music mode.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:20:25 +00:00
Your NameandClaude Opus 4.5 014f0a4903 Fix PCM audio race condition with auto-recovery
When audio frames arrive before the stream/start message (or after
pause/release), the native PCM player isn't setup yet, causing
"must call setup first" errors and silent playback.

This fix adds auto-recovery in two places:
1. In _onAudioData: If audio arrives while paused, automatically
   re-initialize and resume playback
2. In _feedNextChunk: If feed() fails with setup error, re-initialize
   the native player and retry

This ensures audio playback starts even when binary frames race
ahead of JSON control messages on the WebSocket.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:55:13 +00:00
Your NameandClaude Opus 4.5 2f0fc03dc9 chore: bump version to 2.2.1-beta+20
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:41:21 +00:00
Your NameandClaude Opus 4.5 9ae8462c2e fix(home): use overlay for connecting banner to prevent layout shift
Changed from Column to Stack so the "Connecting..." banner overlays
content instead of pushing rows down when it appears/disappears.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 16:40:39 +00:00
44 changed files with 4156 additions and 708 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Generate code (Drift database) - name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons - name: Generate launcher icons
run: flutter pub run flutter_launcher_icons run: flutter pub run flutter_launcher_icons
+5
View File
@@ -0,0 +1,5 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false
output-class: S
+276
View File
@@ -0,0 +1,276 @@
{
"@@locale": "de",
"appTitle": "Ensemble",
"connecting": "Verbinde...",
"connected": "Verbunden",
"disconnected": "Getrennt",
"serverAddress": "Serveradresse",
"serverAddressHint": "z.B. music.example.com oder 192.168.1.100",
"yourName": "Dein Name",
"yourFirstName": "Dein Vorname",
"portOptional": "Port (Optional)",
"portHint": "z.B. 8095 oder leer lassen",
"portDescription": "Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.",
"username": "Benutzername",
"password": "Passwort",
"detectAndConnect": "Erkennen & Verbinden",
"connect": "Verbinden",
"disconnect": "Trennen",
"authServerUrlOptional": "Auth-Server URL (Optional)",
"authServerUrlHint": "z.B. auth.example.com (wenn abweichend vom Server)",
"authServerUrlDescription": "Leer lassen, wenn Authentifizierung auf demselben Server",
"detectedAuthType": "Erkannt: {authType}",
"serverRequiresAuth": "Dieser Server erfordert Authentifizierung",
"tailscaleVpnConnection": "Tailscale VPN-Verbindung",
"unencryptedConnection": "Unverschlusselte Verbindung",
"usingHttpOverTailscale": "HTTP uber Tailscale (verschlusselter Tunnel)",
"httpsFailedUsingHttp": "HTTPS fehlgeschlagen, verwende HTTP",
"httpNotEncrypted": "HTTP-Verbindung - Daten sind nicht verschlusselt",
"pleaseEnterServerAddress": "Bitte gib deine Music Assistant Serveradresse ein",
"pleaseEnterName": "Bitte gib deinen Namen ein",
"pleaseEnterValidPort": "Bitte gib eine gultige Portnummer ein (1-65535)",
"pleaseEnterCredentials": "Bitte gib Benutzername und Passwort ein",
"authFailed": "Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"maLoginFailed": "Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"connectionFailed": "Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.",
"detectingAuth": "Erkenne Authentifizierung...",
"cannotDetermineAuth": "Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.",
"noAuthentication": "Keine Authentifizierung",
"httpBasicAuth": "HTTP Basic Auth",
"authelia": "Authelia",
"musicAssistantLogin": "Music Assistant Anmeldung",
"pressBackToMinimize": "Erneut drucken zum Minimieren",
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
"favoriteAlbums": "Lieblingsalben",
"favoriteArtists": "Lieblingskünstler",
"favoriteTracks": "Lieblingssongs",
"searchMusic": "Musik suchen...",
"searchForContent": "Nach Künstlern, Alben oder Songs suchen",
"retry": "Wiederholen",
"all": "Alle",
"artists": "Künstler",
"albums": "Alben",
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
"playerQueue": "{playerName} Warteschlange",
"noPlayerSelected": "Kein Player ausgewählt",
"undo": "Rückgängig",
"removedItem": "{itemName} entfernt",
"settings": "Einstellungen",
"debugLogs": "Debug-Protokolle",
"themeMode": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"pullToRefresh": "Ziehe zum Aktualisieren der Bibliothek",
"viewDebugLogs": "Debug-Protokolle anzeigen",
"viewAllPlayers": "Alle Player anzeigen",
"copyLogs": "Protokolle kopieren",
"clearLogs": "Protokolle löschen",
"copyList": "Liste kopieren",
"close": "Schließen",
"allPlayersCount": "Alle Player ({count})",
"errorLoadingPlayers": "Fehler beim Laden der Player: {error}",
"logsCopied": "Protokolle in Zwischenablage kopiert",
"logsCleared": "Protokolle gelöscht",
"playerListCopied": "Playerliste in Zwischenablage kopiert!",
"noLogsYet": "Noch keine Protokolle",
"infoPlus": "Info+",
"warnings": "Warnungen",
"errors": "Fehler",
"showingEntries": "Zeige {filtered} von {total} Einträgen",
"thisDevice": "Dieses Gerät",
"ghostPlayer": "Geist-Player (Duplikat)",
"unavailableCorrupt": "Nicht verfügbar/Beschädigt",
"playerId": "ID: {id}",
"playerInfo": "Verfügbar: {available} | Anbieter: {provider}",
"shareBugReport": "Fehlerbericht teilen",
"moreOptions": "Weitere Optionen",
"failedToUpdateFavorite": "Favorit konnte nicht aktualisiert werden: {error}",
"noPlayersAvailable": "Keine Player verfügbar",
"albumAddedToQueue": "Album zur Warteschlange hinzugefügt",
"tracksAddedToQueue": "Songs zur Warteschlange hinzugefügt",
"play": "Abspielen",
"noPlayersFound": "Keine Player gefunden",
"startingRadio": "Starte {name} Radio",
"failedToStartRadio": "Radio konnte nicht gestartet werden: {error}",
"startingRadioOnPlayer": "Starte {name} Radio auf {playerName}",
"addedRadioToQueue": "{name} Radio zur Warteschlange hinzugefügt",
"failedToAddToQueue": "Konnte nicht zur Warteschlange hinzufügen: {error}",
"radio": "Radio",
"inLibrary": "In Bibliothek",
"noAlbumsFound": "Keine Alben gefunden",
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
"markedAsUnplayed": "{name} als ungespielt markiert",
"failedToMarkUnplayed": "Konnte nicht als ungespielt markieren: {error}",
"failedToPlay": "Abspielen fehlgeschlagen: {error}",
"markAsFinished": "Als beendet markieren",
"markAsUnplayed": "Als ungespielt markieren",
"byAuthor": "Von {author}",
"audiobookCount": "{count} Hörbuch/Hörbücher",
"loading": "Laden...",
"bookCount": "{count} Buch/Bücher",
"books": "Bücher",
"noFavoriteAudiobooks": "Keine Lieblingshörbücher",
"tapHeartAudiobook": "Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen",
"noAudiobooks": "Keine Hörbücher",
"addAudiobooksHint": "Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen",
"noFavoriteArtists": "Keine Lieblingskünstler",
"tapHeartArtist": "Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen",
"noFavoriteAlbums": "Keine Lieblingsalben",
"tapHeartAlbum": "Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen",
"noFavoritePlaylists": "Keine Lieblings-Wiedergabelisten",
"tapHeartPlaylist": "Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen",
"noFavoriteTracks": "Keine Lieblingssongs",
"longPressTrackHint": "Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen",
"loadSeries": "Serien laden",
"notConnected": "Nicht mit Music Assistant verbunden",
"notConnectedTitle": "Nicht verbunden",
"connectHint": "Verbinde dich mit deinem Music Assistant Server, um Musik zu hören",
"configureServer": "Server konfigurieren",
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
"debugConsole": "Debug-Konsole",
"copy": "Kopieren",
"clear": "Löschen",
"noLogsToCopy": "Keine Protokolle zum Kopieren",
"noDebugLogsYet": "Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.",
"showDebug": "Debug anzeigen",
"hideDebug": "Debug ausblenden",
"chapters": "Kapitel",
"noChapters": "Keine Kapitel",
"noChapterInfo": "Dieses Hörbuch hat keine Kapitelinformationen",
"errorSeeking": "Fehler beim Spulen: {error}",
"error": "Fehler: {error}",
"home": "Start",
"library": "Bibliothek",
"homeScreen": "Startbildschirm",
"metadataApis": "Metadaten-APIs",
"theme": "Design",
"materialYou": "Material You",
"adaptiveTheme": "Adaptives Design",
"language": "Sprache",
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
"sortByYear": "Nach Jahr sortieren",
"sortBySeriesOrder": "Nach Serienreihenfolge sortieren",
"listView": "Listenansicht",
"gridView": "Rasteransicht",
"noBooksInSeries": "Keine Bücher in dieser Serie gefunden",
"artist": "Künstler",
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
"showFavoriteAlbums": "Lieblingsalben anzeigen",
"showFavoriteArtists": "Lieblingskünstler anzeigen",
"showFavoriteTracks": "Lieblingssongs anzeigen",
"extractColorsFromArtwork": "Farben aus Album- und Künstlercover extrahieren",
"chooseHomeScreenRows": "Wähle welche Zeilen auf dem Startbildschirm angezeigt werden",
"addedToFavorites": "Zu Favoriten hinzugefügt",
"removedFromFavorites": "Aus Favoriten entfernt",
"unknown": "Unbekannt",
"noUpcomingTracks": "Keine weiteren Songs",
"showAll": "Alle anzeigen",
"showFavoritesOnly": "Nur Favoriten anzeigen",
"changeView": "Ansicht ändern",
"authors": "Autoren",
"series": "Serien",
"shows": "Sendungen",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast-Unterstützung kommt bald",
"playlist": "Wiedergabeliste",
"connectionError": "Verbindungsfehler",
"twoColumnGrid": "2-Spalten-Raster",
"threeColumnGrid": "3-Spalten-Raster",
"fromProviders": "Von Anbietern",
"resume": "Fortsetzen",
"about": "Über",
"inProgress": "In Bearbeitung",
"narratedBy": "Gesprochen von {narrators}",
"unknownNarrator": "Unbekannter Sprecher",
"unknownAuthor": "Unbekannter Autor",
"loadingChapters": "Lade Kapitel...",
"noChapterInfoAvailable": "Keine Kapitelinformationen verfügbar",
"percentComplete": "{percent}% abgeschlossen",
"theAudioDbApiKey": "TheAudioDB API-Schlüssel",
"theAudioDbApiKeyHint": "\"2\" für kostenlosen Zugang oder Premium-Schlüssel",
"audiobookLibraries": "Hörbuch-Bibliotheken",
"chooseAudiobookLibraries": "Wähle welche Audiobookshelf-Bibliotheken einbezogen werden",
"unknownLibrary": "Unbekannte Bibliothek",
"noSeriesFound": "Keine Serien gefunden",
"ensembleBugReport": "Ensemble Fehlerbericht",
"byOwner": "Von {owner}",
"noSeriesAvailable": "Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.",
"pullToLoadSeries": "Ziehe nach unten um Serien\nvon Music Assistant zu laden",
"search": "Suche",
"metadataApisDescription": "Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.",
"lastFmApiKey": "Last.fm API-Schlüssel",
"lastFmApiKeyHint": "Kostenlosen Schlüssel auf last.fm/api holen",
"swipeToSwitchDevice": "Wischen um Gerät zu wechseln",
"chapterNumber": "Kapitel {number}",
"pcmAudio": "PCM-Audio",
"playOn": "Abspielen auf...",
"addAlbumToQueueOn": "Album zur Warteschlange hinzufügen auf...",
"addToQueueOn": "Zur Warteschlange hinzufügen auf...",
"startRadioOn": "{name} Radio starten auf...",
"book": "Buch",
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt"
}
+1165
View File
File diff suppressed because it is too large Load Diff
+472
View File
@@ -0,0 +1,472 @@
{
"@@locale": "es",
"appTitle": "Ensemble",
"connecting": "Conectando...",
"connected": "Conectado",
"disconnected": "Desconectado",
"serverAddress": "Dirección del servidor",
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
"yourName": "Tu nombre",
"yourFirstName": "Tu nombre de pila",
"portOptional": "Puerto (Opcional)",
"portHint": "ej., 8095 o dejar en blanco",
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
"username": "Usuario",
"password": "Contraseña",
"detectAndConnect": "Detectar y conectar",
"connect": "Conectar",
"disconnect": "Desconectar",
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
"detectedAuthType": "Detectado: {authType}",
"@detectedAuthType": {
"placeholders": {
"authType": {
"type": "String"
}
}
},
"serverRequiresAuth": "Este servidor requiere autenticación",
"tailscaleVpnConnection": "Conexión VPN Tailscale",
"unencryptedConnection": "Conexión sin cifrar",
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
"pleaseEnterName": "Por favor, introduce tu nombre",
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
"detectingAuth": "Detectando autenticación...",
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
"noAuthentication": "Sin autenticación",
"httpBasicAuth": "Autenticación HTTP Basic",
"authelia": "Authelia",
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
"favoriteAlbums": "Álbumes favoritos",
"favoriteArtists": "Artistas favoritos",
"favoriteTracks": "Canciones favoritas",
"searchMusic": "Buscar música...",
"searchForContent": "Buscar artistas, álbumes o canciones",
"retry": "Reintentar",
"all": "Todo",
"artists": "Artistas",
"albums": "Álbumes",
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
"@playerQueue": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"noPlayerSelected": "Ningún reproductor seleccionado",
"undo": "Deshacer",
"removedItem": "Eliminado {itemName}",
"@removedItem": {
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"settings": "Ajustes",
"debugLogs": "Registros de depuración",
"themeMode": "Modo de tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
"viewDebugLogs": "Ver registros de depuración",
"viewAllPlayers": "Ver todos los reproductores",
"copyLogs": "Copiar registros",
"clearLogs": "Borrar registros",
"copyList": "Copiar lista",
"close": "Cerrar",
"allPlayersCount": "Todos los reproductores ({count})",
"@allPlayersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
"@errorLoadingPlayers": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"logsCopied": "Registros copiados al portapapeles",
"logsCleared": "Registros borrados",
"playerListCopied": "Lista de reproductores copiada al portapapeles",
"noLogsYet": "Aún no hay registros",
"infoPlus": "Info+",
"warnings": "Advertencias",
"errors": "Errores",
"showingEntries": "Mostrando {filtered} de {total} entradas",
"@showingEntries": {
"placeholders": {
"filtered": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"thisDevice": "Este dispositivo",
"ghostPlayer": "Reproductor fantasma (duplicado)",
"unavailableCorrupt": "No disponible/Corrupto",
"playerId": "ID: {id}",
"@playerId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
"@playerInfo": {
"placeholders": {
"available": {
"type": "String"
},
"provider": {
"type": "String"
}
}
},
"shareBugReport": "Compartir informe de errores",
"moreOptions": "Más opciones",
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
"@failedToUpdateFavorite": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noPlayersAvailable": "No hay reproductores disponibles",
"albumAddedToQueue": "Álbum añadido a la cola",
"tracksAddedToQueue": "Canciones añadidas a la cola",
"play": "Reproducir",
"noPlayersFound": "No se encontraron reproductores",
"startingRadio": "Iniciando radio de {name}",
"@startingRadio": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToStartRadio": "Error al iniciar radio: {error}",
"@failedToStartRadio": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
"@startingRadioOnPlayer": {
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"addedRadioToQueue": "Radio de {name} añadida a la cola",
"@addedRadioToQueue": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToAddToQueue": "Error al añadir a la cola: {error}",
"@failedToAddToQueue": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"radio": "Radio",
"inLibrary": "En la biblioteca",
"noAlbumsFound": "No se encontraron álbumes",
"playing": "Reproduciendo {name}",
"@playing": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"playingTrack": "Reproduciendo canción",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkFinished": "Error al marcar como terminado: {error}",
"@failedToMarkFinished": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markedAsUnplayed": "{name} marcado como no reproducido",
"@markedAsUnplayed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
"@failedToMarkUnplayed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlay": "Error al reproducir: {error}",
"@failedToPlay": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markAsFinished": "Marcar como terminado",
"markAsUnplayed": "Marcar como no reproducido",
"byAuthor": "Por {author}",
"@byAuthor": {
"placeholders": {
"author": {
"type": "String"
}
}
},
"audiobookCount": "{count} audiolibro(s)",
"@audiobookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"loading": "Cargando...",
"bookCount": "{count} libro(s)",
"@bookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"books": "Libros",
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
"noAudiobooks": "No hay audiolibros",
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
"noFavoriteArtists": "No hay artistas favoritos",
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
"noFavoriteAlbums": "No hay álbumes favoritos",
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
"noFavoriteTracks": "No hay canciones favoritas",
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
"loadSeries": "Cargar series",
"notConnected": "No conectado a Music Assistant",
"notConnectedTitle": "No conectado",
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
"configureServer": "Configurar servidor",
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
"debugConsole": "Consola de depuración",
"copy": "Copiar",
"clear": "Borrar",
"noLogsToCopy": "No hay registros para copiar",
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
"showDebug": "Mostrar depuración",
"hideDebug": "Ocultar depuración",
"chapters": "Capítulos",
"noChapters": "Sin capítulos",
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
"errorSeeking": "Error al buscar: {error}",
"@errorSeeking": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"error": "Error: {error}",
"@error": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"home": "Inicio",
"library": "Biblioteca",
"homeScreen": "Pantalla de inicio",
"metadataApis": "APIs de metadatos",
"theme": "Tema",
"materialYou": "Material You",
"adaptiveTheme": "Tema adaptativo",
"language": "Idioma",
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
"sortBySeriesOrder": "Ordenar por orden de serie",
"listView": "Vista de lista",
"gridView": "Vista de cuadrícula",
"noBooksInSeries": "No se encontraron libros en esta serie",
"artist": "Artista",
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
"addedToFavorites": "Añadido a favoritos",
"removedFromFavorites": "Eliminado de favoritos",
"unknown": "Desconocido",
"noUpcomingTracks": "No hay canciones próximas",
"showAll": "Mostrar todo",
"showFavoritesOnly": "Mostrar solo favoritos",
"changeView": "Cambiar vista",
"authors": "Autores",
"series": "Series",
"shows": "Programas",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
"playlist": "Lista de reproducción",
"connectionError": "Error de conexión",
"twoColumnGrid": "Cuadrícula de 2 columnas",
"threeColumnGrid": "Cuadrícula de 3 columnas",
"fromProviders": "De proveedores",
"resume": "Continuar",
"about": "Acerca de",
"inProgress": "En progreso",
"narratedBy": "Narrado por {narrators}",
"@narratedBy": {
"placeholders": {
"narrators": {
"type": "String"
}
}
},
"unknownNarrator": "Narrador desconocido",
"unknownAuthor": "Autor desconocido",
"loadingChapters": "Cargando capítulos...",
"noChapterInfoAvailable": "No hay información de capítulos disponible",
"percentComplete": "{percent}% completado",
"@percentComplete": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"theAudioDbApiKey": "Clave API de TheAudioDB",
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
"audiobookLibraries": "Bibliotecas de audiolibros",
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
"unknownLibrary": "Biblioteca desconocida",
"noSeriesFound": "No se encontraron series",
"ensembleBugReport": "Informe de errores de Ensemble",
"byOwner": "Por {owner}",
"@byOwner": {
"placeholders": {
"owner": {
"type": "String"
}
}
},
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
"search": "Buscar",
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"pcmAudio": "Audio PCM",
"playOn": "Reproducir en...",
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
"addToQueueOn": "Añadir a la cola en...",
"startRadioOn": "Iniciar radio de {name} en...",
"@startRadioOn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"book": "libro",
"audiobookSingular": "audiolibro",
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
"@trackCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado"
}
+27 -3
View File
@@ -3,6 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'providers/locale_provider.dart';
import 'providers/music_assistant_provider.dart'; import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart'; import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
@@ -87,6 +90,7 @@ class MusicAssistantApp extends StatefulWidget {
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver { class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _musicProvider; late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider; late ThemeProvider _themeProvider;
late LocaleProvider _localeProvider;
final _hardwareVolumeService = HardwareVolumeService(); final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub; StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub; StreamSubscription? _volumeDownSub;
@@ -101,6 +105,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
super.initState(); super.initState();
_musicProvider = MusicAssistantProvider(); _musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider(); _themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl(); _initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception // Listen to player selection changes to toggle volume interception
@@ -167,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override @override
Future<bool> didPopRoute() async { Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it // Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture // Priority order: device list > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) { if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer(); GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it return true; // We handled it, don't let Navigator process it
} }
// Let Navigator handle the back gesture normally // Let Navigator handle the back gesture normally
return super.didPopRoute(); return super.didPopRoute();
} }
@@ -197,9 +211,10 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
providers: [ providers: [
ChangeNotifierProvider.value(value: _musicProvider), ChangeNotifierProvider.value(value: _musicProvider),
ChangeNotifierProvider.value(value: _themeProvider), ChangeNotifierProvider.value(value: _themeProvider),
ChangeNotifierProvider.value(value: _localeProvider),
], ],
child: Consumer<ThemeProvider>( child: Consumer2<ThemeProvider, LocaleProvider>(
builder: (context, themeProvider, _) { builder: (context, themeProvider, localeProvider, _) {
return FutureBuilder<(ColorScheme, ColorScheme)?>( return FutureBuilder<(ColorScheme, ColorScheme)?>(
future: themeProvider.useMaterialTheme future: themeProvider.useMaterialTheme
? SystemThemeHelper.getSystemColorSchemes() ? SystemThemeHelper.getSystemColorSchemes()
@@ -228,6 +243,15 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
navigatorKey: navigationProvider.navigatorKey, navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble', title: 'Ensemble',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Localization
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
themeMode: themeProvider.themeMode, themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme), theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme), darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class LocaleProvider extends ChangeNotifier {
Locale? _locale; // null = system default
LocaleProvider() {
_loadLocale();
}
Locale? get locale => _locale;
Future<void> _loadLocale() async {
final savedLocale = await SettingsService.getLocale();
if (savedLocale != null) {
_locale = Locale(savedLocale);
}
notifyListeners();
}
Future<void> setLocale(Locale? locale) async {
_locale = locale;
await SettingsService.saveLocale(locale?.languageCode);
notifyListeners();
}
/// Returns the display name for a locale
static String getDisplayName(String? languageCode, BuildContext context) {
switch (languageCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case null:
return 'System';
default:
return languageCode;
}
}
}
+361 -45
View File
@@ -109,10 +109,28 @@ class MusicAssistantProvider with ChangeNotifier {
/// Current sync status /// Current sync status
SyncStatus get syncStatus => SyncService.instance.status; SyncStatus get syncStatus => SyncService.instance.status;
Player? get selectedPlayer => _selectedPlayer; /// Selected player - loads from cache if not yet set
List<Player> get availablePlayers => _availablePlayers; Player? get selectedPlayer {
if (_selectedPlayer == null && _cacheService.getCachedSelectedPlayer() != null) {
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
}
return _selectedPlayer;
}
/// Available players - loads from cache for instant UI display
List<Player> get availablePlayers {
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_logger.log('⚡ Loaded ${_availablePlayers.length} players from cache (lazy)');
}
return _availablePlayers;
}
Track? get currentTrack => _currentTrack; Track? get currentTrack => _currentTrack;
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook /// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook; Audiobook? get currentAudiobook => _currentAudiobook;
@@ -402,6 +420,30 @@ class MusicAssistantProvider with ChangeNotifier {
return; return;
} }
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId,
);
} catch (e) {
_selectedPlayer = _availablePlayers.first;
}
} else {
_selectedPlayer = _availablePlayers.first;
}
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly');
notifyListeners(); // Update UI immediately with cached data
}
if (_connectionState != MAConnectionState.connected && if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) { _connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl'); _logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
@@ -416,6 +458,7 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
await refreshPlayers(); await refreshPlayers();
await _updatePlayerState(); await _updatePlayerState();
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed'); _logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) { } catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e'); _logger.log('🔄 Connection verification failed, reconnecting: $e');
@@ -486,6 +529,76 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
/// Parse server version into components for version comparisons
/// Returns null if version cannot be parsed
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
final serverInfo = _api?.serverInfo;
if (serverInfo == null) return null;
final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return null;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr);
if (match == null) return null;
return (
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
);
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.7.0b20
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7
if (patch > 0) return true;
if (patch < 0) return false;
// patch == 0, so version is 2.7.0 - need beta >= 20
if (beta == null) return true; // 2.7.0 release is newer than 2.7.0b20
return beta >= 20;
}
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
/// or manually configure reverse proxy routing to port 8927
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.8.0
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 8) return true;
if (minor < 8) return false;
// minor == 8, any 2.8.x version has the proxy
return true;
}
/// Get the server version string for logging
String _getServerVersionString() {
final serverInfo = _api?.serverInfo;
return serverInfo?['server_version'] as String? ?? 'unknown';
}
Future<void> _registerLocalPlayer() async { Future<void> _registerLocalPlayer() async {
if (_api == null) return; if (_api == null) return;
@@ -504,6 +617,26 @@ class MusicAssistantProvider with ChangeNotifier {
final name = await SettingsService.getLocalPlayerName(); final name = await SettingsService.getLocalPlayerName();
// Check if server uses Sendspin (MA 2.7.0b20+) - skip builtin_player entirely
if (_serverUsesSendspin()) {
_logger.log('📡 Server uses Sendspin (MA 2.7.0b20+), skipping builtin_player');
_builtinPlayerAvailable = false;
final sendspinSuccess = await _connectViaSendspin();
if (sendspinSuccess) {
_logger.log('✅ Connected via Sendspin - local player available');
_startReportingLocalPlayerState();
} else {
_logger.log('⚠️ Sendspin connection failed - local player unavailable');
}
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
}
final existingPlayers = await _api!.getPlayers(); final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull; final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
@@ -569,18 +702,30 @@ class MusicAssistantProvider with ChangeNotifier {
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+) /// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
/// This is the replacement for builtin_player when that API is not available. /// This is the replacement for builtin_player when that API is not available.
/// ///
/// Connection strategy: /// Connection strategy depends on MA version and network:
/// 1. If server is HTTPS, try external wss://{server}/sendspin first /// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// 2. Get local_ws_url from API and try that /// - MA 2.7.x: NO proxy, must either:
/// 3. WebRTC fallback as last resort (requires TURN servers) /// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
Future<bool> _connectViaSendspin() async { Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false; if (_api == null || _serverUrl == null) return false;
try { try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
// Initialize Sendspin service // Initialize Sendspin service
_sendspinService?.dispose(); _sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); _sendspinService = SendspinService(_serverUrl!);
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
_logger.log('Sendspin: Auth token set for proxy authentication');
}
// Initialize PCM audio player for raw audio streaming // Initialize PCM audio player for raw audio streaming
_pcmAudioPlayer?.dispose(); _pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer(); _pcmAudioPlayer = PcmAudioPlayer();
@@ -603,42 +748,59 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd; _sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId(); final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Strategy 1: If server is HTTPS, try external wss:// first // Parse server URL to determine connection strategy
final isHttps = _serverUrl!.startsWith('https://') || final serverUri = Uri.parse(_serverUrl!.startsWith('http')
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':')); ? _serverUrl!
: 'https://$_serverUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
return true;
}
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path in case user has a local reverse proxy
_logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
if (proxyConnected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
return true;
}
}
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
if (hasProxy) {
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
} else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
if (isHttps) {
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
final connected = await _sendspinService!.connect(); final connected = await _sendspinService!.connect();
if (connected) { if (connected) {
_sendspinConnected = true; _sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external URL'); _logger.log('✅ Sendspin: Connected via external proxy');
return true; return true;
} }
_logger.log('⚠️ Sendspin: External connection failed, trying local URL'); _logger.log('⚠️ Sendspin: External proxy connection failed');
} }
// Strategy 2: Get local_ws_url from API and try that // All strategies failed - provide version-specific guidance
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId); _logger.log('❌ Sendspin: All connection strategies failed');
if (connectionInfo != null) { _logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
final localWsUrl = connectionInfo['local_ws_url'] as String?;
if (localWsUrl != null) {
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local URL');
return true;
}
}
}
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
// For now, just log that we'd need WebRTC - implementing full WebRTC
// would require additional dependencies (flutter_webrtc)
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
return false; return false;
} catch (e) { } catch (e) {
@@ -647,6 +809,29 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
/// Log troubleshooting guidance based on setup
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
if (isLocalIp) {
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
_logger.log(' ports:');
_logger.log(' - "8095:8095"');
_logger.log(' - "8927:8927" # Required for Sendspin');
} else if (!hasProxy) {
// MA 2.7.x without built-in proxy
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
_logger.log('ℹ️ OPTIONS:');
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else {
// MA 2.8.0+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
}
}
/// Handle Sendspin play command /// Handle Sendspin play command
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async { void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
_logger.log('🎵 Sendspin: Play command received'); _logger.log('🎵 Sendspin: Play command received');
@@ -1044,6 +1229,13 @@ class MusicAssistantProvider with ChangeNotifier {
if (currentMedia != null) { if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?; final mediaType = currentMedia['media_type'] as String?;
// Clear audiobook context when switching to music (track) playback
if (mediaType == 'track' && _currentAudiobook != null) {
_logger.log('📚 Media type changed to track - clearing audiobook context');
clearCurrentAudiobook();
}
if (mediaType != 'flow_stream') { if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt(); final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?; final albumName = currentMedia['album'] as String?;
@@ -1075,23 +1267,53 @@ class MusicAssistantProvider with ChangeNotifier {
}; };
} }
// Parse artist from title if artist is missing but title contains " - "
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
var artistName = currentMedia['artist'] as String?;
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
final parts = trackTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
trackTitle = parts.sublist(1).join(' - ').trim();
}
}
artistName ??= 'Unknown Artist';
final trackFromEvent = Track( final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '', itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library', provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track', name: trackTitle,
uri: currentMedia['uri'] as String?, uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null, duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')], artists: [Artist(itemId: '', provider: 'library', name: artistName)],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null, album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata, metadata: metadata,
); );
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) { // Only cache if we don't already have better data from queue
_currentTrack = trackFromEvent; final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
final existingHasProperArtist = existingTrack != null &&
existingTrack.artistsString != 'Unknown Artist' &&
!existingTrack.name.contains(' - ');
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
// Keep existing if it has proper artist OR has image that new one lacks
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
if (!keepExisting) {
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
} else {
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
} }
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}'); // For selected player, _updatePlayerState() is already called above which fetches queue data
// Only update _currentTrack here if we don't have it yet (initial load)
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
_currentTrack = trackFromEvent;
}
notifyListeners(); notifyListeners();
} }
@@ -1566,7 +1788,8 @@ class MusicAssistantProvider with ChangeNotifier {
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); _availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched(); // Cache players for instant display on app resume
_cacheService.setCachedPlayers(_availablePlayers);
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available'); _logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
@@ -1624,8 +1847,9 @@ class MusicAssistantProvider with ChangeNotifier {
selectPlayer(playerToSelect); selectPlayer(playerToSelect);
} }
// Await preload so track data and images are ready for swipe gestures // Preload track data and images in background for swipe gestures
await _preloadAdjacentPlayers(preloadAll: true); // Don't await - UI should show immediately with cached data
unawaited(_preloadAdjacentPlayers(preloadAll: true));
} catch (e) { } catch (e) {
ErrorHandler.logError('Load and select players', e); ErrorHandler.logError('Load and select players', e);
} }
@@ -1634,6 +1858,8 @@ class MusicAssistantProvider with ChangeNotifier {
void selectPlayer(Player player, {bool skipNotify = false}) async { void selectPlayer(Player player, {bool skipNotify = false}) async {
_selectedPlayer = player; _selectedPlayer = player;
// Cache for instant display on app resume
_cacheService.setCachedSelectedPlayer(player);
SettingsService.setLastSelectedPlayerId(player.playerId); SettingsService.setLastSelectedPlayerId(player.playerId);
// Immediately set currentTrack from cache to avoid flash during player switch // Immediately set currentTrack from cache to avoid flash during player switch
@@ -1742,6 +1968,11 @@ class MusicAssistantProvider with ChangeNotifier {
_preloadAdjacentPlayers(); _preloadAdjacentPlayers();
// Immediately fetch fresh track data from queue to avoid showing stale cache
// This is important when resuming app after being in background - cached data
// from player_updated events may have incomplete artist info
await _updatePlayerState();
if (!skipNotify) { if (!skipNotify) {
notifyListeners(); notifyListeners();
} }
@@ -2008,6 +2239,26 @@ class MusicAssistantProvider with ChangeNotifier {
await Future.wait( await Future.wait(
players.map((player) => _preloadPlayerTrack(player)), players.map((player) => _preloadPlayerTrack(player)),
); );
// After preloading, update _currentTrack from cache if it has better data
// This fixes the issue where mini player shows wrong info but device list is correct
if (_selectedPlayer != null && _currentTrack != null) {
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
final cachedHasImage = cachedTrack.metadata?['images'] != null;
final currentHasImage = _currentTrack!.metadata?['images'] != null;
final cachedHasArtist = cachedTrack.artistsString.isNotEmpty &&
cachedTrack.artistsString != 'Unknown Artist';
final currentHasArtist = _currentTrack!.artistsString.isNotEmpty &&
_currentTrack!.artistsString != 'Unknown Artist';
if ((cachedHasImage && !currentHasImage) || (cachedHasArtist && !currentHasArtist)) {
_currentTrack = cachedTrack;
_logger.log('🎵 Updated currentTrack from cache with better metadata');
notifyListeners();
}
}
}
} }
void _startPlayerStatePolling() { void _startPlayerStatePolling() {
@@ -2082,12 +2333,56 @@ class MusicAssistantProvider with ChangeNotifier {
final queue = await getQueue(_selectedPlayer!.playerId); final queue = await getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) { if (queue != null && queue.currentItem != null) {
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null || final trackChanged = _currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri || _currentTrack!.uri != queueTrack.uri ||
_currentTrack!.name != queue.currentItem!.track.name; _currentTrack!.name != queueTrack.name;
if (trackChanged) { if (trackChanged) {
_currentTrack = queue.currentItem!.track; // Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
final queueHasImage = queueTrack.metadata?['images'] != null;
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
final cachedHasProperArtist = cachedTrack?.artistsString.isNotEmpty == true &&
cachedTrack?.artistsString != 'Unknown Artist' &&
!cachedTrack!.name.contains(' - '); // Proper track doesn't have artist in name
final queueHasProperArtist = queueTrack.artistsString.isNotEmpty &&
queueTrack.artistsString != 'Unknown Artist' &&
!queueTrack.name.contains(' - ');
// Also check if _currentTrack (set from cache in selectPlayer) has good data
final currentHasImage = _currentTrack?.metadata?['images'] != null;
final currentHasProperArtist = _currentTrack?.artistsString.isNotEmpty == true &&
_currentTrack?.artistsString != 'Unknown Artist' &&
!(_currentTrack?.name.contains(' - ') ?? false);
// Match by URI - name might differ if queue has malformed data
final isSameTrackAsCached = cachedTrack?.uri == queueTrack.uri;
final cachedHasBetterData = (cachedHasImage && !queueHasImage) ||
(cachedHasProperArtist && !queueHasProperArtist);
// Queue track has bad metadata if it lacks image AND lacks proper artist
final queueHasBadMetadata = !queueHasImage && !queueHasProperArtist;
// Current track (from cache at selectPlayer) has good metadata
final currentHasGoodMetadata = currentHasImage || currentHasProperArtist;
if (isSameTrackAsCached && cachedHasBetterData) {
// Use cached track which has better metadata
_currentTrack = cachedTrack;
_logger.log('🎵 Using cached track with better metadata for ${cachedTrack!.name}');
} else if (queueHasBadMetadata && currentHasGoodMetadata) {
// Queue has bad data, but _currentTrack (set from cache) is good - keep it
_logger.log('🎵 Keeping currentTrack - queue has bad metadata but current is good');
// Don't change _currentTrack, keep the good data
} else {
_currentTrack = queueTrack;
// Update cache if queue track has good metadata
if (queueHasImage || queueHasProperArtist) {
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
}
}
stateChanged = true; stateChanged = true;
// Clear audiobook context if switched to a different media item // Clear audiobook context if switched to a different media item
@@ -2823,4 +3118,25 @@ class MusicAssistantProvider with ChangeNotifier {
_api?.dispose(); _api?.dispose();
super.dispose(); super.dispose();
} }
// ============================================================================
// UTILITY METHODS
// ============================================================================
/// Check if a hostname is a local/private network address
bool _isLocalNetworkHost(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
}
} }
+1
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
+27 -26
View File
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget { class AlbumDetailsScreen extends StatefulWidget {
@@ -171,7 +172,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites', _isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
@@ -182,7 +183,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to update favorite: $e'), content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -257,7 +258,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
!currentFavorite ? 'Added to favorites' : 'Removed from favorites', !currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
@@ -268,7 +269,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to update favorite: $e'), content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -300,7 +301,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player // Use the selected player
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player == null) { if (player == null) {
_showError('No player selected'); _showError(S.of(context)!.noPlayerSelected);
return; return;
} }
@@ -326,7 +327,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player // Use the selected player
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player == null) { if (player == null) {
_showError('No player selected'); _showError(S.of(context)!.noPlayerSelected);
return; return;
} }
@@ -362,14 +363,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Add album to queue on...', S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (players.isEmpty) if (players.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Text('No players available'), child: Text(S.of(context)!.noPlayersAvailable),
) )
else else
Flexible( Flexible(
@@ -397,9 +398,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_logger.log('Album added to queue on ${player.name}'); _logger.log('Album added to queue on ${player.name}');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Album added to queue'), content: Text(S.of(context)!.albumAddedToQueue),
duration: Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
@@ -442,14 +443,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Add to queue on...', S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (players.isEmpty) if (players.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Text('No players available'), child: Text(S.of(context)!.noPlayersAvailable),
) )
else else
Flexible( Flexible(
@@ -476,9 +477,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Tracks added to queue'), content: Text(S.of(context)!.tracksAddedToQueue),
duration: Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
@@ -763,7 +764,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum, onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'), label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
@@ -837,7 +838,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Tracks', S.of(context)!.tracks,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -858,7 +859,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Text( child: Text(
'No tracks found', S.of(context)!.noTracksFound,
style: TextStyle( style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54), color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16, fontSize: 16,
@@ -1089,14 +1090,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Play on...', S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: players.isEmpty child: players.isEmpty
? const Center( ? Center(
child: Text('No players available'), child: Text(S.of(context)!.noPlayersAvailable),
) )
: ListView.builder( : ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16), padding: EdgeInsets.only(bottom: bottomPadding + 16),
+27 -26
View File
@@ -12,6 +12,7 @@ import '../services/metadata_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
class ArtistDetailsScreen extends StatefulWidget { class ArtistDetailsScreen extends StatefulWidget {
final Artist artist; final Artist artist;
@@ -211,7 +212,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites', _isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
@@ -222,7 +223,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to update favorite: $e'), content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -236,7 +237,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) { if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
@@ -246,7 +247,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Starting ${widget.artist.name} radio'), content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -256,7 +257,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to start radio: $e'), content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -282,14 +283,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Start ${widget.artist.name} radio on...', S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (players.isEmpty) if (players.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Text('No players available'), child: Text(S.of(context)!.noPlayersAvailable),
) )
else else
Flexible( Flexible(
@@ -311,7 +312,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Starting ${widget.artist.name} radio on ${player.name}'), content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -321,7 +322,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to start radio: $e'), content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -345,7 +346,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) { if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
@@ -356,7 +357,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Added ${widget.artist.name} radio to queue'), content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -366,7 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to add to queue: $e'), content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -583,7 +584,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _startArtistRadio, onPressed: _startArtistRadio,
icon: const Icon(Icons.radio), icon: const Icon(Icons.radio),
label: const Text('Radio'), label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
@@ -669,7 +670,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Text( child: Text(
'No albums found', S.of(context)!.noAlbumsFound,
style: TextStyle( style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54), color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16, fontSize: 16,
@@ -686,7 +687,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Row( child: Row(
children: [ children: [
Text( Text(
'In Library', S.of(context)!.inLibrary,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -700,7 +701,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
color: colorScheme.primary, color: colorScheme.primary,
size: 20, size: 20,
), ),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically', tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -718,10 +719,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
size: 20, size: 20,
), ),
tooltip: _viewMode == 'grid2' tooltip: _viewMode == 'grid2'
? '3-column grid' ? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3' : _viewMode == 'grid3'
? 'List view' ? S.of(context)!.listView
: '2-column grid', : S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -742,7 +743,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Row( child: Row(
children: [ children: [
Text( Text(
'From Providers', S.of(context)!.fromProviders,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -757,7 +758,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
color: colorScheme.primary, color: colorScheme.primary,
size: 20, size: 20,
), ),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically', tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -774,10 +775,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
size: 20, size: 20,
), ),
tooltip: _viewMode == 'grid2' tooltip: _viewMode == 'grid2'
? '3-column grid' ? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3' : _viewMode == 'grid3'
? 'List view' ? S.of(context)!.listView
: '2-column grid', : S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
+11 -10
View File
@@ -12,6 +12,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
class AudiobookAuthorScreen extends StatefulWidget { class AudiobookAuthorScreen extends StatefulWidget {
@@ -218,7 +219,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${_audiobooks.length} ${_audiobooks.length == 1 ? 'audiobook' : 'audiobooks'}', S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith( style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
@@ -234,7 +235,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
child: Row( child: Row(
children: [ children: [
Text( Text(
'Audiobooks', S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -248,7 +249,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
color: colorScheme.primary, color: colorScheme.primary,
size: 20, size: 20,
), ),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically', tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -266,10 +267,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
size: 20, size: 20,
), ),
tooltip: _viewMode == 'grid2' tooltip: _viewMode == 'grid2'
? '3-column grid' ? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3' : _viewMode == 'grid3'
? 'List view' ? S.of(context)!.listView
: '2-column grid', : S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -405,9 +406,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
), ),
), ),
if (book.narratorsString != 'Unknown Narrator') if (book.narratorsString != S.of(context)!.unknownNarrator)
Text( Text(
'Narrated by ${book.narratorsString}', S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
@@ -488,8 +489,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
), ),
subtitle: Text( subtitle: Text(
book.narratorsString != 'Unknown Narrator' book.narratorsString != S.of(context)!.unknownNarrator
? 'Narrated by ${book.narratorsString}' ? S.of(context)!.narratedBy(book.narratorsString)
: book.duration != null : book.duration != null
? _formatDuration(book.duration!) ? _formatDuration(book.duration!)
: '', : '',
+25 -24
View File
@@ -10,6 +10,7 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class AudiobookDetailScreen extends StatefulWidget { class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook; final Audiobook audiobook;
@@ -172,7 +173,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites', _isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
@@ -183,7 +184,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to update favorite: $e'), content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -215,13 +216,13 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Play on...', S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: players.isEmpty child: players.isEmpty
? const Center(child: Text('No players available')) ? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder( : ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16), padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length, itemCount: players.length,
@@ -287,7 +288,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${_audiobook.name} marked as finished'), content: Text(S.of(context)!.markedAsFinished(_audiobook.name)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -297,7 +298,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to mark as finished: $e'), content: Text(S.of(context)!.failedToMarkFinished(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -338,7 +339,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${_audiobook.name} marked as unplayed'), content: Text(S.of(context)!.markedAsUnplayed(_audiobook.name)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -348,7 +349,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to mark as unplayed: $e'), content: Text(S.of(context)!.failedToMarkUnplayed(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -362,7 +363,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (selectedPlayer == null) { if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
@@ -392,7 +393,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${widget.audiobook.name}'), content: Text(S.of(context)!.playing(widget.audiobook.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -402,7 +403,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to play: $e'), content: Text(S.of(context)!.failedToPlay(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -489,7 +490,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
size: 20, size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text('Mark as Finished'), Text(S.of(context)!.markAsFinished),
], ],
), ),
), ),
@@ -503,7 +504,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
size: 20, size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text('Mark as Unplayed'), Text(S.of(context)!.markAsUnplayed),
], ],
), ),
), ),
@@ -580,17 +581,17 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Author // Author
Text( Text(
'By ${book.authorsString}', S.of(context)!.byAuthor(book.authorsString),
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8), color: colorScheme.onSurface.withOpacity(0.8),
), ),
), ),
// Narrator // Narrator
if (book.narratorsString != 'Unknown Narrator') ...[ if (book.narratorsString != S.of(context)!.unknownNarrator) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Narrated by ${book.narratorsString}', S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
@@ -624,7 +625,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${(book.progress * 100).toInt()}% complete', S.of(context)!.percentComplete((book.progress * 100).toInt()),
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
@@ -662,7 +663,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
startPositionMs: hasResumePosition ? book.resumePositionMs : null, startPositionMs: hasResumePosition ? book.resumePositionMs : null,
), ),
icon: Icon(hasResumePosition ? Icons.play_arrow : Icons.play_arrow), icon: Icon(hasResumePosition ? Icons.play_arrow : Icons.play_arrow),
label: Text(hasResumePosition ? 'Resume' : 'Play'), label: Text(hasResumePosition ? S.of(context)!.resume : S.of(context)!.play),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary, backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
@@ -740,7 +741,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (book.description != null && book.description!.isNotEmpty) ...[ if (book.description != null && book.description!.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'About', S.of(context)!.about,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -771,7 +772,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Chapters Header // Chapters Header
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Chapters', S.of(context)!.chapters,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -815,7 +816,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Loading chapters...', S.of(context)!.loadingChapters,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
@@ -853,7 +854,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Center( child: Center(
child: Text( child: Text(
'No chapter information available', S.of(context)!.noChapterInfoAvailable,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
@@ -914,7 +915,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
subtitle: isInProgress subtitle: isInProgress
? Text( ? Text(
'In progress', S.of(context)!.inProgress,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
@@ -972,7 +973,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [ children: [
const Icon(Icons.play_arrow, size: 20), const Icon(Icons.play_arrow, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(isInProgress ? 'Resume' : 'Play'), Text(isInProgress ? S.of(context)!.resume : S.of(context)!.play),
], ],
), ),
), ),
+14 -12
View File
@@ -11,6 +11,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
import '../l10n/app_localizations.dart';
class AudiobookSeriesScreen extends StatefulWidget { class AudiobookSeriesScreen extends StatefulWidget {
final AudiobookSeries series; final AudiobookSeries series;
@@ -168,7 +169,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
if (maProvider.api == null) { if (maProvider.api == null) {
setState(() { setState(() {
_error = 'Not connected to Music Assistant'; _error = S.of(context)!.notConnected;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -270,6 +271,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>(); final maProvider = context.watch<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -335,8 +337,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_isLoading _isLoading
? 'Loading...' ? l10n.loading
: '${_audiobooks.length} ${_audiobooks.length == 1 ? 'book' : 'books'}', : l10n.bookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith( style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
@@ -353,7 +355,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: Row( child: Row(
children: [ children: [
Text( Text(
'Books', l10n.books,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -367,7 +369,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
color: colorScheme.primary, color: colorScheme.primary,
size: 20, size: 20,
), ),
tooltip: _sortOrder == 'series' ? 'Sort alphabetically' : 'Sort by series order', tooltip: _sortOrder == 'series' ? S.of(context)!.sortAlphabetically : S.of(context)!.sortBySeriesOrder,
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -385,10 +387,10 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
size: 20, size: 20,
), ),
tooltip: _viewMode == 'grid2' tooltip: _viewMode == 'grid2'
? '3-column grid' ? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3' : _viewMode == 'grid3'
? 'List view' ? S.of(context)!.listView
: '2-column grid', : S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -416,7 +418,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _loadSeriesBooks, onPressed: _loadSeriesBooks,
child: const Text('Retry'), child: Text(l10n.retry),
), ),
], ],
), ),
@@ -432,7 +434,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
size: 48, color: colorScheme.onSurfaceVariant), size: 48, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No books found in this series', S.of(context)!.noBooksInSeries,
style: TextStyle(color: colorScheme.onSurfaceVariant), style: TextStyle(color: colorScheme.onSurfaceVariant),
), ),
], ],
@@ -708,7 +710,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
), ),
), ),
), ),
if (book.authorsString.isNotEmpty && book.authorsString != 'Unknown Author') if (book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor)
Text( Text(
book.authorsString, book.authorsString,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
@@ -790,7 +792,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
), ),
), ),
subtitle: Text( subtitle: Text(
book.authorsString.isNotEmpty && book.authorsString != 'Unknown Author' book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor
? book.authorsString ? book.authorsString
: book.duration != null : book.duration != null
? _formatDuration(book.duration!) ? _formatDuration(book.duration!)
+46 -45
View File
@@ -5,6 +5,7 @@ import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
class DebugLogScreen extends StatefulWidget { class DebugLogScreen extends StatefulWidget {
const DebugLogScreen({super.key}); const DebugLogScreen({super.key});
@@ -42,10 +43,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
void _copyLogs() { void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs())); Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Logs copied to clipboard'), content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
@@ -58,7 +59,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
await Share.share( await Share.share(
report, report,
subject: 'Ensemble Bug Report', subject: S.of(context)!.ensembleBugReport,
); );
} finally { } finally {
if (mounted) { if (mounted) {
@@ -72,9 +73,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.clear(); _logger.clear();
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Logs cleared'), content: Text(S.of(context)!.logsCleared),
duration: Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
@@ -93,7 +94,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF2a2a2a), backgroundColor: const Color(0xFF2a2a2a),
title: Text( title: Text(
'All Players (${allPlayers.length})', S.of(context)!.allPlayersCount(allPlayers.length),
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
content: SizedBox( content: SizedBox(
@@ -153,27 +154,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'ID: ${player.playerId}', S.of(context)!.playerId(player.playerId),
style: const TextStyle(color: Colors.white70, fontSize: 11), style: const TextStyle(color: Colors.white70, fontSize: 11),
), ),
Text( Text(
'Available: ${player.available} | Provider: ${player.provider}', S.of(context)!.playerInfo(player.available.toString(), player.provider ?? 'unknown'),
style: const TextStyle(color: Colors.white70, fontSize: 11), style: const TextStyle(color: Colors.white70, fontSize: 11),
), ),
if (isCurrentPlayer) if (isCurrentPlayer)
const Text( Text(
'← This device', S.of(context)!.thisDevice,
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold), style: const TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
), ),
if (isGhost && !isCorrupt) if (isGhost && !isCorrupt)
const Text( Text(
'⚠️ Ghost player (duplicate)', S.of(context)!.ghostPlayer,
style: TextStyle(color: Colors.red, fontSize: 11), style: const TextStyle(color: Colors.red, fontSize: 11),
), ),
if (isCorrupt) if (isCorrupt)
const Text( Text(
'⚠️ Unavailable/Corrupt', S.of(context)!.unavailableCorrupt,
style: TextStyle(color: Colors.orange, fontSize: 11), style: const TextStyle(color: Colors.orange, fontSize: 11),
), ),
], ],
), ),
@@ -195,15 +196,15 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Player list copied to clipboard!')), SnackBar(content: Text(S.of(context)!.playerListCopied)),
); );
} }
}, },
child: const Text('Copy List'), child: Text(S.of(context)!.copyList),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(S.of(context)!.close),
), ),
], ],
), ),
@@ -211,7 +212,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading players: $e')), SnackBar(content: Text(S.of(context)!.errorLoadingPlayers(e.toString()))),
); );
} }
} }
@@ -229,9 +230,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
color: Colors.white, color: Colors.white,
), ),
title: const Text( title: Text(
'Debug Logs', S.of(context)!.debugLogs,
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -256,11 +257,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
icon: const Icon(Icons.share_rounded), icon: const Icon(Icons.share_rounded),
onPressed: _shareBugReport, onPressed: _shareBugReport,
color: Colors.white, color: Colors.white,
tooltip: 'Share bug report', tooltip: S.of(context)!.shareBugReport,
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'More options', tooltip: S.of(context)!.moreOptions,
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case 'show_players': case 'show_players':
@@ -275,27 +276,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( PopupMenuItem(
value: 'show_players', value: 'show_players',
child: ListTile( child: ListTile(
leading: Icon(Icons.speaker_group_rounded), leading: const Icon(Icons.speaker_group_rounded),
title: Text('View All Players'), title: Text(S.of(context)!.viewAllPlayers),
dense: true, dense: true,
), ),
), ),
const PopupMenuItem( PopupMenuItem(
value: 'copy_logs', value: 'copy_logs',
child: ListTile( child: ListTile(
leading: Icon(Icons.copy_rounded), leading: const Icon(Icons.copy_rounded),
title: Text('Copy Logs'), title: Text(S.of(context)!.copyLogs),
dense: true, dense: true,
), ),
), ),
const PopupMenuItem( PopupMenuItem(
value: 'clear_logs', value: 'clear_logs',
child: ListTile( child: ListTile(
leading: Icon(Icons.delete_rounded), leading: const Icon(Icons.delete_rounded),
title: Text('Clear Logs'), title: Text(S.of(context)!.clearLogs),
dense: true, dense: true,
), ),
), ),
@@ -317,20 +318,20 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
_buildFilterChip('All', LogLevel.debug), _buildFilterChip(S.of(context)!.all, LogLevel.debug),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildFilterChip('Info+', LogLevel.info), _buildFilterChip(S.of(context)!.infoPlus, LogLevel.info),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildFilterChip('Warnings', LogLevel.warning), _buildFilterChip(S.of(context)!.warnings, LogLevel.warning),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildFilterChip('Errors', LogLevel.error), _buildFilterChip(S.of(context)!.errors, LogLevel.error),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Stats // Stats
Text( Text(
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries', S.of(context)!.showingEntries(_filteredEntries.length, _logger.entries.length),
style: const TextStyle(color: Colors.white54, fontSize: 11), style: const TextStyle(color: Colors.white54, fontSize: 11),
), ),
], ],
@@ -338,10 +339,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
), ),
Expanded( Expanded(
child: _filteredEntries.isEmpty child: _filteredEntries.isEmpty
? const Center( ? Center(
child: Text( child: Text(
'No logs yet', S.of(context)!.noLogsYet,
style: TextStyle( style: const TextStyle(
color: Colors.white54, color: Colors.white54,
fontSize: 16, fontSize: 16,
), ),
+8 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart'; import 'new_home_screen.dart';
@@ -39,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: const Text('Press back again to minimize'), content: Text(S.of(context)!.pressBackToMinimize),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
@@ -61,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (didPop) return; if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If global player is expanded, collapse it first // If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) { if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer(); GlobalPlayerOverlay.collapsePlayer();
+3 -1
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../widgets/album_card.dart'; import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryAlbumsScreen extends StatelessWidget { class LibraryAlbumsScreen extends StatelessWidget {
const LibraryAlbumsScreen({super.key}); const LibraryAlbumsScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
'Albums', S.of(context)!.albums,
style: textTheme.headlineSmall?.copyWith( style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -54,6 +55,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
if (albums.isEmpty) { if (albums.isEmpty) {
return EmptyState.albums( return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
} }
+3 -1
View File
@@ -6,6 +6,7 @@ import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../widgets/artist_avatar.dart'; import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryArtistsScreen extends StatelessWidget { class LibraryArtistsScreen extends StatelessWidget {
const LibraryArtistsScreen({super.key}); const LibraryArtistsScreen({super.key});
@@ -27,7 +28,7 @@ class LibraryArtistsScreen extends StatelessWidget {
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
'Artists', S.of(context)!.artists,
style: textTheme.headlineSmall?.copyWith( style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -56,6 +57,7 @@ class LibraryArtistsScreen extends StatelessWidget {
if (artists.isEmpty) { if (artists.isEmpty) {
return EmptyState.artists( return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import 'playlist_details_screen.dart'; import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryPlaylistsScreen extends StatefulWidget { class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key}); const LibraryPlaylistsScreen({super.key});
@@ -59,7 +60,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
'Playlists', S.of(context)!.playlists,
style: textTheme.headlineSmall?.copyWith( style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -81,7 +82,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
} }
if (_playlists.isEmpty) { if (_playlists.isEmpty) {
return EmptyState.playlists(onRefresh: _loadPlaylists); return EmptyState.playlists(context: context, onRefresh: _loadPlaylists);
} }
return RefreshIndicator( return RefreshIndicator(
@@ -138,8 +139,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
), ),
subtitle: Text( subtitle: Text(
playlist.trackCount != null playlist.trackCount != null
? '${playlist.trackCount} tracks' ? '${playlist.trackCount} ${S.of(context)!.tracks}'
: playlist.owner ?? 'Playlist', : playlist.owner ?? S.of(context)!.playlist,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
+4 -3
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryTracksScreen extends StatelessWidget { class LibraryTracksScreen extends StatelessWidget {
const LibraryTracksScreen({super.key}); const LibraryTracksScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryTracksScreen extends StatelessWidget {
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
'Tracks', S.of(context)!.tracks,
style: textTheme.headlineSmall?.copyWith( style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -48,7 +49,7 @@ class LibraryTracksScreen extends StatelessWidget {
} }
if (provider.tracks.isEmpty) { if (provider.tracks.isEmpty) {
return EmptyState.tracks(onRefresh: provider.loadLibrary); return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
} }
return ListView.builder( return ListView.builder(
@@ -129,7 +130,7 @@ class LibraryTracksScreen extends StatelessWidget {
if (maProvider.selectedPlayer == null) { if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
+40 -39
View File
@@ -7,6 +7,7 @@ import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart'; import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart'; import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
import 'home_screen.dart'; import 'home_screen.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -158,7 +159,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _detectAuthRequirements() async { Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) { if (_serverUrlController.text.trim().isEmpty) {
setState(() { setState(() {
_error = 'Please enter your Music Assistant server address'; _error = S.of(context)!.pleaseEnterServerAddress;
}); });
return; return;
} }
@@ -194,7 +195,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (strategy == null) { if (strategy == null) {
setState(() { setState(() {
_error = 'Cannot determine authentication requirements. Please check server URL.'; _error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false; _isDetectingAuth = false;
}); });
return; return;
@@ -230,13 +231,13 @@ class _LoginScreenState extends State<LoginScreen> {
String _getAuthTypeName(String strategyName) { String _getAuthTypeName(String strategyName) {
switch (strategyName) { switch (strategyName) {
case 'none': case 'none':
return 'No Authentication'; return S.of(context)!.noAuthentication;
case 'basic': case 'basic':
return 'HTTP Basic Auth'; return S.of(context)!.httpBasicAuth;
case 'authelia': case 'authelia':
return 'Authelia'; return S.of(context)!.authelia;
case 'music_assistant': case 'music_assistant':
return 'Music Assistant Login'; return S.of(context)!.musicAssistantLogin;
default: default:
return 'Unknown'; return 'Unknown';
} }
@@ -260,7 +261,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _connect() async { Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) { if (_serverUrlController.text.trim().isEmpty) {
setState(() { setState(() {
_error = 'Please enter your Music Assistant server address'; _error = S.of(context)!.pleaseEnterServerAddress;
}); });
return; return;
} }
@@ -270,7 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant'; final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) { if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
setState(() { setState(() {
_error = 'Please enter your name'; _error = S.of(context)!.pleaseEnterName;
}); });
return; return;
} }
@@ -292,7 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
portNum = int.tryParse(port); portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) { if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() { setState(() {
_error = 'Please enter a valid port number (1-65535)'; _error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
@@ -324,7 +325,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) { if (username.isEmpty || password.isEmpty) {
setState(() { setState(() {
_error = 'Please enter username and password'; _error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
@@ -350,7 +351,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!success) { if (!success) {
setState(() { setState(() {
_error = 'Authentication failed. Please check your credentials.'; _error = S.of(context)!.authFailed;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
@@ -384,7 +385,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) { if (username.isEmpty || password.isEmpty) {
setState(() { setState(() {
_error = 'Please enter username and password'; _error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
@@ -408,7 +409,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (accessToken == null) { if (accessToken == null) {
setState(() { setState(() {
_error = 'Music Assistant login failed. Please check your credentials.'; _error = S.of(context)!.maLoginFailed;
_isConnecting = false; _isConnecting = false;
}); });
return; return;
@@ -452,7 +453,7 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} else { } else {
setState(() { setState(() {
_error = 'Could not connect to server. Please check the address and try again.'; _error = S.of(context)!.connectionFailed;
_isConnecting = false; _isConnecting = false;
}); });
} }
@@ -500,7 +501,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Server URL // Server URL
Text( Text(
'Server Address', S.of(context)!.serverAddress,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -512,7 +513,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _serverUrlController, controller: _serverUrlController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'e.g., music.example.com or 192.168.1.100', hintText: S.of(context)!.serverAddressHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -539,7 +540,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (_detectedAuthStrategy != null && if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[ _detectedAuthStrategy!.name != 'music_assistant') ...[
Text( Text(
'Your Name', S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -551,7 +552,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _ownerNameController, controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Your first name', hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -574,7 +575,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Port // Port
Text( Text(
'Port (Optional)', S.of(context)!.portOptional,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -582,7 +583,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.', S.of(context)!.portDescription,
style: TextStyle( style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6), color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12, fontSize: 12,
@@ -594,7 +595,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _portController, controller: _portController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'e.g., 8095 or leave blank', hintText: S.of(context)!.portHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -639,7 +640,7 @@ class _LoginScreenState extends State<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Detected: $_detectedAuthType', S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle( style: TextStyle(
color: needsAuth color: needsAuth
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
@@ -652,7 +653,7 @@ class _LoginScreenState extends State<LoginScreen> {
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
'This server requires authentication', S.of(context)!.serverRequiresAuth,
style: TextStyle( style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7), color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12, fontSize: 12,
@@ -698,8 +699,8 @@ class _LoginScreenState extends State<LoginScreen> {
children: [ children: [
Text( Text(
_isTailscaleUrl(_serverUrlController.text) _isTailscaleUrl(_serverUrlController.text)
? 'Tailscale VPN Connection' ? S.of(context)!.tailscaleVpnConnection
: 'Unencrypted Connection', : S.of(context)!.unencryptedConnection,
style: TextStyle( style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text) color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer ? colorScheme.onSecondaryContainer
@@ -712,10 +713,10 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
_isTailscaleUrl(_serverUrlController.text) _isTailscaleUrl(_serverUrlController.text)
? 'Using HTTP over Tailscale (encrypted tunnel)' ? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback : _usedHttpFallback
? 'HTTPS failed, using HTTP fallback' ? S.of(context)!.httpsFailedUsingHttp
: 'HTTP connection - data is not encrypted', : S.of(context)!.httpNotEncrypted,
style: TextStyle( style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text) color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer ? colorScheme.onSecondaryContainer
@@ -736,7 +737,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Auth Server URL (only for Authelia) // Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[ if (_detectedAuthStrategy?.name == 'authelia') ...[
Text( Text(
'Auth Server URL (Optional)', S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -744,7 +745,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Leave empty if authentication is on the same server', S.of(context)!.authServerUrlDescription,
style: TextStyle( style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6), color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12, fontSize: 12,
@@ -756,7 +757,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _authServerUrlController, controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'e.g., auth.example.com (if different from server)', hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -777,7 +778,7 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
Text( Text(
'Username', S.of(context)!.username,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -790,7 +791,7 @@ class _LoginScreenState extends State<LoginScreen> {
focusNode: _usernameFocusNode, focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Username', hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -810,7 +811,7 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Password', S.of(context)!.password,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -822,7 +823,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController, controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Password', hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -912,9 +913,9 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'Detecting authentication...', S.of(context)!.detectingAuth,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -931,7 +932,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
) )
: Text( : Text(
_detectedAuthStrategy == null ? 'Detect & Connect' : 'Connect', _detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
+30 -24
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -179,6 +180,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Only show disconnected state if we have no cached data at all // Only show disconnected state if we have no cached data at all
if (!isConnected && !syncService.hasCache) { if (!isConnected && !syncService.hasCache) {
return DisconnectedState.full( return DisconnectedState.full(
context: context,
onSettings: () => Navigator.push( onSettings: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -186,14 +188,25 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
); );
} }
return Column( return Stack(
children: [ children: [
// Connecting banner when showing cached data // Main content
if (!isConnected && syncService.hasCache) RefreshIndicator(
Container( onRefresh: _onRefresh,
width: double.infinity, color: colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), backgroundColor: colorScheme.surface,
color: colorScheme.primaryContainer.withOpacity(0.5), child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !maProvider.hasCachedPlayers)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: colorScheme.primaryContainer.withOpacity(0.9),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -207,7 +220,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Connecting...', S.of(context)!.connecting,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
@@ -215,13 +228,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
], ],
), ),
), ),
Expanded(
child: RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
child: _buildConnectedView(context, maProvider),
),
), ),
], ],
); );
@@ -296,7 +302,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showRecentAlbums) return null; if (!_showRecentAlbums) return null;
return AlbumRow( return AlbumRow(
key: const ValueKey('recent-albums'), key: const ValueKey('recent-albums'),
title: 'Recently Played', title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(), loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -304,7 +310,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverArtists) return null; if (!_showDiscoverArtists) return null;
return ArtistRow( return ArtistRow(
key: const ValueKey('discover-artists'), key: const ValueKey('discover-artists'),
title: 'Discover Artists', title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(), loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -312,7 +318,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverAlbums) return null; if (!_showDiscoverAlbums) return null;
return AlbumRow( return AlbumRow(
key: const ValueKey('discover-albums'), key: const ValueKey('discover-albums'),
title: 'Discover Albums', title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(), loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -320,7 +326,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showContinueListeningAudiobooks) return null; if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow( return AudiobookRow(
key: const ValueKey('continue-listening'), key: const ValueKey('continue-listening'),
title: 'Continue Listening', title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(), loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -328,7 +334,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverAudiobooks) return null; if (!_showDiscoverAudiobooks) return null;
return AudiobookRow( return AudiobookRow(
key: const ValueKey('discover-audiobooks'), key: const ValueKey('discover-audiobooks'),
title: 'Discover Audiobooks', title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(), loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -336,7 +342,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverSeries) return null; if (!_showDiscoverSeries) return null;
return SeriesRow( return SeriesRow(
key: const ValueKey('discover-series'), key: const ValueKey('discover-series'),
title: 'Discover Series', title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(), loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -344,7 +350,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteAlbums) return null; if (!_showFavoriteAlbums) return null;
return AlbumRow( return AlbumRow(
key: const ValueKey('favorite-albums'), key: const ValueKey('favorite-albums'),
title: 'Favorite Albums', title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(), loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -352,7 +358,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteArtists) return null; if (!_showFavoriteArtists) return null;
return ArtistRow( return ArtistRow(
key: const ValueKey('favorite-artists'), key: const ValueKey('favorite-artists'),
title: 'Favorite Artists', title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(), loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
@@ -360,7 +366,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteTracks) return null; if (!_showFavoriteTracks) return null;
return TrackRow( return TrackRow(
key: const ValueKey('favorite-tracks'), key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks', title: S.of(context)!.favoriteTracks,
loadTracks: () => provider.getFavoriteTracks(), loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
+94 -80
View File
@@ -18,6 +18,7 @@ import '../services/settings_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/sync_service.dart'; import '../services/sync_service.dart';
import '../l10n/app_localizations.dart';
import 'album_details_screen.dart'; import 'album_details_screen.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
import 'playlist_details_screen.dart'; import 'playlist_details_screen.dart';
@@ -652,6 +653,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = S.of(context)!;
// Use Selector for targeted rebuilds - only rebuild when connection state changes // Use Selector for targeted rebuilds - only rebuild when connection state changes
return Selector<MusicAssistantProvider, bool>( return Selector<MusicAssistantProvider, bool>(
selector: (_, provider) => provider.isConnected, selector: (_, provider) => provider.isConnected,
@@ -669,7 +672,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text( title: Text(
'Library', l10n.library,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -679,6 +682,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
actions: const [PlayerSelector()], actions: const [PlayerSelector()],
), ),
body: DisconnectedState.withSettingsAction( body: DisconnectedState.withSettingsAction(
context: context,
onSettings: () => Navigator.push( onSettings: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -727,7 +731,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
indicatorWeight: 3, indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14), unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
tabs: _buildTabs(), tabs: _buildTabs(l10n),
), ),
), ),
// Favorites + Layout buttons on the right // Favorites + Layout buttons on the right
@@ -738,7 +742,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
size: 20, size: 20,
), ),
onPressed: () => _toggleFavoritesMode(!_showFavoritesOnly), onPressed: () => _toggleFavoritesMode(!_showFavoritesOnly),
tooltip: _showFavoritesOnly ? 'Show all' : 'Show favorites only', tooltip: _showFavoritesOnly ? l10n.showAll : l10n.showFavoritesOnly,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
@@ -750,7 +754,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
size: 20, size: 20,
), ),
onPressed: _cycleCurrentViewMode, onPressed: _cycleCurrentViewMode,
tooltip: 'Change view', tooltip: l10n.changeView,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
@@ -765,7 +769,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
body: Column( body: Column(
children: [ children: [
// Connecting banner when showing cached data // Connecting banner when showing cached data
if (!isConnected && syncService.hasCache) // Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -783,7 +788,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Connecting...', l10n.connecting,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
@@ -794,7 +799,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: _buildTabViews(context), children: _buildTabViews(context, l10n),
), ),
), ),
], ],
@@ -858,54 +863,53 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ CONTEXTUAL TABS ============ // ============ CONTEXTUAL TABS ============
List<Tab> _buildTabs() { List<Tab> _buildTabs(S l10n) {
switch (_selectedMediaType) { switch (_selectedMediaType) {
case LibraryMediaType.music: case LibraryMediaType.music:
return [ return [
const Tab(text: 'Artists'), Tab(text: l10n.artists),
const Tab(text: 'Albums'), Tab(text: l10n.albums),
if (_showFavoritesOnly) const Tab(text: 'Tracks'), if (_showFavoritesOnly) Tab(text: l10n.tracks),
const Tab(text: 'Playlists'), Tab(text: l10n.playlists),
]; ];
case LibraryMediaType.books: case LibraryMediaType.books:
return const [ return [
Tab(text: 'Authors'), Tab(text: l10n.authors),
Tab(text: 'All Books'), Tab(text: l10n.books),
Tab(text: 'Series'), Tab(text: l10n.series),
]; ];
case LibraryMediaType.podcasts: case LibraryMediaType.podcasts:
return const [ return [
Tab(text: 'Shows'), Tab(text: l10n.shows),
]; ];
} }
} }
List<Widget> _buildTabViews(BuildContext context) { List<Widget> _buildTabViews(BuildContext context, S l10n) {
switch (_selectedMediaType) { switch (_selectedMediaType) {
case LibraryMediaType.music: case LibraryMediaType.music:
return [ return [
_buildArtistsTab(context), _buildArtistsTab(context, l10n),
_buildAlbumsTab(context), _buildAlbumsTab(context, l10n),
if (_showFavoritesOnly) _buildTracksTab(context), if (_showFavoritesOnly) _buildTracksTab(context, l10n),
_buildPlaylistsTab(context), _buildPlaylistsTab(context, l10n),
]; ];
case LibraryMediaType.books: case LibraryMediaType.books:
return [ return [
_buildBooksAuthorsTab(context), _buildBooksAuthorsTab(context, l10n),
_buildAllBooksTab(context), _buildAllBooksTab(context, l10n),
_buildSeriesTab(context), _buildSeriesTab(context, l10n),
]; ];
case LibraryMediaType.podcasts: case LibraryMediaType.podcasts:
return [ return [
_buildPodcastsComingSoonTab(context), _buildPodcastsComingSoonTab(context, l10n),
]; ];
} }
} }
// ============ BOOKS TABS ============ // ============ BOOKS TABS ============
Widget _buildBooksAuthorsTab(BuildContext context) { Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
if (_isLoadingAudiobooks) { if (_isLoadingAudiobooks) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -919,15 +923,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (audiobooks.isEmpty) { if (audiobooks.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite audiobooks', title: l10n.noFavoriteAudiobooks,
subtitle: 'Tap the heart on an audiobook to add it to favorites', subtitle: l10n.tapHeartAudiobook,
); );
} }
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline, icon: MdiIcons.bookOutline,
title: 'No audiobooks', title: l10n.noAudiobooks,
subtitle: 'Add audiobooks to your library to see them here', subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(), onRefresh: () => _loadAudiobooks(),
); );
} }
@@ -956,7 +962,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: sortedAuthors.length, itemCount: sortedAuthors.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildAuthorListTile(sortedAuthors[index], authorMap[sortedAuthors[index]]!); return _buildAuthorListTile(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n);
}, },
) )
: GridView.builder( : GridView.builder(
@@ -973,13 +979,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
itemCount: sortedAuthors.length, itemCount: sortedAuthors.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildAuthorCard(sortedAuthors[index], authorMap[sortedAuthors[index]]!); return _buildAuthorCard(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n);
}, },
), ),
); );
} }
Widget _buildAuthorListTile(String authorName, List<Audiobook> books) { Widget _buildAuthorListTile(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName]; final authorImageUrl = _authorImages[authorName];
@@ -1036,7 +1042,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( subtitle: Text(
'${books.length} ${books.length == 1 ? 'audiobook' : 'audiobooks'}', '${books.length} ${books.length == 1 ? l10n.audiobookSingular : l10n.audiobooks}',
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
), ),
@@ -1045,7 +1051,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
); );
} }
Widget _buildAuthorCard(String authorName, List<Audiobook> books) { Widget _buildAuthorCard(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName]; final authorImageUrl = _authorImages[authorName];
@@ -1232,7 +1238,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
); );
} }
Widget _buildAllBooksTab(BuildContext context) { Widget _buildAllBooksTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -1248,15 +1254,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (audiobooks.isEmpty) { if (audiobooks.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite audiobooks', title: l10n.noFavoriteAudiobooks,
subtitle: 'Tap the heart on an audiobook to add it to favorites', subtitle: l10n.tapHeartAudiobook,
); );
} }
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline, icon: MdiIcons.bookOutline,
title: 'No audiobooks', title: l10n.noAudiobooks,
subtitle: 'Add audiobooks to your library to see them here', subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(), onRefresh: () => _loadAudiobooks(),
); );
} }
@@ -1405,7 +1413,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
); );
} }
Widget _buildSeriesTab(BuildContext context) { Widget _buildSeriesTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -1419,7 +1427,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
CircularProgressIndicator(color: colorScheme.primary), CircularProgressIndicator(color: colorScheme.primary),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Loading series...', l10n.loading,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14, fontSize: 14,
@@ -1452,7 +1460,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No Series Found', l10n.noSeriesFound,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: 20, fontSize: 20,
@@ -1462,8 +1470,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_seriesLoaded _seriesLoaded
? 'No series available from your audiobook library.\nPull to refresh.' ? l10n.noSeriesAvailable
: 'Pull down to load series\nfrom Music Assistant', : l10n.pullToLoadSeries,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
@@ -1473,7 +1481,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.tonal( FilledButton.tonal(
onPressed: _loadSeries, onPressed: _loadSeries,
child: const Text('Load Series'), child: Text(l10n.loadSeries),
), ),
], ],
), ),
@@ -1497,7 +1505,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _series.length, itemCount: _series.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildSeriesListTile(context, _series[index], maProvider); return _buildSeriesListTile(context, _series[index], maProvider, l10n);
}, },
) )
: GridView.builder( : GridView.builder(
@@ -1512,13 +1520,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _series.length, itemCount: _series.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final series = _series[index]; final series = _series[index];
return _buildSeriesCard(context, series, maProvider, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3); return _buildSeriesCard(context, series, maProvider, l10n, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3);
}, },
), ),
); );
} }
Widget _buildSeriesListTile(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider) { Widget _buildSeriesListTile(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -1576,7 +1584,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final count = series.bookCount ?? _seriesBookCounts[series.id]; final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink(); if (count == null) return const SizedBox.shrink();
return Text( return Text(
'$count ${count == 1 ? 'book' : 'books'}', '$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
@@ -1599,7 +1607,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
); );
} }
Widget _buildSeriesCard(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, {int maxCoverGridSize = 3}) { Widget _buildSeriesCard(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n, {int maxCoverGridSize = 3}) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -1661,7 +1669,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Padding( return Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
child: Text( child: Text(
'$count ${count == 1 ? 'book' : 'books'}', '$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
@@ -1833,7 +1841,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ PODCASTS TAB (Placeholder) ============ // ============ PODCASTS TAB (Placeholder) ============
Widget _buildPodcastsComingSoonTab(BuildContext context) { Widget _buildPodcastsComingSoonTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Center( return Center(
child: Column( child: Column(
@@ -1846,7 +1854,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Podcasts', l10n.podcasts,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: 20, fontSize: 20,
@@ -1855,7 +1863,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Podcast support coming soon', l10n.podcastSupportComingSoon,
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14, fontSize: 14,
@@ -1867,7 +1875,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ ARTISTS TAB ============ // ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context) { Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes // Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
return Selector<MusicAssistantProvider, (List<Artist>, bool)>( return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading), selector: (_, provider) => (provider.artists, provider.isLoading),
@@ -1887,12 +1895,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (artists.isEmpty) { if (artists.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite artists', title: l10n.noFavoriteArtists,
subtitle: 'Tap the heart on an artist to add them to favorites', subtitle: l10n.tapHeartArtist,
); );
} }
return EmptyState.artists( return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
} }
@@ -2050,7 +2060,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ ALBUMS TAB ============ // ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context) { Widget _buildAlbumsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes // Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
return Selector<MusicAssistantProvider, (List<Album>, bool)>( return Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading), selector: (_, provider) => (provider.albums, provider.isLoading),
@@ -2070,12 +2080,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (albums.isEmpty) { if (albums.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite albums', title: l10n.noFavoriteAlbums,
subtitle: 'Tap the heart on an album to add it to favorites', subtitle: l10n.tapHeartAlbum,
); );
} }
return EmptyState.albums( return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
); );
} }
@@ -2184,7 +2196,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ PLAYLISTS TAB ============ // ============ PLAYLISTS TAB ============
Widget _buildPlaylistsTab(BuildContext context) { Widget _buildPlaylistsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingPlaylists) { if (_isLoadingPlaylists) {
@@ -2194,12 +2206,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (_playlists.isEmpty) { if (_playlists.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite playlists', title: l10n.noFavoritePlaylists,
subtitle: 'Tap the heart on a playlist to add it to favorites', subtitle: l10n.tapHeartPlaylist,
); );
} }
return EmptyState.playlists(onRefresh: () => _loadPlaylists()); return EmptyState.playlists(context: context, onRefresh: () => _loadPlaylists());
} }
return RefreshIndicator( return RefreshIndicator(
@@ -2216,7 +2229,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly), padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = _playlists[index]; final playlist = _playlists[index];
return _buildPlaylistTile(context, playlist); return _buildPlaylistTile(context, playlist, l10n);
}, },
) )
: GridView.builder( : GridView.builder(
@@ -2234,13 +2247,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _playlists.length, itemCount: _playlists.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = _playlists[index]; final playlist = _playlists[index];
return _buildPlaylistGridCard(context, playlist); return _buildPlaylistGridCard(context, playlist, l10n);
}, },
), ),
); );
} }
Widget _buildPlaylistTile(BuildContext context, Playlist playlist) { Widget _buildPlaylistTile(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 128); final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -2274,8 +2287,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
subtitle: Text( subtitle: Text(
playlist.trackCount != null playlist.trackCount != null
? '${playlist.trackCount} tracks' ? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? 'Playlist', : playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -2299,7 +2312,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
); );
} }
Widget _buildPlaylistGridCard(BuildContext context, Playlist playlist) { Widget _buildPlaylistGridCard(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 256); final imageUrl = provider.api?.getImageUrl(playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -2363,8 +2376,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
), ),
Text( Text(
playlist.trackCount != null playlist.trackCount != null
? '${playlist.trackCount} tracks' ? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? 'Playlist', : playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7), color: colorScheme.onSurface.withOpacity(0.7),
), ),
@@ -2377,7 +2390,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
} }
// ============ TRACKS TAB (favorites only) ============ // ============ TRACKS TAB (favorites only) ============
Widget _buildTracksTab(BuildContext context) { Widget _buildTracksTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingTracks) { if (_isLoadingTracks) {
@@ -2386,9 +2399,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (_favoriteTracks.isEmpty) { if (_favoriteTracks.isEmpty) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: 'No favorite tracks', title: l10n.noFavoriteTracks,
subtitle: 'Long-press a track and tap the heart to add it to favorites', subtitle: l10n.longPressTrackHint,
); );
} }
@@ -2474,7 +2488,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player == null) { if (player == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
+12 -11
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -61,7 +62,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
// Use the selected player // Use the selected player
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player == null) { if (player == null) {
_showError('No player selected'); _showError(S.of(context)!.noPlayerSelected);
return; return;
} }
@@ -77,7 +78,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${widget.playlist.name}'), content: Text(S.of(context)!.playing(widget.playlist.name)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -195,7 +196,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _isLoading ? null : _playPlaylist, onPressed: _isLoading ? null : _playPlaylist,
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'), label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a), foregroundColor: const Color(0xFF1a1a1a),
@@ -208,9 +209,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Tracks header // Tracks header
const Text( Text(
'Tracks', S.of(context)!.tracks,
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -230,7 +231,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
? SliverFillRemaining( ? SliverFillRemaining(
child: Center( child: Center(
child: Text( child: Text(
'No tracks in playlist', S.of(context)!.noTracksInPlaylist,
style: TextStyle( style: TextStyle(
color: Colors.grey[600], color: Colors.grey[600],
fontSize: 16, fontSize: 16,
@@ -327,7 +328,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
onPressed: () async { onPressed: () async {
final player = maProvider.selectedPlayer; final player = maProvider.selectedPlayer;
if (player == null) { if (player == null) {
_showError('No player selected'); _showError(S.of(context)!.noPlayerSelected);
return; return;
} }
@@ -336,9 +337,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
await maProvider.playTracks(player.playerId, _tracks, startIndex: index); await maProvider.playTracks(player.playerId, _tracks, startIndex: index);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Playing track'), content: Text(S.of(context)!.playingTrack),
duration: Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
+10 -9
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget { class QueueScreen extends StatefulWidget {
const QueueScreen({super.key}); const QueueScreen({super.key});
@@ -51,7 +52,7 @@ class _QueueScreenState extends State<QueueScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_error = 'No player selected'; _error = S.of(context)!.noPlayerSelected;
}); });
} }
} }
@@ -77,7 +78,7 @@ class _QueueScreenState extends State<QueueScreen> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text( title: Text(
player != null ? '${player.name} Queue' : 'Queue', player != null ? S.of(context)!.playerQueue(player.name) : S.of(context)!.queue,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 20, fontSize: 20,
@@ -116,7 +117,7 @@ class _QueueScreenState extends State<QueueScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _loadQueue, onPressed: _loadQueue,
child: const Text('Retry'), child: Text(S.of(context)!.retry),
), ),
], ],
), ),
@@ -131,7 +132,7 @@ class _QueueScreenState extends State<QueueScreen> {
Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]), Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No player selected', S.of(context)!.noPlayerSelected,
style: TextStyle(color: Colors.grey[600], fontSize: 18), style: TextStyle(color: Colors.grey[600], fontSize: 18),
), ),
], ],
@@ -140,7 +141,7 @@ class _QueueScreenState extends State<QueueScreen> {
} }
if (_queue == null || _queue!.items.isEmpty) { if (_queue == null || _queue!.items.isEmpty) {
return EmptyState.queue(); return EmptyState.queue(context: context);
} }
// Filter to show only current and upcoming items (not history) // Filter to show only current and upcoming items (not history)
@@ -148,9 +149,9 @@ class _QueueScreenState extends State<QueueScreen> {
final upcomingItems = _queue!.items.sublist(currentIndex); final upcomingItems = _queue!.items.sublist(currentIndex);
if (upcomingItems.isEmpty) { if (upcomingItems.isEmpty) {
return const EmptyState( return EmptyState(
icon: Icons.queue_music, icon: Icons.queue_music,
message: 'No upcoming tracks', message: S.of(context)!.noUpcomingTracks,
); );
} }
@@ -220,9 +221,9 @@ class _QueueScreenState extends State<QueueScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Removed ${item.track.name}'), content: Text(S.of(context)!.removedItem(item.track.name)),
action: SnackBarAction( action: SnackBarAction(
label: 'Undo', label: S.of(context)!.undo,
onPressed: () { onPressed: () {
setState(() { setState(() {
_queue!.items.insert(index, item); _queue!.items.insert(index, item);
+16 -15
View File
@@ -12,6 +12,7 @@ import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart'; import '../widgets/artist_avatar.dart';
import 'album_details_screen.dart'; import 'album_details_screen.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class SearchScreen extends StatefulWidget { class SearchScreen extends StatefulWidget {
const SearchScreen({super.key}); const SearchScreen({super.key});
@@ -140,7 +141,7 @@ class SearchScreenState extends State<SearchScreen> {
setState(() { setState(() {
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
_searchError = 'Search failed. Please check your connection.'; _searchError = S.of(context)!.searchFailed;
}); });
if (keepFocus && _focusNode.hasFocus) { if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus(); _focusNode.requestFocus();
@@ -166,7 +167,7 @@ class SearchScreenState extends State<SearchScreen> {
cursorColor: colorScheme.primary, cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search music...', hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none, border: InputBorder.none,
suffixIcon: _searchController.text.isNotEmpty suffixIcon: _searchController.text.isNotEmpty
@@ -191,7 +192,7 @@ class SearchScreenState extends State<SearchScreen> {
), ),
), ),
body: !maProvider.isConnected body: !maProvider.isConnected
? DisconnectedState.simple() ? DisconnectedState.simple(context)
: _buildSearchContent(), : _buildSearchContent(),
); );
} }
@@ -217,7 +218,7 @@ class SearchScreenState extends State<SearchScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Search for artists, albums, or tracks', S.of(context)!.searchForContent,
style: TextStyle( style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.5), color: colorScheme.onBackground.withOpacity(0.5),
fontSize: 16, fontSize: 16,
@@ -250,7 +251,7 @@ class SearchScreenState extends State<SearchScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text), onPressed: () => _performSearch(_searchController.text),
child: const Text('Retry'), child: Text(S.of(context)!.retry),
), ),
], ],
), ),
@@ -264,7 +265,7 @@ class SearchScreenState extends State<SearchScreen> {
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty; final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
if (!hasResults) { if (!hasResults) {
return EmptyState.search(); return EmptyState.search(context: context);
} }
return Column( return Column(
@@ -275,18 +276,18 @@ class SearchScreenState extends State<SearchScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
children: [ children: [
_buildFilterChip('All', 'all'), _buildFilterChip(S.of(context)!.all, 'all'),
const SizedBox(width: 8), const SizedBox(width: 8),
if (artists.isNotEmpty) ...[ if (artists.isNotEmpty) ...[
_buildFilterChip('Artists', 'artists'), _buildFilterChip(S.of(context)!.artists, 'artists'),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
if (albums.isNotEmpty) ...[ if (albums.isNotEmpty) ...[
_buildFilterChip('Albums', 'albums'), _buildFilterChip(S.of(context)!.albums, 'albums'),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
if (tracks.isNotEmpty) ...[ if (tracks.isNotEmpty) ...[
_buildFilterChip('Tracks', 'tracks'), _buildFilterChip(S.of(context)!.tracks, 'tracks'),
], ],
], ],
), ),
@@ -336,7 +337,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add artists section // Add artists section
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) { if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
if (_activeFilter == 'all') { if (_activeFilter == 'all') {
items.add(_ListItem.header('Artists', artists.length)); items.add(_ListItem.header(S.of(context)!.artists, artists.length));
} }
for (final artist in artists) { for (final artist in artists) {
items.add(_ListItem.artist(artist)); items.add(_ListItem.artist(artist));
@@ -347,7 +348,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add albums section // Add albums section
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) { if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
if (_activeFilter == 'all') { if (_activeFilter == 'all') {
items.add(_ListItem.header('Albums', albums.length)); items.add(_ListItem.header(S.of(context)!.albums, albums.length));
} }
for (final album in albums) { for (final album in albums) {
items.add(_ListItem.album(album)); items.add(_ListItem.album(album));
@@ -358,7 +359,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add tracks section // Add tracks section
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) { if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
if (_activeFilter == 'all') { if (_activeFilter == 'all') {
items.add(_ListItem.header('Tracks', tracks.length)); items.add(_ListItem.header(S.of(context)!.tracks, tracks.length));
} }
for (final track in tracks) { for (final track in tracks) {
items.add(_ListItem.track(track)); items.add(_ListItem.track(track));
@@ -428,7 +429,7 @@ class SearchScreenState extends State<SearchScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( subtitle: Text(
'Artist', S.of(context)!.artist,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
onTap: () { onTap: () {
@@ -556,7 +557,7 @@ class SearchScreenState extends State<SearchScreen> {
if (maProvider.selectedPlayer == null) { if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')), SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
); );
return; return;
} }
+142 -49
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
@@ -106,25 +108,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row display info // Helper to get row display info
Map<String, String> _getRowInfo(String rowId) { Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
switch (rowId) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
return {'title': 'Recently Played', 'subtitle': 'Show recently played albums'}; return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
case 'discover-artists': case 'discover-artists':
return {'title': 'Discover Artists', 'subtitle': 'Show random artists to discover'}; return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums': case 'discover-albums':
return {'title': 'Discover Albums', 'subtitle': 'Show random albums to discover'}; return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'continue-listening': case 'continue-listening':
return {'title': 'Continue Listening', 'subtitle': 'Show audiobooks in progress'}; return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks': case 'discover-audiobooks':
return {'title': 'Discover Audiobooks', 'subtitle': 'Show random audiobooks to discover'}; return {'title': s.discoverAudiobooks, 'subtitle': s.showRandomAudiobooks};
case 'discover-series': case 'discover-series':
return {'title': 'Discover Series', 'subtitle': 'Show random audiobook series to discover'}; return {'title': s.discoverSeries, 'subtitle': s.showRandomSeries};
case 'favorite-albums': case 'favorite-albums':
return {'title': 'Favorite Albums', 'subtitle': 'Show a row of your favorite albums'}; return {'title': s.favoriteAlbums, 'subtitle': s.showFavoriteAlbums};
case 'favorite-artists': case 'favorite-artists':
return {'title': 'Favorite Artists', 'subtitle': 'Show a row of your favorite artists'}; return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks': case 'favorite-tracks':
return {'title': 'Favorite Tracks', 'subtitle': 'Show a row of your favorite tracks'}; return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
default: default:
return {'title': rowId, 'subtitle': ''}; return {'title': rowId, 'subtitle': ''};
} }
@@ -233,7 +236,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
'Settings', S.of(context)!.settings,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -252,7 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
}, },
color: colorScheme.onBackground, color: colorScheme.onBackground,
tooltip: 'Debug Logs', tooltip: S.of(context)!.debugLogs,
), ),
], ],
), ),
@@ -308,9 +311,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: _disconnect, onPressed: _disconnect,
icon: const Icon(Icons.logout_rounded), icon: const Icon(Icons.logout_rounded),
label: const Text( label: Text(
'Disconnect', S.of(context)!.disconnect,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -329,7 +332,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Theme section // Theme section
Text( Text(
'Theme', S.of(context)!.theme,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -340,14 +343,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3), color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Theme Mode', S.of(context)!.themeMode,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -358,21 +361,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
child: SegmentedButton<ThemeMode>( child: SegmentedButton<ThemeMode>(
segments: const [ segments: [
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.light, value: ThemeMode.light,
label: Text('Light'), label: Text(S.of(context)!.light),
icon: Icon(Icons.light_mode_rounded), icon: const Icon(Icons.light_mode_rounded),
), ),
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.dark, value: ThemeMode.dark,
label: Text('Dark'), label: Text(S.of(context)!.dark),
icon: Icon(Icons.dark_mode_rounded), icon: const Icon(Icons.dark_mode_rounded),
), ),
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.system, value: ThemeMode.system,
label: Text('System'), label: Text(S.of(context)!.system),
icon: Icon(Icons.auto_mode_rounded), icon: const Icon(Icons.auto_mode_rounded),
), ),
], ],
selected: {themeProvider.themeMode}, selected: {themeProvider.themeMode},
@@ -384,7 +387,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return colorScheme.primaryContainer; return colorScheme.primaryContainer;
} }
return colorScheme.surfaceVariant.withOpacity(0.3); return colorScheme.surfaceVariant.withOpacity(0.5);
}), }),
foregroundColor: WidgetStateProperty.resolveWith((states) { foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
@@ -393,6 +396,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
return colorScheme.onSurfaceVariant; return colorScheme.onSurfaceVariant;
}), }),
side: WidgetStateProperty.all(BorderSide.none), side: WidgetStateProperty.all(BorderSide.none),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
), ),
), ),
); );
@@ -404,6 +410,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Language picker
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
switch (code) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
default:
return S.of(context)!.system;
}
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
tileColor: colorScheme.surfaceVariant.withOpacity(0.5),
leading: Icon(Icons.language_rounded, color: colorScheme.onSurfaceVariant),
title: Text(S.of(context)!.language),
subtitle: Text(
getLanguageName(localeProvider.locale?.languageCode),
style: TextStyle(color: colorScheme.primary),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(S.of(context)!.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(null);
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('English'),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('en'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('de'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Español'),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('es'));
Navigator.pop(dialogContext);
},
),
],
),
);
},
);
},
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>( Consumer<ThemeProvider>(
builder: (context, themeProvider, _) { builder: (context, themeProvider, _) {
return Container( return Container(
@@ -414,11 +505,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
child: SwitchListTile( child: SwitchListTile(
title: Text( title: Text(
'Material You', S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Use system colors (Android 12+)', S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
value: themeProvider.useMaterialTheme, value: themeProvider.useMaterialTheme,
@@ -444,11 +535,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
child: SwitchListTile( child: SwitchListTile(
title: Text( title: Text(
'Adaptive Theme', S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Extract colors from album and artist artwork', S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
value: themeProvider.adaptiveTheme, value: themeProvider.adaptiveTheme,
@@ -466,7 +557,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home Screen section // Home Screen section
Text( Text(
'Home Screen', S.of(context)!.homeScreen,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -474,7 +565,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Choose which rows to display on the home screen', S.of(context)!.chooseHomeScreenRows,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6), color: colorScheme.onBackground.withOpacity(0.6),
), ),
@@ -551,7 +642,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Metadata APIs (Optional)', S.of(context)!.metadataApis,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -559,7 +650,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.', S.of(context)!.metadataApisDescription,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6), color: colorScheme.onBackground.withOpacity(0.6),
), ),
@@ -570,8 +661,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _lastFmApiKeyController, controller: _lastFmApiKeyController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Last.fm API Key', labelText: S.of(context)!.lastFmApiKey,
hintText: 'Get free key at last.fm/api', hintText: S.of(context)!.lastFmApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -607,8 +698,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _audioDbApiKeyController, controller: _audioDbApiKeyController,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'TheAudioDB API Key', labelText: S.of(context)!.theAudioDbApiKey,
hintText: 'Use "2" for free tier or premium key', hintText: S.of(context)!.theAudioDbApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true, filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3), fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -643,7 +734,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Audiobook Libraries section // Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[ if (_discoveredLibraries.isNotEmpty) ...[
Text( Text(
'Audiobook Libraries', S.of(context)!.audiobookLibraries,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -651,7 +742,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Choose which Audiobookshelf libraries to include', S.of(context)!.chooseAudiobookLibraries,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6), color: colorScheme.onBackground.withOpacity(0.6),
), ),
@@ -668,7 +759,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final index = entry.key; final index = entry.key;
final library = entry.value; final library = entry.value;
final path = library['path'] ?? ''; final path = library['path'] ?? '';
final name = library['name'] ?? 'Unknown Library'; final name = library['name'] ?? S.of(context)!.unknownLibrary;
final isEnabled = _libraryEnabled[path] ?? true; final isEnabled = _libraryEnabled[path] ?? true;
return Column( return Column(
@@ -686,9 +777,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
await SettingsService.toggleAbsLibrary(path, value); await SettingsService.toggleAbsLibrary(path, value);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Pull to refresh the library to apply changes'), content: Text(S.of(context)!.pullToRefresh),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
@@ -723,7 +814,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
}, },
icon: const Icon(Icons.bug_report_rounded), icon: const Icon(Icons.bug_report_rounded),
label: const Text('View Debug Logs'), label: Text(S.of(context)!.viewDebugLogs),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5), backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
foregroundColor: colorScheme.onSurfaceVariant, foregroundColor: colorScheme.onSurfaceVariant,
@@ -772,17 +863,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
String _getStatusText(MAConnectionState state) { String _getStatusText(MAConnectionState state) {
final s = S.of(context)!;
switch (state) { switch (state) {
case MAConnectionState.connected: case MAConnectionState.connected:
case MAConnectionState.authenticated: case MAConnectionState.authenticated:
return 'Connected'; return s.connected;
case MAConnectionState.connecting: case MAConnectionState.connecting:
case MAConnectionState.authenticating: case MAConnectionState.authenticating:
return 'Connecting...'; return s.connecting;
case MAConnectionState.error: case MAConnectionState.error:
return 'Connection Error'; return s.connectionError;
case MAConnectionState.disconnected: case MAConnectionState.disconnected:
return 'Disconnected'; return s.disconnected;
} }
} }
} }
+91 -13
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'auth_strategy.dart'; import 'auth_strategy.dart';
import 'no_auth_strategy.dart'; import 'no_auth_strategy.dart';
@@ -86,6 +87,22 @@ class AuthManager {
return baseUrl; return baseUrl;
} }
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
}
/// Try to detect auth strategy for a given URL /// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed /// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async { Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +204,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl'); _logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required // Use IOClient to control redirect behavior for local IPs
// Note: The command is just 'info', not 'server/info' final isLocalIp = _isLocalIpAddress(uri.host);
final response = await http.post( http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl, apiUrl,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'command': 'info', 'command': 'info',
}), }),
).timeout(const Duration(seconds: 5)); ).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}'); _logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}'); _logger.log('MA API body: ${response.body}');
@@ -255,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication /// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async { Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try { try {
// Test root endpoint - Music Assistant doesn't have /api/info final uri = Uri.parse(serverUrl);
final testUrl = serverUrl; _logger.log('No-auth test URL: $serverUrl');
_logger.log('No-auth test URL: $testUrl');
final response = await http.get( final isLocalIp = _isLocalIpAddress(uri.host);
Uri.parse(testUrl), int statusCode;
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
_logger.log('No-auth status: ${response.statusCode}'); if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required // 200 = no auth required
// 302/307 = redirect (probably to auth) // 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required // 401 = auth required
if (response.statusCode == 200) { if (statusCode == 200) {
return true; return true;
} }
+25
View File
@@ -1,4 +1,5 @@
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
@@ -28,6 +29,8 @@ class CacheService {
final Map<String, DateTime> _searchCacheTime = {}; final Map<String, DateTime> _searchCacheTime = {};
// Player list caching // Player list caching
List<Player>? _cachedPlayers;
Player? _cachedSelectedPlayer;
DateTime? _playersLastFetched; DateTime? _playersLastFetched;
// Player track cache (for smooth swipe transitions) // Player track cache (for smooth swipe transitions)
@@ -219,6 +222,26 @@ class CacheService {
_playersLastFetched = DateTime.now(); _playersLastFetched = DateTime.now();
} }
/// Get cached players list
List<Player>? getCachedPlayers() => _cachedPlayers;
/// Set cached players list
void setCachedPlayers(List<Player> players) {
_cachedPlayers = players;
_playersLastFetched = DateTime.now();
}
/// Get cached selected player
Player? getCachedSelectedPlayer() => _cachedSelectedPlayer;
/// Set cached selected player
void setCachedSelectedPlayer(Player? player) {
_cachedSelectedPlayer = player;
}
/// Check if we have cached players (for instant UI display on app resume)
bool get hasCachedPlayers => _cachedPlayers != null && _cachedPlayers!.isNotEmpty;
/// Get cached track for a player (used for smooth swipe transitions) /// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId]; Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
@@ -249,6 +272,8 @@ class CacheService {
void clearAll() { void clearAll() {
invalidateHomeCache(); invalidateHomeCache();
clearAllDetailCaches(); clearAllDetailCaches();
_cachedPlayers = null;
_cachedSelectedPlayer = null;
_playersLastFetched = null; _playersLastFetched = null;
} }
} }
+9 -83
View File
@@ -2302,89 +2302,15 @@ class MusicAssistantAPI {
return _eventStreams['sendspin_player']!.stream; return _eventStreams['sendspin_player']!.stream;
} }
/// Get Sendspin connection info for a player // NOTE: The following Sendspin API methods were removed because they don't exist in MA:
/// Returns connection details including: // - getSendspinConnectionInfo (sendspin/connection_info)
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin) // - sendspinOffer (sendspin/webrtc_offer)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback // - sendspinAnswer (sendspin/webrtc_answer)
/// - player_id: The player's ID // - sendspinIceCandidate (sendspin/ice_candidate)
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async { //
try { // Sendspin connection is handled directly via WebSocket to:
_logger.log('Sendspin: Getting connection info for player $playerId'); // - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
final response = await _sendCommand(
'sendspin/connection_info',
args: {
'player_id': playerId,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result != null) {
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
}
return result;
} catch (e) {
_logger.log('Sendspin: Error getting connection info: $e');
return null;
}
}
/// Send WebRTC offer to Sendspin server
/// Used when establishing WebRTC connection as fallback
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
final response = await _sendCommand(
'sendspin/webrtc_offer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
return response['result'] as Map<String, dynamic>?;
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC offer: $e');
return null;
}
}
/// Send WebRTC answer to Sendspin server
Future<void> sendspinAnswer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
await _sendCommand(
'sendspin/webrtc_answer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC answer: $e');
rethrow;
}
}
/// Send ICE candidate to Sendspin server
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
try {
await _sendCommand(
'sendspin/ice_candidate',
args: {
'player_id': playerId,
'candidate': candidate,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending ICE candidate: $e');
// Don't rethrow - ICE candidate failures are common and non-fatal
}
}
/// Update Sendspin player state /// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol /// Similar to updateBuiltinPlayerState but for Sendspin protocol
+91 -6
View File
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
final List<Uint8List> _audioBuffer = []; final List<Uint8List> _audioBuffer = [];
bool _isFeeding = false; bool _isFeeding = false;
bool _isStarted = false; bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
Completer<void>? _feedCompleter; // Tracks when current feed operation completes Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures // Error callback for operation failures
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
/// Handle incoming audio data from the stream /// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) { void _onAudioData(Uint8List audioData) {
// Ignore data if in error state or if in transitional/paused state // Ignore data if in error state
if (_state == PcmPlayerState.error) return; if (_state == PcmPlayerState.error) return;
// Handle audio arriving during paused/transitional states
// This can happen when audio frames arrive before stream/start message (race condition)
// We need to auto-recover in this case
if (_shouldBlockFeeding) { if (_shouldBlockFeeding) {
// Silently ignore audio data when paused or transitioning // If we're paused and audio is arriving, this is likely a new stream
// This prevents buffer accumulation during pause and any race conditions // starting before the stream/start message. Queue for auto-recovery.
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_audioBuffer.add(audioData);
// Trigger async recovery
_autoRecoverFromPause();
return;
}
// If already recovering or in transitional state, buffer the data
if (_isAutoRecovering) {
_audioBuffer.add(audioData);
return;
}
// For other blocking states (pausing, stopping, resuming), ignore
return; return;
} }
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
_startPlayback(); _startPlayback();
} }
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
// The provider should explicitly call play() when ready to resume.
// Feed data if not currently feeding // Feed data if not currently feeding
if (!_isFeeding && _isStarted) { if (!_isFeeding && _isStarted) {
_feedNextChunk(); _feedNextChunk();
} }
} }
/// Auto-recover from paused state when audio arrives unexpectedly
Future<void> _autoRecoverFromPause() async {
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
try {
// Re-initialize the native player
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
// Start feeding buffered data
_isAutoRecovering = false;
if (_audioBuffer.isNotEmpty && !_isFeeding) {
_feedNextChunk();
}
} catch (e) {
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
_isAutoRecovering = false;
_state = PcmPlayerState.error;
_emitError(PcmPlayerError.resumeFailed, e.toString());
}
}
/// Start audio playback /// Start audio playback
Future<void> _startPlayback() async { Future<void> _startPlayback() async {
if (_isStarted) return; if (_isStarted) return;
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
// Check state again before the async feed call // Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) { if (samples.isNotEmpty && !_shouldBlockFeeding) {
try {
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples)); await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
} catch (feedError) {
// Auto-recover from "must call setup first" error
// This happens when audio frames arrive before stream/start message
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
_isAutoRecovering = true;
try {
// Re-initialize the native player
await pcm.FlutterPcmSound.setup(
sampleRate: _format.sampleRate,
channelCount: _format.channels,
);
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
await pcm.FlutterPcmSound.start();
_isStarted = true;
_state = PcmPlayerState.playing;
_startElapsedTimeTimer();
_logger.log('PcmAudioPlayer: Auto-recovery successful');
// Retry the feed with the current chunk
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
} catch (recoveryError) {
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
_isAutoRecovering = false;
rethrow;
}
_isAutoRecovering = false;
} else {
rethrow;
}
}
// Don't update stats if we got paused during the feed // Don't update stats if we got paused during the feed
if (!_shouldBlockFeeding) { if (!_shouldBlockFeeding) {
+53 -7
View File
@@ -84,8 +84,16 @@ class SendspinService {
bool _isDisposed = false; bool _isDisposed = false;
// Auth token for proxy authentication (MA 2.7.1+)
String? _authToken;
SendspinService(this.serverUrl); SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication
void setAuthToken(String? token) {
_authToken = token;
}
/// Initialize and connect to Sendspin server /// Initialize and connect to Sendspin server
/// Uses persistent player ID and username-based naming /// Uses persistent player ID and username-based naming
Future<bool> connect() async { Future<bool> connect() async {
@@ -105,18 +113,18 @@ class SendspinService {
bool connected = false; bool connected = false;
// Strategy 1: If server is HTTPS, try external wss:// first // Strategy 1: If server is HTTPS, try external wss:// first
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
if (_isHttpsServer()) { if (_isHttpsServer()) {
final externalUrl = _buildExternalSendspinUrl(); final externalUrl = _buildExternalSendspinUrl();
_logger.log('Sendspin: Trying external URL: $externalUrl'); _logger.log('Sendspin: Trying external URL: $externalUrl');
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3)); connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
} }
// Strategy 2: If external failed or server is HTTP, try local connection // Note: Local network connections (ws://ip:8927/sendspin) are handled
// Note: We would need local_ws_url from getSendspinConnectionInfo API // by the provider calling connectWithUrl() directly, not by this method.
// For now, skip this step and fall back to WebRTC (handled by provider)
if (!connected) { if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed'); _logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
return false; return false;
} }
@@ -158,10 +166,11 @@ class SendspinService {
} }
/// Attempt to connect to a specific WebSocket URL /// Attempt to connect to a specific WebSocket URL
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async { /// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
try { try {
// Connect to the base URL without query params - we send player info in hello message // Connect to the base URL without query params - we send player info in hello message
_logger.log('Sendspin: Connecting to $url'); _logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
// Create WebSocket connection // Create WebSocket connection
final webSocket = await WebSocket.connect(url).timeout(timeout); final webSocket = await WebSocket.connect(url).timeout(timeout);
@@ -176,6 +185,29 @@ class SendspinService {
onDone: _handleDone, onDone: _handleDone,
); );
// If using proxy auth (MA 2.7.1+), authenticate first
if (useProxyAuth && _authToken != null) {
_logger.log('Sendspin: Sending proxy auth with token');
_sendMessage({
'type': 'auth',
'token': _authToken,
}, allowDuringHandshake: true);
// Wait for auth_ok response
final authOk = await _waitForAuth().timeout(
const Duration(seconds: 5),
onTimeout: () => false,
);
if (!authOk) {
_logger.log('Sendspin: Proxy authentication failed or timed out');
await _channel?.sink.close();
_channel = null;
return false;
}
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
}
// Send client/hello message immediately after connecting // Send client/hello message immediately after connecting
// This is required by the Sendspin protocol - server waits for this // This is required by the Sendspin protocol - server waits for this
// Message format follows aiosendspin protocol spec // Message format follows aiosendspin protocol spec
@@ -228,12 +260,18 @@ class SendspinService {
/// Wait for server acknowledgment after connection /// Wait for server acknowledgment after connection
Completer<bool>? _ackCompleter; Completer<bool>? _ackCompleter;
Completer<bool>? _authCompleter;
Future<bool> _waitForAck() async { Future<bool> _waitForAck() async {
_ackCompleter = Completer<bool>(); _ackCompleter = Completer<bool>();
return _ackCompleter!.future; return _ackCompleter!.future;
} }
Future<bool> _waitForAuth() async {
_authCompleter = Completer<bool>();
return _authCompleter!.future;
}
/// Handle incoming WebSocket messages /// Handle incoming WebSocket messages
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data) /// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
void _handleMessage(dynamic message) { void _handleMessage(dynamic message) {
@@ -310,6 +348,14 @@ class SendspinService {
onStreamEnd?.call(); onStreamEnd?.call();
break; break;
case 'auth_ok':
// Proxy authentication successful (MA 2.7.1+)
_logger.log('Sendspin: Proxy authentication successful');
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete(true);
}
break;
case 'ack': case 'ack':
case 'connected': case 'connected':
case 'registered': case 'registered':
+16
View File
@@ -15,6 +15,7 @@ class SettingsService {
static const String _keyUseMaterialTheme = 'use_material_theme'; static const String _keyUseMaterialTheme = 'use_material_theme';
static const String _keyAdaptiveTheme = 'adaptive_theme'; static const String _keyAdaptiveTheme = 'adaptive_theme';
static const String _keyCustomColor = 'custom_color'; static const String _keyCustomColor = 'custom_color';
static const String _keyLocale = 'locale';
static const String _keyLastFmApiKey = 'lastfm_api_key'; static const String _keyLastFmApiKey = 'lastfm_api_key';
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key'; static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
static const String _keyEnableLocalPlayback = 'enable_local_playback'; static const String _keyEnableLocalPlayback = 'enable_local_playback';
@@ -265,6 +266,21 @@ class SettingsService {
await prefs.setString(_keyCustomColor, color); await prefs.setString(_keyCustomColor, color);
} }
// Locale (null = system default)
static Future<String?> getLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocale);
}
static Future<void> saveLocale(String? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
await prefs.remove(_keyLocale);
} else {
await prefs.setString(_keyLocale, locale);
}
}
// Metadata API Keys // Metadata API Keys
static Future<String?> getLastFmApiKey() async { static Future<String?> getLastFmApiKey() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
+10 -7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
/// A reusable widget for displaying disconnected/offline state. /// A reusable widget for displaying disconnected/offline state.
@@ -21,29 +22,31 @@ class DisconnectedState extends StatelessWidget {
}); });
/// Simple disconnected state without action button /// Simple disconnected state without action button
factory DisconnectedState.simple() => const DisconnectedState( factory DisconnectedState.simple(BuildContext context) => DisconnectedState(
title: 'Not connected to Music Assistant', title: S.of(context)!.notConnected,
); );
/// Disconnected state with settings button for navigation /// Disconnected state with settings button for navigation
factory DisconnectedState.withSettingsAction({ factory DisconnectedState.withSettingsAction({
required BuildContext context,
required VoidCallback onSettings, required VoidCallback onSettings,
}) => }) =>
DisconnectedState( DisconnectedState(
title: 'Not connected to Music Assistant', title: S.of(context)!.notConnected,
actionLabel: 'Configure Server', actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded, actionIcon: Icons.settings_rounded,
onAction: onSettings, onAction: onSettings,
); );
/// Full disconnected state with title, subtitle, and action (for home screen) /// Full disconnected state with title, subtitle, and action (for home screen)
factory DisconnectedState.full({ factory DisconnectedState.full({
required BuildContext context,
required VoidCallback onSettings, required VoidCallback onSettings,
}) => }) =>
DisconnectedState( DisconnectedState(
title: 'Not Connected', title: S.of(context)!.notConnectedTitle,
subtitle: 'Connect to your Music Assistant server to start listening', subtitle: S.of(context)!.connectHint,
actionLabel: 'Configure Server', actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded, actionIcon: Icons.settings_rounded,
onAction: onSettings, onAction: onSettings,
iconSize: 80, iconSize: 80,
+41 -17
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
/// A reusable empty state widget for displaying when no data is available. /// A reusable empty state widget for displaying when no data is available.
@@ -21,48 +22,66 @@ class EmptyState extends StatelessWidget {
}); });
/// Creates an empty state for artists /// Creates an empty state for artists
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState( factory EmptyState.artists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.person_outline_rounded, icon: Icons.person_outline_rounded,
message: 'No artists found', message: s.noArtistsFound,
actionLabel: onRefresh != null ? 'Refresh' : null, actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh, onAction: onRefresh,
); );
}
/// Creates an empty state for albums /// Creates an empty state for albums
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState( factory EmptyState.albums({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.album_outlined, icon: Icons.album_outlined,
message: 'No albums found', message: s.noAlbumsFound,
actionLabel: onRefresh != null ? 'Refresh' : null, actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh, onAction: onRefresh,
); );
}
/// Creates an empty state for tracks /// Creates an empty state for tracks
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState( factory EmptyState.tracks({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.music_note_outlined, icon: Icons.music_note_outlined,
message: 'No tracks found', message: s.noTracksFound,
actionLabel: onRefresh != null ? 'Refresh' : null, actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh, onAction: onRefresh,
); );
}
/// Creates an empty state for playlists /// Creates an empty state for playlists
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState( factory EmptyState.playlists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.playlist_play_rounded, icon: Icons.playlist_play_rounded,
message: 'No playlists found', message: s.noPlaylistsFound,
actionLabel: onRefresh != null ? 'Refresh' : null, actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh, onAction: onRefresh,
); );
}
/// Creates an empty state for queue /// Creates an empty state for queue
factory EmptyState.queue() => const EmptyState( factory EmptyState.queue({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.queue_music, icon: Icons.queue_music,
message: 'Queue is empty', message: s.queueIsEmpty,
); );
}
/// Creates an empty state for search results /// Creates an empty state for search results
factory EmptyState.search() => const EmptyState( factory EmptyState.search({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.search_off_rounded, icon: Icons.search_off_rounded,
message: 'No results found', message: s.noResultsFound,
); );
}
/// Creates a custom empty state with title and subtitle /// Creates a custom empty state with title and subtitle
factory EmptyState.custom({ factory EmptyState.custom({
@@ -70,12 +89,14 @@ class EmptyState extends StatelessWidget {
required String title, required String title,
String? subtitle, String? subtitle,
VoidCallback? onRefresh, VoidCallback? onRefresh,
required BuildContext context,
}) => }) =>
_CustomEmptyState( _CustomEmptyState(
icon: icon, icon: icon,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
onRefresh: onRefresh, onRefresh: onRefresh,
context: context,
); );
@override @override
@@ -126,17 +147,20 @@ class _CustomEmptyState extends EmptyState {
final String title; final String title;
final String? subtitle; final String? subtitle;
final VoidCallback? onRefresh; final VoidCallback? onRefresh;
final BuildContext context;
const _CustomEmptyState({ const _CustomEmptyState({
required super.icon, required super.icon,
required this.title, required this.title,
this.subtitle, this.subtitle,
this.onRefresh, this.onRefresh,
required this.context,
}) : super(message: title); }) : super(message: title);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final s = S.of(context)!;
return Center( return Center(
child: Padding( child: Padding(
@@ -175,7 +199,7 @@ class _CustomEmptyState extends EmptyState {
ElevatedButton.icon( ElevatedButton.icon(
onPressed: onRefresh, onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'), label: Text(s.refresh),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant, backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant, foregroundColor: colorScheme.onSurfaceVariant,
+167 -20
View File
@@ -10,6 +10,7 @@ import '../models/player.dart';
import '../services/animation_debugger.dart'; import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart';
import 'animated_icon_button.dart'; import 'animated_icon_button.dart';
import 'global_player_overlay.dart'; import 'global_player_overlay.dart';
import 'volume_control.dart'; import 'volume_control.dart';
@@ -24,9 +25,17 @@ class ExpandablePlayer extends StatefulWidget {
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen) /// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
final double slideOffset; final double slideOffset;
/// Bounce offset for the player reveal animation (moves mini player down slightly)
final double bounceOffset;
/// Callback when swipe-down gesture triggers player reveal
final VoidCallback? onRevealPlayers;
const ExpandablePlayer({ const ExpandablePlayer({
super.key, super.key,
this.slideOffset = 0.0, this.slideOffset = 0.0,
this.bounceOffset = 0.0,
this.onRevealPlayers,
}); });
@override @override
@@ -90,6 +99,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
String? _lastMeasuredTrackName; String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth; double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state
bool _isVerticalDragging = false;
double _dragStartY = 0;
double _dragStartValue = 0;
// Maximum drag distance for full expand/collapse
static const double _maxDragDistance = 300.0;
// Thresholds for committing expand/collapse
static const double _commitPositionThreshold = 0.3; // 30% progress
static const double _commitVelocityThreshold = 800.0; // px/s
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -97,6 +118,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
vsync: this, vsync: this,
); );
// Simple curved animation - performant and smooth
_expandAnimation = CurvedAnimation( _expandAnimation = CurvedAnimation(
parent: _controller, parent: _controller,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
@@ -181,23 +204,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
AnimationDebugger.recordFrame(_controller.value); AnimationDebugger.recordFrame(_controller.value);
} }
// Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200);
void expand() { void expand() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerExpand'); AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) { _controller.forward().then((_) {
AnimationDebugger.endSession(); AnimationDebugger.endSession();
}); });
} }
void collapse() { void collapse() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerCollapse'); AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches // Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture // during Android's predictive back gesture
_queuePanelController.value = 0; _queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) { _controller.reverse().then((_) {
AnimationDebugger.endSession(); AnimationDebugger.endSession();
}); });
} }
/// Handle vertical drag start - begin gesture-driven expansion
void _handleVerticalDragStart(DragStartDetails details) {
_isVerticalDragging = true;
_dragStartY = details.globalPosition.dy;
_dragStartValue = _controller.value;
_controller.stop(); // Stop any running animation
}
/// Handle vertical drag update - finger tracking
void _handleVerticalDragUpdate(DragUpdateDetails details) {
if (!_isVerticalDragging) return;
final dragDelta = _dragStartY - details.globalPosition.dy;
// Swipe up = positive delta = expand (increase value)
// Swipe down = negative delta = collapse (decrease value)
final normalizedDelta = dragDelta / _maxDragDistance;
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
_controller.value = newValue;
}
/// Handle vertical drag end - decide to commit or snap back
void _handleVerticalDragEnd(DragEndDetails details) {
if (!_isVerticalDragging) return;
_isVerticalDragging = false;
final velocity = details.primaryVelocity ?? 0;
final currentValue = _controller.value;
// Determine direction based on velocity first, then position
bool shouldExpand;
if (velocity.abs() > _commitVelocityThreshold) {
// High velocity: use velocity direction (negative = swipe up = expand)
shouldExpand = velocity < 0;
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
// In the middle zone: use velocity direction if any, otherwise snap to nearest
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
} else {
// Near edges: commit to nearest endpoint
shouldExpand = currentValue > 0.5;
}
// Animate to target with appropriate duration
if (shouldExpand) {
if (currentValue < 1.0) {
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
} else {
if (currentValue > 0.0) {
AnimationDebugger.startSession('playerCollapse');
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
}
}
bool get isExpanded => _controller.value > 0.5; bool get isExpanded => _controller.value > 0.5;
double get expansionProgress => _controller.value; double get expansionProgress => _controller.value;
@@ -400,7 +494,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to update favorite: $e'), content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -849,9 +943,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final bottomOffset = bottomNavSpace + _collapsedMargin; final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2); final width = screenSize.width - (_collapsedMargin * 2);
// Apply slide offset for hiding // Apply slide offset for hiding and bounce offset for reveal animation
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20); final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
final adjustedBottomOffset = bottomOffset - slideDownAmount; final bounceDownAmount = widget.bounceOffset;
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
final availablePlayers = _getAvailablePlayersSorted(maProvider); final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1; final hasMultiplePlayers = availablePlayers.length > 1;
@@ -860,6 +955,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
left: _collapsedMargin, left: _collapsedMargin,
right: _collapsedMargin, right: _collapsedMargin,
bottom: adjustedBottomOffset, bottom: adjustedBottomOffset,
child: GestureDetector(
onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!();
}
},
child: DeviceSelectorBar( child: DeviceSelectorBar(
selectedPlayer: selectedPlayer, selectedPlayer: selectedPlayer,
peekPlayer: _peekPlayer, peekPlayer: _peekPlayer,
@@ -896,6 +997,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider); _handleHorizontalDragEnd(details, maProvider);
}, },
), ),
),
); );
} }
@@ -953,11 +1055,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedHeight = screenSize.height - bottomNavSpace; final expandedHeight = screenSize.height - bottomNavSpace;
// Apply slide offset to hide mini player (slides down off-screen) // Apply slide offset to hide mini player (slides down off-screen)
// Only apply when collapsed (t == 0), don't affect expanded state // Apply bounce offset for reveal animation (small downward movement)
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20); final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
final slideAdjustedBottomOffset = t < 0.1 final bounceDownAmount = widget.bounceOffset;
? collapsedBottomOffset - slideDownAmount // Fade out slide/bounce effects smoothly over first 15% of animation
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t); final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
final collapsedWidth = screenSize.width - (_collapsedMargin * 2); final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
final width = _lerpDouble(collapsedWidth, screenSize.width, t); final width = _lerpDouble(collapsedWidth, screenSize.width, t);
@@ -1001,7 +1106,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Artist: medium weight, secondary (18px expanded, was 16px) // Artist: medium weight, secondary (18px expanded, was 16px)
// Album: light, tertiary (15px expanded, was 13px) // Album: light, tertiary (15px expanded, was 13px)
final titleFontSize = _lerpDouble(16.0, 24.0, t); final titleFontSize = _lerpDouble(16.0, 24.0, t);
final artistFontSize = _lerpDouble(14.0, 18.0, t); // Increased from 16 to 18 final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less) final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
final expandedTitleLeft = contentPadding; final expandedTitleLeft = contentPadding;
@@ -1009,7 +1114,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap) final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150; // Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
final expandedTitleWidth = screenSize.width - (contentPadding * 2); final expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t); final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
@@ -1098,7 +1206,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final selectedPlayerId = selectedPlayer.playerId; final selectedPlayerId = selectedPlayer.playerId;
// Calculate slide offset for mini player content (only when collapsed) // Calculate slide offset for mini player content (only when collapsed)
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0; // Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
return Positioned( return Positioned(
left: horizontalMargin, left: horizontalMargin,
@@ -1109,13 +1218,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps // Only handle tap when collapsed - when expanded, let children handle their own taps
onTap: isExpanded ? null : expand, onTap: isExpanded ? null : expand,
onVerticalDragUpdate: (details) { onVerticalDragStart: (details) {
if (details.primaryDelta! < -10 && !isExpanded) { // For expanded player or queue panel: start tracking immediately
expand(); // For collapsed player: defer decision until we know swipe direction
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) { if (isExpanded || isQueuePanelOpen) {
collapse(); _handleVerticalDragStart(details);
} }
}, },
onVerticalDragUpdate: (details) {
final delta = details.primaryDelta ?? 0;
// Handle queue panel close
if (isQueuePanelOpen && delta > 0) {
_toggleQueuePanel();
return;
}
// Collapsed + not yet tracking: decide based on direction
if (!isExpanded && !_isVerticalDragging) {
if (delta > 5 && widget.onRevealPlayers != null) {
// Swipe down → show player reveal
widget.onRevealPlayers!();
return;
} else if (delta < -5) {
// Swipe up → start expand tracking
_handleVerticalDragStart(DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
));
} else {
// Not enough movement yet, wait for more
return;
}
}
// Gesture-driven expand/collapse
_handleVerticalDragUpdate(details);
},
onVerticalDragEnd: (details) {
// Finish gesture-driven expansion
_handleVerticalDragEnd(details);
},
onHorizontalDragStart: (details) { onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx; _horizontalDragStartX = details.globalPosition.dx;
}, },
@@ -1213,6 +1356,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Must have peek data to render // Must have peek data to render
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition)) if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
_buildPeekContent( _buildPeekContent(
context: context,
maProvider: maProvider, maProvider: maProvider,
peekPlayer: _peekPlayer, peekPlayer: _peekPlayer,
peekImageUrl: _peekImageUrl, peekImageUrl: _peekImageUrl,
@@ -1304,6 +1448,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1, maxLines: t > 0.5 ? 2 : 1,
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -1322,7 +1467,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
width: titleWidth, width: titleWidth,
child: Text( child: Text(
maProvider.isPlayingAudiobook maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? 'Unknown Author') ? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString, : currentTrack.artistsString,
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6), color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
@@ -1331,6 +1476,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left, textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: 1, maxLines: 1,
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -1382,7 +1528,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
maProvider.currentAudioFormat ?? 'PCM Audio', maProvider.currentAudioFormat ?? S.of(context)!.pcmAudio,
style: TextStyle( style: TextStyle(
color: primaryColor.withOpacity(0.7), color: primaryColor.withOpacity(0.7),
fontSize: 12, fontSize: 12,
@@ -1438,7 +1584,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')), SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
); );
} }
} finally { } finally {
@@ -1706,6 +1852,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
/// Build the peek player content that slides in from the edge during drag /// Build the peek player content that slides in from the edge during drag
Widget _buildPeekContent({ Widget _buildPeekContent({
required BuildContext context,
required MusicAssistantProvider maProvider, required MusicAssistantProvider maProvider,
required dynamic peekPlayer, required dynamic peekPlayer,
required String? peekImageUrl, required String? peekImageUrl,
@@ -1745,8 +1892,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Check if peek player has a track - if not, show device info instead // Check if peek player has a track - if not, show device info instead
final hasTrack = _peekTrack != null && peekImageUrl != null; final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? 'Unknown'); final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : 'Swipe to switch device'; final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
return Stack( return Stack(
children: [ children: [
+129 -16
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import 'expandable_player.dart'; import 'expandable_player.dart';
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment /// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll /// Avoids expensive HSL conversions during scroll
@@ -110,13 +113,48 @@ class GlobalPlayerOverlay extends StatefulWidget {
static void showPlayer() { static void showPlayer() {
_overlayStateKey.currentState?._setHidden(false); _overlayStateKey.currentState?._setHidden(false);
} }
/// Show the player reveal overlay with bounce animation
static void showPlayerReveal() {
_overlayStateKey.currentState?._showPlayerReveal();
}
/// Hide the player reveal overlay (no animation - used as callback from overlay)
static void hidePlayerReveal() {
_overlayStateKey.currentState?._hidePlayerReveal();
}
/// Dismiss the player reveal overlay with animation (for back gesture)
static void dismissPlayerReveal() {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
} }
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay> class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
with SingleTickerProviderStateMixin { with TickerProviderStateMixin {
late AnimationController _slideController; late AnimationController _slideController;
late Animation<double> _slideAnimation; late Animation<double> _slideAnimation;
// Controller for player reveal animation with bounce
late AnimationController _bounceController;
late Animation<double> _bounceAnimation;
// State for player reveal overlay
bool _isRevealVisible = false;
double _bounceOffset = 0.0;
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -129,11 +167,31 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic, reverseCurve: Curves.easeInCubic,
); );
// Bounce animation - quick dip down then back up
_bounceController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_bounceAnimation = CurvedAnimation(
parent: _bounceController,
curve: Curves.easeOut,
);
_bounceController.addListener(() {
setState(() {
// Quick dip: goes down 10px at peak (0.5), then back to 0
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
});
} }
@override @override
void dispose() { void dispose() {
_slideController.dispose(); _slideController.dispose();
_bounceController.dispose();
super.dispose(); super.dispose();
} }
@@ -145,12 +203,55 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
} }
} }
void _showPlayerReveal() {
if (_isRevealVisible) return;
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
_bounceController.reset();
setState(() {
_isRevealVisible = true;
});
_bounceController.forward();
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
setState(() {
_isRevealVisible = false;
_bounceOffset = 0;
});
}
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
// Note: dismiss() triggers the bounce animation internally
_revealKey.currentState?.dismiss();
}
/// Trigger bounce animation on mini player
void _triggerBounce() {
_bounceController.reset();
_bounceController.forward();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Stack( // Handle back gesture at top level - dismiss device list if visible
return PopScope(
canPop: !_isRevealVisible,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isRevealVisible) {
_dismissPlayerReveal();
}
},
child: Stack(
children: [ children: [
// The main app content (Navigator, screens, etc.) // The main app content (Navigator, screens, etc.)
// Add bottom padding to account for bottom nav + mini player // Add bottom padding to account for bottom nav + mini player
@@ -206,26 +307,26 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
selectedFontSize: 12, selectedFontSize: 12,
unselectedFontSize: 12, unselectedFontSize: 12,
items: const [ items: [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.home_outlined), icon: const Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded), activeIcon: const Icon(Icons.home_rounded),
label: 'Home', label: S.of(context)!.home,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined), icon: const Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded), activeIcon: const Icon(Icons.library_music_rounded),
label: 'Library', label: S.of(context)!.library,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded), activeIcon: const Icon(Icons.search_rounded),
label: 'Search', label: S.of(context)!.search,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined), icon: const Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded), activeIcon: const Icon(Icons.settings_rounded),
label: 'Settings', label: S.of(context)!.settings,
), ),
], ],
), ),
@@ -258,7 +359,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}, },
), ),
), ),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav // Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
miniPlayerHeight: 64,
),
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation // Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>( Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
selector: (_, provider) => ( selector: (_, provider) => (
@@ -278,12 +388,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return ExpandablePlayer( return ExpandablePlayer(
key: globalPlayerKey, key: globalPlayerKey,
slideOffset: _slideAnimation.value, slideOffset: _slideAnimation.value,
bounceOffset: _bounceOffset,
onRevealPlayers: _showPlayerReveal,
); );
}, },
); );
}, },
), ),
], ],
),
); );
} }
} }
+7 -5
View File
@@ -3,6 +3,7 @@ import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' show Audiobook, Chapter; import '../../models/media_item.dart' show Audiobook, Chapter;
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../common/empty_state.dart'; import '../common/empty_state.dart';
import '../../l10n/app_localizations.dart';
/// Panel that displays audiobook chapters for navigation /// Panel that displays audiobook chapters for navigation
class ChaptersPanel extends StatelessWidget { class ChaptersPanel extends StatelessWidget {
@@ -58,7 +59,7 @@ class ChaptersPanel extends StatelessWidget {
), ),
const Spacer(), const Spacer(),
Text( Text(
'Chapters', S.of(context)!.chapters,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontSize: 18, fontSize: 18,
@@ -75,7 +76,7 @@ class ChaptersPanel extends StatelessWidget {
// Chapters list // Chapters list
Expanded( Expanded(
child: chapters.isEmpty child: chapters.isEmpty
? _buildEmptyState() ? _buildEmptyState(context)
: _buildChaptersList(chapters, currentChapterIndex), : _buildChaptersList(chapters, currentChapterIndex),
), ),
], ],
@@ -83,11 +84,12 @@ class ChaptersPanel extends StatelessWidget {
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(BuildContext context) {
return EmptyState.custom( return EmptyState.custom(
context: context,
icon: Icons.bookmark_outline_rounded, icon: Icons.bookmark_outline_rounded,
title: 'No chapters', title: S.of(context)!.noChapters,
subtitle: 'This audiobook has no chapter information', subtitle: S.of(context)!.noChapterInfo,
); );
} }
+39 -21
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart'; import '../../l10n/app_localizations.dart';
/// A compact device selector bar shown when no track is playing /// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget { class DeviceSelectorBar extends StatelessWidget {
@@ -52,22 +52,31 @@ class DeviceSelectorBar extends StatelessWidget {
children: [ children: [
// Peek player content (shows when dragging) // Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null) if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(), _buildPeekContent(context),
// Current player content // Current player content
Transform.translate( Transform.translate(
offset: Offset(slideOffset * width, 0), offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row( child: Row(
children: [ children: [
Icon( // Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(selectedPlayer.name), _getPlayerIcon(selectedPlayer.name),
color: textColor, color: textColor.withOpacity(0.4),
size: IconSizes.md, size: 28,
),
),
),
), ),
Spacing.hGap12,
Expanded( Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -84,17 +93,17 @@ class DeviceSelectorBar extends StatelessWidget {
), ),
if (hasMultiplePlayers) if (hasMultiplePlayers)
Text( Text(
'Swipe to switch device', S.of(context)!.swipeToSwitchDevice,
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.6), color: textColor.withOpacity(0.6),
fontSize: 12, fontSize: 14,
), ),
), ),
], ],
), ),
), ),
],
), ),
],
), ),
), ),
], ],
@@ -106,7 +115,7 @@ class DeviceSelectorBar extends StatelessWidget {
} }
/// Build peek player content that slides in from the edge /// Build peek player content that slides in from the edge
Widget _buildPeekContent() { Widget _buildPeekContent(BuildContext context) {
final isFromRight = slideOffset < 0; final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs(); final peekProgress = slideOffset.abs();
@@ -117,17 +126,26 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate( return Transform.translate(
offset: Offset(peekBaseOffset, 0), offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row( child: Row(
children: [ children: [
Icon( // Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(peekPlayer.name), _getPlayerIcon(peekPlayer.name),
color: textColor, color: textColor.withOpacity(0.4),
size: IconSizes.md, size: 28,
),
),
),
), ),
Spacing.hGap12,
Expanded( Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -144,17 +162,17 @@ class DeviceSelectorBar extends StatelessWidget {
), ),
if (hasMultiplePlayers) if (hasMultiplePlayers)
Text( Text(
'Swipe to switch device', S.of(context)!.swipeToSwitchDevice,
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.6), color: textColor.withOpacity(0.6),
fontSize: 12, fontSize: 14,
), ),
), ),
], ],
), ),
), ),
],
), ),
],
), ),
); );
} }
+233
View File
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onPlayPause,
this.onSkipNext,
this.onPower,
});
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
);
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
return Colors.grey;
}
if (isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}
@@ -0,0 +1,307 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
import 'player_card.dart';
/// Overlay that reveals all available players with a staggered slide-up animation.
/// Triggered by swiping down on the mini player or tapping the device button.
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom; // Bottom position of mini player
final double miniPlayerHeight;
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
});
@override
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
}
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
with TickerProviderStateMixin {
late AnimationController _revealController;
late Animation<double> _revealAnimation;
late Animation<double> _backdropAnimation;
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
@override
void initState() {
super.initState();
_revealController = AnimationController(
vsync: this,
);
// Quick smooth animation with subtle overshoot
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_backdropAnimation = CurvedAnimation(
parent: _revealController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
}
});
}
/// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
await maProvider.preloadAllPlayerTracks();
final players = maProvider.availablePlayers;
for (final player in players) {
if (_playerColors.containsKey(player.playerId)) continue;
final track = maProvider.getCachedTrackForPlayer(player.playerId);
if (track == null) continue;
final imageUrl = maProvider.getImageUrl(track, size: 128);
if (imageUrl == null) continue;
try {
final imageProvider = NetworkImage(imageUrl);
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
if (mounted && schemes != null) {
final colorScheme = isDark ? schemes.$2 : schemes.$1;
setState(() {
_playerColors[player.playerId] = colorScheme;
});
}
} catch (_) {
// Ignore color extraction errors
}
}
}
@override
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// Animate reveal (called from parent)
void reveal() {
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
}
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger settle bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
final defaultBgColor = colorScheme.primaryContainer;
final defaultTextColor = colorScheme.onPrimaryContainer;
// Calculate total stack height for animation
const cardHeight = 64.0;
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
final t = _revealAnimation.value;
return Stack(
children: [
// Backdrop - tap to dismiss
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
child: Container(
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
),
),
),
// Player cards - positioned above mini player
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Build player cards - all slide from behind mini player
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
),
),
);
}),
],
),
),
),
],
);
},
);
},
);
}
}
+3 -3
View File
@@ -70,7 +70,7 @@ class QueuePanel extends StatelessWidget {
child: isLoading child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor)) ? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty : queue == null || queue!.items.isEmpty
? _buildEmptyState() ? _buildEmptyState(context)
: _buildQueueList(), : _buildQueueList(),
), ),
], ],
@@ -78,8 +78,8 @@ class QueuePanel extends StatelessWidget {
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(BuildContext context) {
return EmptyState.queue(); return EmptyState.queue(context: context);
} }
Widget _buildQueueList() { Widget _buildQueueList() {
+8 -26
View File
@@ -4,6 +4,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
@@ -63,27 +64,8 @@ class PlayerSelector extends StatelessWidget {
MusicAssistantProvider provider, MusicAssistantProvider provider,
List players, List players,
) { ) {
// Collapse player if expanded before showing sheet // Use the new player reveal animation
if (GlobalPlayerOverlay.isPlayerExpanded) { GlobalPlayerOverlay.showPlayerReveal();
GlobalPlayerOverlay.collapsePlayer();
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
// Show the bottom sheet
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true,
builder: (context) {
return _PlayerSelectorSheet();
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
} }
} }
@@ -182,7 +164,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
child: currentPlayers.isEmpty child: currentPlayers.isEmpty
? Center( ? Center(
child: Text( child: Text(
'No players available', S.of(context)!.noPlayersAvailable,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
), ),
) )
@@ -304,12 +286,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
Expanded( Expanded(
child: Text( child: Text(
!player.available !player.available
? 'Unavailable' ? S.of(context)!.playerStateUnavailable
: !isOn : !isOn
? 'Off' ? S.of(context)!.playerStateOff
: hasContent && playerTrack != null : hasContent && playerTrack != null
? playerTrack.name ? playerTrack.name
: 'Idle', : S.of(context)!.playerStateIdle,
style: TextStyle( style: TextStyle(
color: player.available color: player.available
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
@@ -329,7 +311,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
'Selected', S.of(context)!.playerSelected,
style: TextStyle( style: TextStyle(
color: colorScheme.primary, color: colorScheme.primary,
fontSize: 10, fontSize: 10,
+5 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.2.0-beta+19 version: 2.3.3-beta+25
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -9,6 +9,9 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
# UI # UI
material_design_icons_flutter: ^7.0.7296 material_design_icons_flutter: ^7.0.7296
@@ -54,6 +57,7 @@ dev_dependencies:
drift_dev: ^2.22.1 drift_dev: ^2.22.1
flutter: flutter:
generate: true
uses-material-design: true uses-material-design: true
assets: assets: