Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0342caaf5c | ||
|
|
d85dc9e592 | ||
|
|
9338828ac7 | ||
|
|
d920abd241 | ||
|
|
02f7d501ff | ||
|
|
5f72cca25e | ||
|
|
c2f75797e1 | ||
|
|
3d982892de | ||
|
|
c48f9d19c4 | ||
|
|
e4f24766d2 | ||
|
|
d7ac8befac | ||
|
|
8a2a3b8a14 | ||
|
|
ea1133c4d6 | ||
|
|
cb2595e36a | ||
|
|
70e983de51 | ||
|
|
251579d32a | ||
|
|
9d5e8abc11 | ||
|
|
225a1bd010 | ||
|
|
ef5f6b6483 | ||
|
|
4a369fa5f5 | ||
|
|
52489fa97a | ||
|
|
5c52ef2738 | ||
|
|
447b8c8a93 | ||
|
|
eb34663d3f | ||
|
|
2d359db25c | ||
|
|
0845d6d29a | ||
|
|
c80a86abe8 | ||
|
|
8358274f73 | ||
|
|
9200af705a | ||
|
|
1eed678deb | ||
|
|
4ab31c11df | ||
|
|
4c7ecfbf88 | ||
|
|
d376fd3725 | ||
|
|
a7893e6ad9 | ||
|
|
d3474a2665 | ||
|
|
5bfa91c566 | ||
|
|
884b457139 | ||
|
|
5e2599bdb8 | ||
|
|
4dec8f43a2 | ||
|
|
ee7a08a859 | ||
|
|
4351a32cde | ||
|
|
587115dc2b | ||
|
|
862c9d5ef3 | ||
|
|
ed7613e6ad | ||
|
|
74d2b1b1eb | ||
|
|
951c8a68ea | ||
|
|
c24dec51c5 | ||
|
|
11ed7e7d34 | ||
|
|
40f72f8db9 | ||
|
|
5e2fb6ff32 | ||
|
|
47894e0bb9 | ||
|
|
c9f7321bec |
@@ -1,136 +1,257 @@
|
||||
# Ensemble Flutter App - Code Audit Report
|
||||
# Ensemble Codebase Audit Report
|
||||
|
||||
**Date:** January 4, 2026
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Files Audited:** 93 Dart files
|
||||
**Date**: 2026-01-14
|
||||
**Scope**: Full codebase audit (104 Dart files, 18 screens)
|
||||
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
|
||||
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
|
||||
|
||||
| Category | Issues Found | Priority |
|
||||
|----------|-------------|----------|
|
||||
| Deprecated APIs | 20 instances | HIGH |
|
||||
| Missing const constructors | 100+ instances | MEDIUM |
|
||||
| Image cache parameters | 7 locations | MEDIUM |
|
||||
| Code duplication | 8 view mode functions | LOW |
|
||||
| Color constants | 3 files with duplicates | LOW |
|
||||
| Severity | Count | Key Themes |
|
||||
|----------|-------|------------|
|
||||
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
|
||||
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
|
||||
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
|
||||
| **Low** | 15+ | Minor polish items |
|
||||
|
||||
**Top 3 Priorities:**
|
||||
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
|
||||
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
|
||||
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL: Deprecated API Usage
|
||||
## Critical Issues
|
||||
|
||||
### `colorScheme.background` (20 instances)
|
||||
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
||||
### 1. God Object: MusicAssistantProvider (5,443 lines)
|
||||
**File**: `lib/providers/music_assistant_provider.dart`
|
||||
- 54 `notifyListeners()` calls
|
||||
- 30+ state variables
|
||||
- 100+ public methods
|
||||
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
|
||||
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
|
||||
|
||||
**Files affected:**
|
||||
- lib/main.dart:335, 336
|
||||
- lib/screens/library_artists_screen.dart:21
|
||||
- lib/screens/new_home_screen.dart:121
|
||||
- lib/screens/artist_details_screen.dart:708, 720
|
||||
- lib/screens/library_playlists_screen.dart:53
|
||||
- lib/screens/library_tracks_screen.dart:19
|
||||
- lib/screens/login_screen.dart:489
|
||||
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
||||
- lib/screens/album_details_screen.dart:807, 819
|
||||
- lib/screens/library_albums_screen.dart:19
|
||||
- lib/screens/search_screen.dart:427, 738, 739
|
||||
- lib/screens/settings_screen.dart:245
|
||||
### 2. God Object: NewLibraryScreen (4,209 lines)
|
||||
**File**: `lib/screens/new_library_screen.dart`
|
||||
- 10 scroll controllers
|
||||
- 15+ view mode states
|
||||
- 15+ sort order states
|
||||
- Handles: Music, Books, Podcasts, Radio tabs
|
||||
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
|
||||
|
||||
### 3. Sequential Blocking Startup (500-1500ms wasted)
|
||||
**File**: `lib/main.dart:33-66`
|
||||
```dart
|
||||
await DatabaseService.instance.initialize();
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
await SyncService.instance.loadFromCache();
|
||||
audioHandler = await AudioService.init(...);
|
||||
```
|
||||
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
|
||||
|
||||
### 4. Design Tokens Exist But Unused
|
||||
**File**: `lib/theme/design_tokens.dart`
|
||||
- `Spacing.*` used in only ~6 of 34+ widget files
|
||||
- `Radii.*` used in only ~5 files
|
||||
- `IconSizes.*` used in only ~4 files
|
||||
- **Fix**: Systematic replacement of hardcoded values
|
||||
|
||||
### 5. Touch Targets Too Small (32x32)
|
||||
**File**: `lib/screens/new_library_screen.dart:1805-1867`
|
||||
- Sort, favorites, view mode buttons are 32x32
|
||||
- Material Design minimum: 48x48
|
||||
- **Fix**: Increase to 48x48 or add padding for hit area
|
||||
|
||||
### 6. No Batch Database Inserts
|
||||
**File**: `lib/services/sync_service.dart:212-273`
|
||||
- 1000 albums = 1000 separate DB transactions
|
||||
- **Fix**: Wrap in single transaction or use Drift batch insert
|
||||
|
||||
---
|
||||
|
||||
## 2. HIGH: Missing const Constructors
|
||||
## High Severity Issues
|
||||
|
||||
### EdgeInsets.zero (64 instances)
|
||||
High-impact optimization - used extensively in button padding.
|
||||
### Performance
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
|
||||
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
|
||||
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
|
||||
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
|
||||
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
|
||||
|
||||
**Key files:**
|
||||
- lib/screens/album_details_screen.dart (12 instances)
|
||||
- lib/screens/artist_details_screen.dart (8 instances)
|
||||
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
||||
- lib/screens/audiobook_series_screen.dart (2 instances)
|
||||
- lib/screens/audiobook_author_screen.dart (2 instances)
|
||||
### Architecture
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
|
||||
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
|
||||
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
|
||||
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
|
||||
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
|
||||
|
||||
### BorderRadius.circular (40+ instances)
|
||||
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
||||
### UI/UX
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
|
||||
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
|
||||
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
|
||||
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
|
||||
|
||||
### Error Handling
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| No Flutter error boundaries | main.dart | Red error screen on build failure |
|
||||
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
|
||||
|
||||
---
|
||||
|
||||
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
|
||||
## Medium Severity Issues
|
||||
|
||||
### CachedNetworkImage without memCacheWidth/Height
|
||||
### Performance
|
||||
- PaletteGenerator on main isolate (10-30ms blocking)
|
||||
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
|
||||
- AppStartup polling loop with fixed 250ms delay
|
||||
- SystemThemeHelper FutureBuilder recreated on every build
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1395-1402: Author image (48x48) - missing cache params
|
||||
- Line 1480-1486: Author card image - missing cache params
|
||||
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
||||
- Line 1740-1745: Audiobook grid cover - missing cache params
|
||||
- Line 1959-1969: Playlist cover (56x56) - missing cache params
|
||||
- Line 2143-2152: Playlist grid covers - missing cache params
|
||||
- Line 2973-2981: Album cover (56x56) - missing cache params
|
||||
### UI Consistency
|
||||
- Border radius varies: 8, 12, 20, 22, 25 for same element types
|
||||
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
|
||||
- Hardcoded colors for status (Colors.red, Colors.green)
|
||||
- Inconsistent icon sizes across screens
|
||||
|
||||
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
|
||||
### State Management
|
||||
- Over-notification (multiple notifyListeners in single methods)
|
||||
- Wide Consumer scope (watch instead of select)
|
||||
- Missing dispose() in some providers
|
||||
- Inconsistent async state handling (loading/error)
|
||||
|
||||
### Navigation
|
||||
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
|
||||
- `popToHome()` doesn't clear adaptive colors
|
||||
- Infinite navigation loop possible (Album → Artist → Album)
|
||||
- Orphaned Hero animations in podcast_card, radio_station_card
|
||||
|
||||
### Data/Caching
|
||||
- Unbounded `_podcastCoverCache`
|
||||
- SyncService listener not removed on error
|
||||
- Redundant API calls between providers
|
||||
- Fire-and-forget persistence race condition
|
||||
|
||||
### Error Handling
|
||||
- No zone error handler for async exceptions
|
||||
- `retryCritical()` retries all errors including non-recoverable
|
||||
- No user notification of offline queue status
|
||||
- DisconnectedState not used consistently
|
||||
|
||||
---
|
||||
|
||||
## 4. LOW: Code Duplication
|
||||
## Quick Wins (High Impact, Low Effort)
|
||||
|
||||
### View Mode Cycling Functions (8 nearly identical functions)
|
||||
**File:** lib/screens/new_library_screen.dart
|
||||
|
||||
Functions that follow identical switch/case pattern:
|
||||
- `_cycleArtistsViewMode()` (line 184)
|
||||
- `_cycleAlbumsViewMode()` (line 200)
|
||||
- `_cyclePlaylistsViewMode()` (line 216)
|
||||
- `_cycleAuthorsViewMode()` (line 232)
|
||||
- `_cycleAudiobooksViewMode()` (line 248)
|
||||
- `_cycleSeriesViewMode()` (line 286)
|
||||
- `_cycleRadioViewMode()` (line 303)
|
||||
- `_cyclePodcastsViewMode()` (line 319)
|
||||
|
||||
**Recommendation:** Create generic utility function.
|
||||
|
||||
### Duplicated Color Constants (3 files)
|
||||
Series fallback colors defined identically in:
|
||||
- lib/widgets/series_row.dart:246-253
|
||||
- lib/screens/audiobook_series_screen.dart:466-473
|
||||
- lib/screens/new_library_screen.dart:2112-2119
|
||||
|
||||
**Recommendation:** Extract to lib/constants/colors.dart
|
||||
| # | Task | File(s) | Effort |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
|
||||
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
|
||||
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
|
||||
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
|
||||
| 5 | Add Flutter error boundary | main.dart | 30 min |
|
||||
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
|
||||
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
|
||||
|
||||
---
|
||||
|
||||
## 5. POSITIVE FINDINGS (Already Optimized)
|
||||
## Refactoring Roadmap
|
||||
|
||||
- **RepaintBoundary**: Properly implemented in all list item widgets
|
||||
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
||||
- **ScrollController disposal**: All 8+ controllers properly disposed
|
||||
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
||||
- **Debounced operations**: Color extraction debounced to 300ms
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
- Fix touch targets
|
||||
- Fix bottom spacing
|
||||
- Add navigation debounce
|
||||
- Add error boundaries
|
||||
- Cache SharedPreferences singleton
|
||||
|
||||
### Phase 2: Performance (3-5 days)
|
||||
- Parallelize startup operations
|
||||
- Replace `context.watch()` with `context.select()`
|
||||
- Batch database inserts
|
||||
- Add SecureStorage migration skip flag
|
||||
- Fix over-notification in provider
|
||||
|
||||
### Phase 3: Design System (1 week)
|
||||
- Systematic design token adoption
|
||||
- Standardize button sizes (44 vs 50)
|
||||
- Standardize border radius values
|
||||
- Create StatusColors semantic tokens
|
||||
- Replace all magic numbers
|
||||
|
||||
### Phase 4: Architecture (2-3 weeks)
|
||||
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
|
||||
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
|
||||
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
|
||||
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
|
||||
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
## Positive Findings
|
||||
|
||||
### Phase 1: Quick Wins (Low Risk)
|
||||
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
||||
2. Add `const` to EdgeInsets.zero instances
|
||||
The codebase already demonstrates many good practices:
|
||||
|
||||
### Phase 2: Image Optimization
|
||||
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
||||
|
||||
### Phase 3: Code Cleanup (Optional)
|
||||
1. Extract view mode cycling to utility function
|
||||
2. Centralize color constants
|
||||
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
|
||||
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
|
||||
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
|
||||
- **Theme system**: Material You support, adaptive colors from album art
|
||||
- **Offline support**: Cache-first loading, graceful degradation
|
||||
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
|
||||
|
||||
---
|
||||
|
||||
## Files by Complexity
|
||||
## Files by Priority
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
||||
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
||||
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
||||
| Priority | File | Lines | Key Issues |
|
||||
|----------|------|-------|------------|
|
||||
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
|
||||
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
|
||||
| 3 | main.dart | 549 | Startup blocking, error boundaries |
|
||||
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
|
||||
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
|
||||
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
|
||||
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
|
||||
| 8 | All detail screens | Various | Hardcoded bottom spacing |
|
||||
| 9 | All card widgets | Various | Navigation debounce, design tokens |
|
||||
|
||||
---
|
||||
|
||||
## Audit Methodology
|
||||
|
||||
This audit was conducted using 9 parallel sub-agents analyzing:
|
||||
|
||||
**UI Continuity (3 agents)**
|
||||
1. Navigation & Flow - back button, Hero animations, tab preservation
|
||||
2. Visual Consistency - design tokens, spacing, typography
|
||||
3. Component Placement - touch targets, bottom spacing, modals
|
||||
|
||||
**Performance (3 agents)**
|
||||
4. Rendering Performance - ListView.builder, const, rebuilds
|
||||
5. Data & Caching - streams, invalidation, memory bounds
|
||||
6. Startup & Load Time - blocking operations, initialization order
|
||||
|
||||
**Logic/Best Practices (3 agents)**
|
||||
7. State Management - Provider patterns, notification frequency
|
||||
8. Error Handling - try-catch, boundaries, resilience
|
||||
9. Code Quality - file size, duplication, SOLID principles
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
|
||||
|
||||
1. **Splitting the two 4,000+ line files** into focused components
|
||||
2. **Adopting design tokens systematically** for visual consistency
|
||||
3. **Optimizing startup sequence** for faster app launch
|
||||
4. **Using granular state management** (select vs watch) for better performance
|
||||
|
||||
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:label="Ensemble"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:appCategory="audio"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background_color">#1a1a1a</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#1a1a1a</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||
@@ -31,6 +31,9 @@ class Timings {
|
||||
/// Search debounce delay
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Navigation debounce to prevent double-tap navigation
|
||||
static const Duration navigationDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Delay after track change before updating state
|
||||
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
@@ -1401,5 +1401,35 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noTracks": "No tracks",
|
||||
"@noTracks": {
|
||||
"description": "Empty state title when no tracks exist in library"
|
||||
},
|
||||
|
||||
"addTracksHint": "Add some music to your library to see tracks here",
|
||||
"@addTracksHint": {
|
||||
"description": "Hint for adding tracks to library"
|
||||
},
|
||||
|
||||
"noFavoriteRadioStations": "No favorite radio stations",
|
||||
"@noFavoriteRadioStations": {
|
||||
"description": "Empty state when no favorite radio stations"
|
||||
},
|
||||
|
||||
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
|
||||
"@longPressRadioHint": {
|
||||
"description": "Hint for favoriting radio stations"
|
||||
},
|
||||
|
||||
"noFavoritePodcasts": "No favorite podcasts",
|
||||
"@noFavoritePodcasts": {
|
||||
"description": "Empty state when no favorite podcasts"
|
||||
},
|
||||
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for German (`de`).
|
||||
class SDe extends S {
|
||||
SDe([String locale = 'de']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Ensemble';
|
||||
|
||||
@override
|
||||
String get connecting => 'Verbinde...';
|
||||
|
||||
@override
|
||||
String get connected => 'Verbunden';
|
||||
|
||||
@override
|
||||
String get disconnected => 'Getrennt';
|
||||
|
||||
@override
|
||||
String get serverAddress => 'Serveradresse';
|
||||
|
||||
@override
|
||||
String get serverAddressHint => 'z.B. music.example.com oder 192.168.1.100';
|
||||
|
||||
@override
|
||||
String get yourName => 'Dein Name';
|
||||
|
||||
@override
|
||||
String get yourFirstName => 'Dein Vorname';
|
||||
|
||||
@override
|
||||
String get portOptional => 'Port (Optional)';
|
||||
|
||||
@override
|
||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
|
||||
@override
|
||||
String get username => 'Benutzername';
|
||||
|
||||
@override
|
||||
String get password => 'Passwort';
|
||||
|
||||
@override
|
||||
String get detectAndConnect => 'Erkennen & Verbinden';
|
||||
|
||||
@override
|
||||
String get connect => 'Verbinden';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Trennen';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
return 'Erkannt: $authType';
|
||||
}
|
||||
|
||||
@override
|
||||
String get serverRequiresAuth => 'Dieser Server erfordert Authentifizierung';
|
||||
|
||||
@override
|
||||
String get tailscaleVpnConnection => 'Tailscale VPN-Verbindung';
|
||||
|
||||
@override
|
||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Keine Authentifizierung';
|
||||
|
||||
@override
|
||||
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||
|
||||
@override
|
||||
String get authelia => 'Authelia';
|
||||
|
||||
@override
|
||||
String get musicAssistantLogin => 'Music Assistant Anmeldung';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Erneut drucken zum Minimieren';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Zuletzt gespielt';
|
||||
|
||||
@override
|
||||
String get discoverArtists => 'Kunstler entdecken';
|
||||
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@override
|
||||
String get discoverAudiobooks => 'Horbücher entdecken';
|
||||
|
||||
@override
|
||||
String get discoverSeries => 'Serien entdecken';
|
||||
|
||||
@override
|
||||
String get favoriteAlbums => 'Lieblingsalben';
|
||||
|
||||
@override
|
||||
String get favoriteArtists => 'Lieblingskünstler';
|
||||
|
||||
@override
|
||||
String get favoriteTracks => 'Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get favoritePlaylists => 'Favorite Playlists';
|
||||
|
||||
@override
|
||||
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||
|
||||
@override
|
||||
String get favoritePodcasts => 'Favorite Podcasts';
|
||||
|
||||
@override
|
||||
String get searchMusic => 'Musik suchen...';
|
||||
|
||||
@override
|
||||
String get searchForContent => 'Nach Künstlern, Alben oder Songs suchen';
|
||||
|
||||
@override
|
||||
String get recentSearches => 'Recent Searches';
|
||||
|
||||
@override
|
||||
String get clearSearchHistory => 'Clear Search History';
|
||||
|
||||
@override
|
||||
String get searchHistoryCleared => 'Search history cleared';
|
||||
|
||||
@override
|
||||
String get libraryOnly => 'Library only';
|
||||
|
||||
@override
|
||||
String get retry => 'Wiederholen';
|
||||
|
||||
@override
|
||||
String get all => 'Alle';
|
||||
|
||||
@override
|
||||
String get artists => 'Künstler';
|
||||
|
||||
@override
|
||||
String get albums => 'Alben';
|
||||
|
||||
@override
|
||||
String get tracks => 'Songs';
|
||||
|
||||
@override
|
||||
String get playlists => 'Wiedergabelisten';
|
||||
|
||||
@override
|
||||
String get audiobooks => 'Hörbücher';
|
||||
|
||||
@override
|
||||
String get music => 'Musik';
|
||||
|
||||
@override
|
||||
String get radio => 'Radio';
|
||||
|
||||
@override
|
||||
String get stations => 'Sender';
|
||||
|
||||
@override
|
||||
String get selectLibrary => 'Bibliothek auswählen';
|
||||
|
||||
@override
|
||||
String get noRadioStations => 'Keine Radiosender';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
|
||||
@override
|
||||
String get queue => 'Warteschlange';
|
||||
|
||||
@override
|
||||
String playerQueue(String playerName) {
|
||||
return '$playerName Warteschlange';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayerSelected => 'Kein Player ausgewählt';
|
||||
|
||||
@override
|
||||
String get undo => 'Rückgängig';
|
||||
|
||||
@override
|
||||
String removedItem(String itemName) {
|
||||
return '$itemName entfernt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settings => 'Einstellungen';
|
||||
|
||||
@override
|
||||
String get debugLogs => 'Debug-Protokolle';
|
||||
|
||||
@override
|
||||
String get themeMode => 'Design';
|
||||
|
||||
@override
|
||||
String get light => 'Hell';
|
||||
|
||||
@override
|
||||
String get dark => 'Dunkel';
|
||||
|
||||
@override
|
||||
String get system => 'System';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Ziehe zum Aktualisieren der Bibliothek';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Debug-Protokolle anzeigen';
|
||||
|
||||
@override
|
||||
String get viewAllPlayers => 'Alle Player anzeigen';
|
||||
|
||||
@override
|
||||
String get copyLogs => 'Protokolle kopieren';
|
||||
|
||||
@override
|
||||
String get clearLogs => 'Protokolle löschen';
|
||||
|
||||
@override
|
||||
String get copyList => 'Liste kopieren';
|
||||
|
||||
@override
|
||||
String get close => 'Schließen';
|
||||
|
||||
@override
|
||||
String allPlayersCount(int count) {
|
||||
return 'Alle Player ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String errorLoadingPlayers(String error) {
|
||||
return 'Fehler beim Laden der Player: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logsCopied => 'Protokolle in Zwischenablage kopiert';
|
||||
|
||||
@override
|
||||
String get logsCleared => 'Protokolle gelöscht';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Playerliste in Zwischenablage kopiert!';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Noch keine Protokolle';
|
||||
|
||||
@override
|
||||
String get infoPlus => 'Info+';
|
||||
|
||||
@override
|
||||
String get warnings => 'Warnungen';
|
||||
|
||||
@override
|
||||
String get errors => 'Fehler';
|
||||
|
||||
@override
|
||||
String showingEntries(int filtered, int total) {
|
||||
return 'Zeige $filtered von $total Einträgen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get thisDevice => 'Dieses Gerät';
|
||||
|
||||
@override
|
||||
String get ghostPlayer => 'Geist-Player (Duplikat)';
|
||||
|
||||
@override
|
||||
String get unavailableCorrupt => 'Nicht verfügbar/Beschädigt';
|
||||
|
||||
@override
|
||||
String playerId(String id) {
|
||||
return 'ID: $id';
|
||||
}
|
||||
|
||||
@override
|
||||
String playerInfo(String available, String provider) {
|
||||
return 'Verfügbar: $available | Anbieter: $provider';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareBugReport => 'Fehlerbericht teilen';
|
||||
|
||||
@override
|
||||
String get moreOptions => 'Weitere Optionen';
|
||||
|
||||
@override
|
||||
String failedToUpdateFavorite(String error) {
|
||||
return 'Favorit konnte nicht aktualisiert werden: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayersAvailable => 'Keine Player verfügbar';
|
||||
|
||||
@override
|
||||
String get albumAddedToQueue => 'Album zur Warteschlange hinzugefügt';
|
||||
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
|
||||
|
||||
@override
|
||||
String get play => 'Abspielen';
|
||||
|
||||
@override
|
||||
String get noPlayersFound => 'Keine Player gefunden';
|
||||
|
||||
@override
|
||||
String startingRadio(String name) {
|
||||
return 'Starte $name Radio';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToStartRadio(String error) {
|
||||
return 'Radio konnte nicht gestartet werden: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String startingRadioOnPlayer(String name, String playerName) {
|
||||
return 'Starte $name Radio auf $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String addedRadioToQueue(String name) {
|
||||
return '$name Radio zur Warteschlange hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToAddToQueue(String error) {
|
||||
return 'Konnte nicht zur Warteschlange hinzufügen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingRadioStation(String name) {
|
||||
return 'Playing $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addedToQueue => 'Added to queue';
|
||||
|
||||
@override
|
||||
String get inLibrary => 'In Bibliothek';
|
||||
|
||||
@override
|
||||
String get noAlbumsFound => 'Keine Alben gefunden';
|
||||
|
||||
@override
|
||||
String playing(String name) {
|
||||
return 'Spiele $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get playingTrack => 'Spiele Song';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'Wird gespielt';
|
||||
|
||||
@override
|
||||
String markedAsFinished(String name) {
|
||||
return '$name als beendet markiert';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkFinished(String error) {
|
||||
return 'Konnte nicht als beendet markieren: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String markedAsUnplayed(String name) {
|
||||
return '$name als ungespielt markiert';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkUnplayed(String error) {
|
||||
return 'Konnte nicht als ungespielt markieren: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlay(String error) {
|
||||
return 'Abspielen fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get markAsFinished => 'Als beendet markieren';
|
||||
|
||||
@override
|
||||
String get markAsUnplayed => 'Als ungespielt markieren';
|
||||
|
||||
@override
|
||||
String byAuthor(String author) {
|
||||
return 'Von $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String audiobookCount(int count) {
|
||||
return '$count Hörbuch/Hörbücher';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loading => 'Laden...';
|
||||
|
||||
@override
|
||||
String bookCount(int count) {
|
||||
return '$count Buch/Bücher';
|
||||
}
|
||||
|
||||
@override
|
||||
String get books => 'Bücher';
|
||||
|
||||
@override
|
||||
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Keine Hörbücher';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Serien laden';
|
||||
|
||||
@override
|
||||
String get notConnected => 'Nicht mit Music Assistant verbunden';
|
||||
|
||||
@override
|
||||
String get notConnectedTitle => 'Nicht verbunden';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Server konfigurieren';
|
||||
|
||||
@override
|
||||
String get noArtistsFound => 'Keine Künstler gefunden';
|
||||
|
||||
@override
|
||||
String get noTracksFound => 'Keine Songs gefunden';
|
||||
|
||||
@override
|
||||
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Warteschlange ist leer';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'Keine Ergebnisse gefunden';
|
||||
|
||||
@override
|
||||
String get refresh => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String get debugConsole => 'Debug-Konsole';
|
||||
|
||||
@override
|
||||
String get copy => 'Kopieren';
|
||||
|
||||
@override
|
||||
String get clear => 'Löschen';
|
||||
|
||||
@override
|
||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Debug anzeigen';
|
||||
|
||||
@override
|
||||
String get hideDebug => 'Debug ausblenden';
|
||||
|
||||
@override
|
||||
String get chapters => 'Kapitel';
|
||||
|
||||
@override
|
||||
String get noChapters => 'Keine Kapitel';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Dieses Hörbuch hat keine Kapitelinformationen';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
return 'Fehler beim Spulen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String error(String error) {
|
||||
return 'Fehler: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get home => 'Start';
|
||||
|
||||
@override
|
||||
String get library => 'Bibliothek';
|
||||
|
||||
@override
|
||||
String get homeScreen => 'Startbildschirm';
|
||||
|
||||
@override
|
||||
String get metadataApis => 'Metadaten-APIs';
|
||||
|
||||
@override
|
||||
String get theme => 'Design';
|
||||
|
||||
@override
|
||||
String get materialYou => 'Material You';
|
||||
|
||||
@override
|
||||
String get adaptiveTheme => 'Adaptives Design';
|
||||
|
||||
@override
|
||||
String get language => 'Sprache';
|
||||
|
||||
@override
|
||||
String get english => 'Englisch';
|
||||
|
||||
@override
|
||||
String get german => 'Deutsch';
|
||||
|
||||
@override
|
||||
String get spanish => 'Spanisch';
|
||||
|
||||
@override
|
||||
String get french => 'Französisch';
|
||||
|
||||
@override
|
||||
String get noTracksInPlaylist => 'Keine Songs in Wiedergabeliste';
|
||||
|
||||
@override
|
||||
String get sortAlphabetically => 'Alphabetisch sortieren';
|
||||
|
||||
@override
|
||||
String get sortByYear => 'Nach Jahr sortieren';
|
||||
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
|
||||
|
||||
@override
|
||||
String get listView => 'Listenansicht';
|
||||
|
||||
@override
|
||||
String get gridView => 'Rasteransicht';
|
||||
|
||||
@override
|
||||
String get noBooksInSeries => 'Keine Bücher in dieser Serie gefunden';
|
||||
|
||||
@override
|
||||
String get artist => 'Künstler';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Kürzlich gespielte Alben anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Zufällige Künstler zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Lieblingskünstler anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Lieblingssongs anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
|
||||
@override
|
||||
String get removedFromFavorites => 'Aus Favoriten entfernt';
|
||||
|
||||
@override
|
||||
String get addedToLibrary => 'Added to library';
|
||||
|
||||
@override
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unbekannt';
|
||||
|
||||
@override
|
||||
String get noUpcomingTracks => 'Keine weiteren Songs';
|
||||
|
||||
@override
|
||||
String get showAll => 'Alle anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoritesOnly => 'Nur Favoriten anzeigen';
|
||||
|
||||
@override
|
||||
String get changeView => 'Ansicht ändern';
|
||||
|
||||
@override
|
||||
String get authors => 'Autoren';
|
||||
|
||||
@override
|
||||
String get series => 'Serien';
|
||||
|
||||
@override
|
||||
String get shows => 'Sendungen';
|
||||
|
||||
@override
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Podcast-Unterstützung kommt bald';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'No podcasts';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get episode => 'Episode';
|
||||
|
||||
@override
|
||||
String get playlist => 'Wiedergabeliste';
|
||||
|
||||
@override
|
||||
String get connectionError => 'Verbindungsfehler';
|
||||
|
||||
@override
|
||||
String get twoColumnGrid => '2-Spalten-Raster';
|
||||
|
||||
@override
|
||||
String get threeColumnGrid => '3-Spalten-Raster';
|
||||
|
||||
@override
|
||||
String get fromProviders => 'Von Anbietern';
|
||||
|
||||
@override
|
||||
String get resume => 'Fortsetzen';
|
||||
|
||||
@override
|
||||
String get about => 'Über';
|
||||
|
||||
@override
|
||||
String get inProgress => 'In Bearbeitung';
|
||||
|
||||
@override
|
||||
String narratedBy(String narrators) {
|
||||
return 'Gesprochen von $narrators';
|
||||
}
|
||||
|
||||
@override
|
||||
String get unknownNarrator => 'Unbekannter Sprecher';
|
||||
|
||||
@override
|
||||
String get unknownAuthor => 'Unbekannter Autor';
|
||||
|
||||
@override
|
||||
String get loadingChapters => 'Lade Kapitel...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Keine Kapitelinformationen verfügbar';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% abgeschlossen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Keine Serien gefunden';
|
||||
|
||||
@override
|
||||
String get ensembleBugReport => 'Ensemble Fehlerbericht';
|
||||
|
||||
@override
|
||||
String byOwner(String owner) {
|
||||
return 'Von $owner';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
|
||||
@override
|
||||
String get search => 'Suche';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||
|
||||
@override
|
||||
String get lastFmApiKeyHint => 'Kostenlosen Schlüssel auf last.fm/api holen';
|
||||
|
||||
@override
|
||||
String get swipeToSwitchDevice => 'Wischen um Gerät zu wechseln';
|
||||
|
||||
@override
|
||||
String chapterNumber(int number) {
|
||||
return 'Kapitel $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pcmAudio => 'PCM-Audio';
|
||||
|
||||
@override
|
||||
String get playOn => 'Abspielen auf...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return '$name Radio starten auf...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get book => 'Buch';
|
||||
|
||||
@override
|
||||
String get audiobookSingular => 'Hörbuch';
|
||||
|
||||
@override
|
||||
String get albumSingular => 'Album';
|
||||
|
||||
@override
|
||||
String get trackSingular => 'Track';
|
||||
|
||||
@override
|
||||
String get podcastSingular => 'Podcast';
|
||||
|
||||
@override
|
||||
String trackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Songs',
|
||||
one: 'Song',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Systemfarben verwenden (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Akzentfarbe';
|
||||
|
||||
@override
|
||||
String get players => 'Geräte';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||
|
||||
@override
|
||||
String get playerStateOff => 'Aus';
|
||||
|
||||
@override
|
||||
String get playerStateIdle => 'Inaktiv';
|
||||
|
||||
@override
|
||||
String get playerStateExternalSource => 'Externe Quelle';
|
||||
|
||||
@override
|
||||
String get playerSelected => 'Ausgewählt';
|
||||
|
||||
@override
|
||||
String get actionQueuedForSync => 'Wird synchronisiert wenn online';
|
||||
|
||||
@override
|
||||
String pendingOfflineActions(int count) {
|
||||
return '$count ausstehende Synchronisierung';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hintsAndTips => 'Tipps & Hinweise';
|
||||
|
||||
@override
|
||||
String get showHints => 'Hinweise anzeigen';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||
|
||||
@override
|
||||
String get holdToSync => 'Gedrückt halten zum Sync';
|
||||
|
||||
@override
|
||||
String get swipeToAdjustVolume => 'Wischen für Lautstärke';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
return 'Playing $albumName';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingPlaylist(String playlistName) {
|
||||
return 'Playing $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class SEn extends S {
|
||||
SEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Ensemble';
|
||||
|
||||
@override
|
||||
String get connecting => 'Connecting...';
|
||||
|
||||
@override
|
||||
String get connected => 'Connected';
|
||||
|
||||
@override
|
||||
String get disconnected => 'Disconnected';
|
||||
|
||||
@override
|
||||
String get serverAddress => 'Server Address';
|
||||
|
||||
@override
|
||||
String get serverAddressHint => 'e.g., music.example.com or 192.168.1.100';
|
||||
|
||||
@override
|
||||
String get yourName => 'Your Name';
|
||||
|
||||
@override
|
||||
String get yourFirstName => 'Your first name';
|
||||
|
||||
@override
|
||||
String get portOptional => 'Port (Optional)';
|
||||
|
||||
@override
|
||||
String get portHint => 'e.g., 8095 or leave blank';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
|
||||
@override
|
||||
String get password => 'Password';
|
||||
|
||||
@override
|
||||
String get detectAndConnect => 'Detect & Connect';
|
||||
|
||||
@override
|
||||
String get connect => 'Connect';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Disconnect';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
return 'Detected: $authType';
|
||||
}
|
||||
|
||||
@override
|
||||
String get serverRequiresAuth => 'This server requires authentication';
|
||||
|
||||
@override
|
||||
String get tailscaleVpnConnection => 'Tailscale VPN Connection';
|
||||
|
||||
@override
|
||||
String get unencryptedConnection => 'Unencrypted Connection';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Please enter your name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentication failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detecting authentication...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'No Authentication';
|
||||
|
||||
@override
|
||||
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||
|
||||
@override
|
||||
String get authelia => 'Authelia';
|
||||
|
||||
@override
|
||||
String get musicAssistantLogin => 'Music Assistant Login';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Press back again to minimize';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Recently Played';
|
||||
|
||||
@override
|
||||
String get discoverArtists => 'Discover Artists';
|
||||
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@override
|
||||
String get discoverAudiobooks => 'Discover Audiobooks';
|
||||
|
||||
@override
|
||||
String get discoverSeries => 'Discover Series';
|
||||
|
||||
@override
|
||||
String get favoriteAlbums => 'Favorite Albums';
|
||||
|
||||
@override
|
||||
String get favoriteArtists => 'Favorite Artists';
|
||||
|
||||
@override
|
||||
String get favoriteTracks => 'Favorite Tracks';
|
||||
|
||||
@override
|
||||
String get favoritePlaylists => 'Favorite Playlists';
|
||||
|
||||
@override
|
||||
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||
|
||||
@override
|
||||
String get favoritePodcasts => 'Favorite Podcasts';
|
||||
|
||||
@override
|
||||
String get searchMusic => 'Search music...';
|
||||
|
||||
@override
|
||||
String get searchForContent => 'Search for artists, albums, or tracks';
|
||||
|
||||
@override
|
||||
String get recentSearches => 'Recent Searches';
|
||||
|
||||
@override
|
||||
String get clearSearchHistory => 'Clear Search History';
|
||||
|
||||
@override
|
||||
String get searchHistoryCleared => 'Search history cleared';
|
||||
|
||||
@override
|
||||
String get libraryOnly => 'Library only';
|
||||
|
||||
@override
|
||||
String get retry => 'Retry';
|
||||
|
||||
@override
|
||||
String get all => 'All';
|
||||
|
||||
@override
|
||||
String get artists => 'Artists';
|
||||
|
||||
@override
|
||||
String get albums => 'Albums';
|
||||
|
||||
@override
|
||||
String get tracks => 'Tracks';
|
||||
|
||||
@override
|
||||
String get playlists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get audiobooks => 'Audiobooks';
|
||||
|
||||
@override
|
||||
String get music => 'Music';
|
||||
|
||||
@override
|
||||
String get radio => 'Radio';
|
||||
|
||||
@override
|
||||
String get stations => 'Stations';
|
||||
|
||||
@override
|
||||
String get selectLibrary => 'Select Library';
|
||||
|
||||
@override
|
||||
String get noRadioStations => 'No radio stations';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Add radio stations in Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Search failed. Please check your connection.';
|
||||
|
||||
@override
|
||||
String get queue => 'Queue';
|
||||
|
||||
@override
|
||||
String playerQueue(String playerName) {
|
||||
return '$playerName Queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayerSelected => 'No player selected';
|
||||
|
||||
@override
|
||||
String get undo => 'Undo';
|
||||
|
||||
@override
|
||||
String removedItem(String itemName) {
|
||||
return 'Removed $itemName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
|
||||
@override
|
||||
String get debugLogs => 'Debug Logs';
|
||||
|
||||
@override
|
||||
String get themeMode => 'Theme Mode';
|
||||
|
||||
@override
|
||||
String get light => 'Light';
|
||||
|
||||
@override
|
||||
String get dark => 'Dark';
|
||||
|
||||
@override
|
||||
String get system => 'System';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Pull to refresh the library to apply changes';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'View Debug Logs';
|
||||
|
||||
@override
|
||||
String get viewAllPlayers => 'View All Players';
|
||||
|
||||
@override
|
||||
String get copyLogs => 'Copy Logs';
|
||||
|
||||
@override
|
||||
String get clearLogs => 'Clear Logs';
|
||||
|
||||
@override
|
||||
String get copyList => 'Copy List';
|
||||
|
||||
@override
|
||||
String get close => 'Close';
|
||||
|
||||
@override
|
||||
String allPlayersCount(int count) {
|
||||
return 'All Players ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String errorLoadingPlayers(String error) {
|
||||
return 'Error loading players: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logsCopied => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get logsCleared => 'Logs cleared';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Player list copied to clipboard!';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'No logs yet';
|
||||
|
||||
@override
|
||||
String get infoPlus => 'Info+';
|
||||
|
||||
@override
|
||||
String get warnings => 'Warnings';
|
||||
|
||||
@override
|
||||
String get errors => 'Errors';
|
||||
|
||||
@override
|
||||
String showingEntries(int filtered, int total) {
|
||||
return 'Showing $filtered of $total entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get thisDevice => 'This device';
|
||||
|
||||
@override
|
||||
String get ghostPlayer => 'Ghost player (duplicate)';
|
||||
|
||||
@override
|
||||
String get unavailableCorrupt => 'Unavailable/Corrupt';
|
||||
|
||||
@override
|
||||
String playerId(String id) {
|
||||
return 'ID: $id';
|
||||
}
|
||||
|
||||
@override
|
||||
String playerInfo(String available, String provider) {
|
||||
return 'Available: $available | Provider: $provider';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareBugReport => 'Share bug report';
|
||||
|
||||
@override
|
||||
String get moreOptions => 'More options';
|
||||
|
||||
@override
|
||||
String failedToUpdateFavorite(String error) {
|
||||
return 'Failed to update favorite: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayersAvailable => 'No players available';
|
||||
|
||||
@override
|
||||
String get albumAddedToQueue => 'Album added to queue';
|
||||
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Tracks added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Play';
|
||||
|
||||
@override
|
||||
String get noPlayersFound => 'No players found';
|
||||
|
||||
@override
|
||||
String startingRadio(String name) {
|
||||
return 'Starting $name radio';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToStartRadio(String error) {
|
||||
return 'Failed to start radio: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String startingRadioOnPlayer(String name, String playerName) {
|
||||
return 'Starting $name radio on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String addedRadioToQueue(String name) {
|
||||
return 'Added $name radio to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToAddToQueue(String error) {
|
||||
return 'Failed to add to queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingRadioStation(String name) {
|
||||
return 'Playing $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addedToQueue => 'Added to queue';
|
||||
|
||||
@override
|
||||
String get inLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String get noAlbumsFound => 'No albums found';
|
||||
|
||||
@override
|
||||
String playing(String name) {
|
||||
return 'Playing $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get playingTrack => 'Playing track';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'Now playing';
|
||||
|
||||
@override
|
||||
String markedAsFinished(String name) {
|
||||
return '$name marked as finished';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkFinished(String error) {
|
||||
return 'Failed to mark as finished: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String markedAsUnplayed(String name) {
|
||||
return '$name marked as unplayed';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkUnplayed(String error) {
|
||||
return 'Failed to mark as unplayed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlay(String error) {
|
||||
return 'Failed to play: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get markAsFinished => 'Mark as Finished';
|
||||
|
||||
@override
|
||||
String get markAsUnplayed => 'Mark as Unplayed';
|
||||
|
||||
@override
|
||||
String byAuthor(String author) {
|
||||
return 'By $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String audiobookCount(int count) {
|
||||
return '$count audiobook(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loading => 'Loading...';
|
||||
|
||||
@override
|
||||
String bookCount(int count) {
|
||||
return '$count book(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get books => 'Books';
|
||||
|
||||
@override
|
||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No audiobooks';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No favorite artists';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No favorite albums';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No favorite playlists';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No favorite tracks';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Load Series';
|
||||
|
||||
@override
|
||||
String get notConnected => 'Not connected to Music Assistant';
|
||||
|
||||
@override
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configure Server';
|
||||
|
||||
@override
|
||||
String get noArtistsFound => 'No artists found';
|
||||
|
||||
@override
|
||||
String get noTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get noPlaylistsFound => 'No playlists found';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'Queue is empty';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'No results found';
|
||||
|
||||
@override
|
||||
String get refresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String get debugConsole => 'Debug Console';
|
||||
|
||||
@override
|
||||
String get copy => 'Copy';
|
||||
|
||||
@override
|
||||
String get clear => 'Clear';
|
||||
|
||||
@override
|
||||
String get noLogsToCopy => 'No logs to copy';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'No debug logs yet. Try detecting auth.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Show Debug';
|
||||
|
||||
@override
|
||||
String get hideDebug => 'Hide Debug';
|
||||
|
||||
@override
|
||||
String get chapters => 'Chapters';
|
||||
|
||||
@override
|
||||
String get noChapters => 'No chapters';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'This audiobook has no chapter information';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
return 'Error seeking: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String error(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get home => 'Home';
|
||||
|
||||
@override
|
||||
String get library => 'Library';
|
||||
|
||||
@override
|
||||
String get homeScreen => 'Home Screen';
|
||||
|
||||
@override
|
||||
String get metadataApis => 'Metadata APIs';
|
||||
|
||||
@override
|
||||
String get theme => 'Theme';
|
||||
|
||||
@override
|
||||
String get materialYou => 'Material You';
|
||||
|
||||
@override
|
||||
String get adaptiveTheme => 'Adaptive Theme';
|
||||
|
||||
@override
|
||||
String get language => 'Language';
|
||||
|
||||
@override
|
||||
String get english => 'English';
|
||||
|
||||
@override
|
||||
String get german => 'German';
|
||||
|
||||
@override
|
||||
String get spanish => 'Spanish';
|
||||
|
||||
@override
|
||||
String get french => 'French';
|
||||
|
||||
@override
|
||||
String get noTracksInPlaylist => 'No tracks in playlist';
|
||||
|
||||
@override
|
||||
String get sortAlphabetically => 'Sort alphabetically';
|
||||
|
||||
@override
|
||||
String get sortByYear => 'Sort by year';
|
||||
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Sort by series order';
|
||||
|
||||
@override
|
||||
String get listView => 'List view';
|
||||
|
||||
@override
|
||||
String get gridView => 'Grid view';
|
||||
|
||||
@override
|
||||
String get noBooksInSeries => 'No books found in this series';
|
||||
|
||||
@override
|
||||
String get artist => 'Artist';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Show recently played albums';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Show random artists to discover';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Show random albums to discover';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Show audiobooks in progress';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Show random audiobooks to discover';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Show random audiobook series to discover';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Show a row of your favorite albums';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Show a row of your favorite artists';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Show a row of your favorite tracks';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
|
||||
@override
|
||||
String get removedFromFavorites => 'Removed from favorites';
|
||||
|
||||
@override
|
||||
String get addedToLibrary => 'Added to library';
|
||||
|
||||
@override
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get noUpcomingTracks => 'No upcoming tracks';
|
||||
|
||||
@override
|
||||
String get showAll => 'Show all';
|
||||
|
||||
@override
|
||||
String get showFavoritesOnly => 'Show favorites only';
|
||||
|
||||
@override
|
||||
String get changeView => 'Change view';
|
||||
|
||||
@override
|
||||
String get authors => 'Authors';
|
||||
|
||||
@override
|
||||
String get series => 'Series';
|
||||
|
||||
@override
|
||||
String get shows => 'Shows';
|
||||
|
||||
@override
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Podcast support coming soon';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'No podcasts';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get episode => 'Episode';
|
||||
|
||||
@override
|
||||
String get playlist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get connectionError => 'Connection Error';
|
||||
|
||||
@override
|
||||
String get twoColumnGrid => '2-column grid';
|
||||
|
||||
@override
|
||||
String get threeColumnGrid => '3-column grid';
|
||||
|
||||
@override
|
||||
String get fromProviders => 'From Providers';
|
||||
|
||||
@override
|
||||
String get resume => 'Resume';
|
||||
|
||||
@override
|
||||
String get about => 'About';
|
||||
|
||||
@override
|
||||
String get inProgress => 'In progress';
|
||||
|
||||
@override
|
||||
String narratedBy(String narrators) {
|
||||
return 'Narrated by $narrators';
|
||||
}
|
||||
|
||||
@override
|
||||
String get unknownNarrator => 'Unknown Narrator';
|
||||
|
||||
@override
|
||||
String get unknownAuthor => 'Unknown Author';
|
||||
|
||||
@override
|
||||
String get loadingChapters => 'Loading chapters...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No chapter information available';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% complete';
|
||||
}
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKey => 'TheAudioDB API Key';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Use \"2\" for free tier or premium key';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Audiobook Libraries';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unknown Library';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No Series Found';
|
||||
|
||||
@override
|
||||
String get ensembleBugReport => 'Ensemble Bug Report';
|
||||
|
||||
@override
|
||||
String byOwner(String owner) {
|
||||
return 'By $owner';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API Key';
|
||||
|
||||
@override
|
||||
String get lastFmApiKeyHint => 'Get free key at last.fm/api';
|
||||
|
||||
@override
|
||||
String get swipeToSwitchDevice => 'Swipe to change player';
|
||||
|
||||
@override
|
||||
String chapterNumber(int number) {
|
||||
return 'Chapter $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pcmAudio => 'PCM Audio';
|
||||
|
||||
@override
|
||||
String get playOn => 'Play on...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Add album to queue on...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Add to queue on...';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Start $name radio on...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get book => 'book';
|
||||
|
||||
@override
|
||||
String get audiobookSingular => 'audiobook';
|
||||
|
||||
@override
|
||||
String get albumSingular => 'Album';
|
||||
|
||||
@override
|
||||
String get trackSingular => 'Track';
|
||||
|
||||
@override
|
||||
String get podcastSingular => 'Podcast';
|
||||
|
||||
@override
|
||||
String trackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Use system colors (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Accent Color';
|
||||
|
||||
@override
|
||||
String get players => 'Players';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayer => 'Prefer Local Player';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Smart Sort';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Unavailable';
|
||||
|
||||
@override
|
||||
String get playerStateOff => 'Off';
|
||||
|
||||
@override
|
||||
String get playerStateIdle => 'Idle';
|
||||
|
||||
@override
|
||||
String get playerStateExternalSource => 'External Source';
|
||||
|
||||
@override
|
||||
String get playerSelected => 'Selected';
|
||||
|
||||
@override
|
||||
String get actionQueuedForSync => 'Will sync when online';
|
||||
|
||||
@override
|
||||
String pendingOfflineActions(int count) {
|
||||
return '$count pending sync';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hintsAndTips => 'Hints & Tips';
|
||||
|
||||
@override
|
||||
String get showHints => 'Show Hints';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Pull to select players';
|
||||
|
||||
@override
|
||||
String get holdToSync => 'Long-press to sync';
|
||||
|
||||
@override
|
||||
String get swipeToAdjustVolume => 'Swipe to adjust volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
return 'Playing $albumName';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingPlaylist(String playlistName) {
|
||||
return 'Playing $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Spanish Castilian (`es`).
|
||||
class SEs extends S {
|
||||
SEs([String locale = 'es']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Ensemble';
|
||||
|
||||
@override
|
||||
String get connecting => 'Conectando...';
|
||||
|
||||
@override
|
||||
String get connected => 'Conectado';
|
||||
|
||||
@override
|
||||
String get disconnected => 'Desconectado';
|
||||
|
||||
@override
|
||||
String get serverAddress => 'Dirección del servidor';
|
||||
|
||||
@override
|
||||
String get serverAddressHint => 'ej., music.example.com o 192.168.1.100';
|
||||
|
||||
@override
|
||||
String get yourName => 'Tu nombre';
|
||||
|
||||
@override
|
||||
String get yourFirstName => 'Tu nombre de pila';
|
||||
|
||||
@override
|
||||
String get portOptional => 'Puerto (Opcional)';
|
||||
|
||||
@override
|
||||
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
|
||||
@override
|
||||
String get username => 'Usuario';
|
||||
|
||||
@override
|
||||
String get password => 'Contraseña';
|
||||
|
||||
@override
|
||||
String get detectAndConnect => 'Detectar y conectar';
|
||||
|
||||
@override
|
||||
String get connect => 'Conectar';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Desconectar';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
return 'Detectado: $authType';
|
||||
}
|
||||
|
||||
@override
|
||||
String get serverRequiresAuth => 'Este servidor requiere autenticación';
|
||||
|
||||
@override
|
||||
String get tailscaleVpnConnection => 'Conexión VPN Tailscale';
|
||||
|
||||
@override
|
||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detectando autenticación...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Sin autenticación';
|
||||
|
||||
@override
|
||||
String get httpBasicAuth => 'Autenticación HTTP Basic';
|
||||
|
||||
@override
|
||||
String get authelia => 'Authelia';
|
||||
|
||||
@override
|
||||
String get musicAssistantLogin => 'Inicio de sesión de Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Presiona atrás de nuevo para minimizar';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Reproducido recientemente';
|
||||
|
||||
@override
|
||||
String get discoverArtists => 'Descubrir artistas';
|
||||
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@override
|
||||
String get discoverAudiobooks => 'Descubrir audiolibros';
|
||||
|
||||
@override
|
||||
String get discoverSeries => 'Descubrir series';
|
||||
|
||||
@override
|
||||
String get favoriteAlbums => 'Álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get favoriteArtists => 'Artistas favoritos';
|
||||
|
||||
@override
|
||||
String get favoriteTracks => 'Canciones favoritas';
|
||||
|
||||
@override
|
||||
String get favoritePlaylists => 'Favorite Playlists';
|
||||
|
||||
@override
|
||||
String get favoriteRadioStations => 'Favorite Radio Stations';
|
||||
|
||||
@override
|
||||
String get favoritePodcasts => 'Favorite Podcasts';
|
||||
|
||||
@override
|
||||
String get searchMusic => 'Buscar música...';
|
||||
|
||||
@override
|
||||
String get searchForContent => 'Buscar artistas, álbumes o canciones';
|
||||
|
||||
@override
|
||||
String get recentSearches => 'Recent Searches';
|
||||
|
||||
@override
|
||||
String get clearSearchHistory => 'Clear Search History';
|
||||
|
||||
@override
|
||||
String get searchHistoryCleared => 'Search history cleared';
|
||||
|
||||
@override
|
||||
String get libraryOnly => 'Library only';
|
||||
|
||||
@override
|
||||
String get retry => 'Reintentar';
|
||||
|
||||
@override
|
||||
String get all => 'Todo';
|
||||
|
||||
@override
|
||||
String get artists => 'Artistas';
|
||||
|
||||
@override
|
||||
String get albums => 'Álbumes';
|
||||
|
||||
@override
|
||||
String get tracks => 'Canciones';
|
||||
|
||||
@override
|
||||
String get playlists => 'Listas de reproducción';
|
||||
|
||||
@override
|
||||
String get audiobooks => 'Audiolibros';
|
||||
|
||||
@override
|
||||
String get music => 'Música';
|
||||
|
||||
@override
|
||||
String get radio => 'Radio';
|
||||
|
||||
@override
|
||||
String get stations => 'Emisoras';
|
||||
|
||||
@override
|
||||
String get selectLibrary => 'Seleccionar Biblioteca';
|
||||
|
||||
@override
|
||||
String get noRadioStations => 'No hay emisoras de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
|
||||
@override
|
||||
String get queue => 'Cola';
|
||||
|
||||
@override
|
||||
String playerQueue(String playerName) {
|
||||
return 'Cola de $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayerSelected => 'Ningún reproductor seleccionado';
|
||||
|
||||
@override
|
||||
String get undo => 'Deshacer';
|
||||
|
||||
@override
|
||||
String removedItem(String itemName) {
|
||||
return 'Eliminado $itemName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settings => 'Ajustes';
|
||||
|
||||
@override
|
||||
String get debugLogs => 'Registros de depuración';
|
||||
|
||||
@override
|
||||
String get themeMode => 'Modo de tema';
|
||||
|
||||
@override
|
||||
String get light => 'Claro';
|
||||
|
||||
@override
|
||||
String get dark => 'Oscuro';
|
||||
|
||||
@override
|
||||
String get system => 'Sistema';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Ver registros de depuración';
|
||||
|
||||
@override
|
||||
String get viewAllPlayers => 'Ver todos los reproductores';
|
||||
|
||||
@override
|
||||
String get copyLogs => 'Copiar registros';
|
||||
|
||||
@override
|
||||
String get clearLogs => 'Borrar registros';
|
||||
|
||||
@override
|
||||
String get copyList => 'Copiar lista';
|
||||
|
||||
@override
|
||||
String get close => 'Cerrar';
|
||||
|
||||
@override
|
||||
String allPlayersCount(int count) {
|
||||
return 'Todos los reproductores ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String errorLoadingPlayers(String error) {
|
||||
return 'Error al cargar reproductores: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logsCopied => 'Registros copiados al portapapeles';
|
||||
|
||||
@override
|
||||
String get logsCleared => 'Registros borrados';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Aún no hay registros';
|
||||
|
||||
@override
|
||||
String get infoPlus => 'Info+';
|
||||
|
||||
@override
|
||||
String get warnings => 'Advertencias';
|
||||
|
||||
@override
|
||||
String get errors => 'Errores';
|
||||
|
||||
@override
|
||||
String showingEntries(int filtered, int total) {
|
||||
return 'Mostrando $filtered de $total entradas';
|
||||
}
|
||||
|
||||
@override
|
||||
String get thisDevice => 'Este dispositivo';
|
||||
|
||||
@override
|
||||
String get ghostPlayer => 'Reproductor fantasma (duplicado)';
|
||||
|
||||
@override
|
||||
String get unavailableCorrupt => 'No disponible/Corrupto';
|
||||
|
||||
@override
|
||||
String playerId(String id) {
|
||||
return 'ID: $id';
|
||||
}
|
||||
|
||||
@override
|
||||
String playerInfo(String available, String provider) {
|
||||
return 'Disponible: $available | Proveedor: $provider';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareBugReport => 'Compartir informe de errores';
|
||||
|
||||
@override
|
||||
String get moreOptions => 'Más opciones';
|
||||
|
||||
@override
|
||||
String failedToUpdateFavorite(String error) {
|
||||
return 'Error al actualizar favorito: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayersAvailable => 'No hay reproductores disponibles';
|
||||
|
||||
@override
|
||||
String get albumAddedToQueue => 'Álbum añadido a la cola';
|
||||
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
|
||||
|
||||
@override
|
||||
String get play => 'Reproducir';
|
||||
|
||||
@override
|
||||
String get noPlayersFound => 'No se encontraron reproductores';
|
||||
|
||||
@override
|
||||
String startingRadio(String name) {
|
||||
return 'Iniciando radio de $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToStartRadio(String error) {
|
||||
return 'Error al iniciar radio: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String startingRadioOnPlayer(String name, String playerName) {
|
||||
return 'Iniciando radio de $name en $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String addedRadioToQueue(String name) {
|
||||
return 'Radio de $name añadida a la cola';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToAddToQueue(String error) {
|
||||
return 'Error al añadir a la cola: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingRadioStation(String name) {
|
||||
return 'Playing $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addedToQueue => 'Added to queue';
|
||||
|
||||
@override
|
||||
String get inLibrary => 'En la biblioteca';
|
||||
|
||||
@override
|
||||
String get noAlbumsFound => 'No se encontraron álbumes';
|
||||
|
||||
@override
|
||||
String playing(String name) {
|
||||
return 'Reproduciendo $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get playingTrack => 'Reproduciendo canción';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'Reproduciendo ahora';
|
||||
|
||||
@override
|
||||
String markedAsFinished(String name) {
|
||||
return '$name marcado como terminado';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkFinished(String error) {
|
||||
return 'Error al marcar como terminado: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String markedAsUnplayed(String name) {
|
||||
return '$name marcado como no reproducido';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkUnplayed(String error) {
|
||||
return 'Error al marcar como no reproducido: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlay(String error) {
|
||||
return 'Error al reproducir: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get markAsFinished => 'Marcar como terminado';
|
||||
|
||||
@override
|
||||
String get markAsUnplayed => 'Marcar como no reproducido';
|
||||
|
||||
@override
|
||||
String byAuthor(String author) {
|
||||
return 'Por $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String audiobookCount(int count) {
|
||||
return '$count audiolibro(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loading => 'Cargando...';
|
||||
|
||||
@override
|
||||
String bookCount(int count) {
|
||||
return '$count libro(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get books => 'Libros';
|
||||
|
||||
@override
|
||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No hay audiolibros';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Cargar series';
|
||||
|
||||
@override
|
||||
String get notConnected => 'No conectado a Music Assistant';
|
||||
|
||||
@override
|
||||
String get notConnectedTitle => 'No conectado';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurar servidor';
|
||||
|
||||
@override
|
||||
String get noArtistsFound => 'No se encontraron artistas';
|
||||
|
||||
@override
|
||||
String get noTracksFound => 'No se encontraron canciones';
|
||||
|
||||
@override
|
||||
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La cola está vacía';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'No se encontraron resultados';
|
||||
|
||||
@override
|
||||
String get refresh => 'Actualizar';
|
||||
|
||||
@override
|
||||
String get debugConsole => 'Consola de depuración';
|
||||
|
||||
@override
|
||||
String get copy => 'Copiar';
|
||||
|
||||
@override
|
||||
String get clear => 'Borrar';
|
||||
|
||||
@override
|
||||
String get noLogsToCopy => 'No hay registros para copiar';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Mostrar depuración';
|
||||
|
||||
@override
|
||||
String get hideDebug => 'Ocultar depuración';
|
||||
|
||||
@override
|
||||
String get chapters => 'Capítulos';
|
||||
|
||||
@override
|
||||
String get noChapters => 'Sin capítulos';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
return 'Error al buscar: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String error(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get home => 'Inicio';
|
||||
|
||||
@override
|
||||
String get library => 'Biblioteca';
|
||||
|
||||
@override
|
||||
String get homeScreen => 'Pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get metadataApis => 'APIs de metadatos';
|
||||
|
||||
@override
|
||||
String get theme => 'Tema';
|
||||
|
||||
@override
|
||||
String get materialYou => 'Material You';
|
||||
|
||||
@override
|
||||
String get adaptiveTheme => 'Tema adaptativo';
|
||||
|
||||
@override
|
||||
String get language => 'Idioma';
|
||||
|
||||
@override
|
||||
String get english => 'Inglés';
|
||||
|
||||
@override
|
||||
String get german => 'Alemán';
|
||||
|
||||
@override
|
||||
String get spanish => 'Español';
|
||||
|
||||
@override
|
||||
String get french => 'Francés';
|
||||
|
||||
@override
|
||||
String get noTracksInPlaylist => 'No hay canciones en la lista';
|
||||
|
||||
@override
|
||||
String get sortAlphabetically => 'Ordenar alfabéticamente';
|
||||
|
||||
@override
|
||||
String get sortByYear => 'Ordenar por año';
|
||||
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Ordenar por orden de serie';
|
||||
|
||||
@override
|
||||
String get listView => 'Vista de lista';
|
||||
|
||||
@override
|
||||
String get gridView => 'Vista de cuadrícula';
|
||||
|
||||
@override
|
||||
String get noBooksInSeries => 'No se encontraron libros en esta serie';
|
||||
|
||||
@override
|
||||
String get artist => 'Artista';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
|
||||
@override
|
||||
String get removedFromFavorites => 'Eliminado de favoritos';
|
||||
|
||||
@override
|
||||
String get addedToLibrary => 'Added to library';
|
||||
|
||||
@override
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
|
||||
@override
|
||||
String get unknown => 'Desconocido';
|
||||
|
||||
@override
|
||||
String get noUpcomingTracks => 'No hay canciones próximas';
|
||||
|
||||
@override
|
||||
String get showAll => 'Mostrar todo';
|
||||
|
||||
@override
|
||||
String get showFavoritesOnly => 'Mostrar solo favoritos';
|
||||
|
||||
@override
|
||||
String get changeView => 'Cambiar vista';
|
||||
|
||||
@override
|
||||
String get authors => 'Autores';
|
||||
|
||||
@override
|
||||
String get series => 'Series';
|
||||
|
||||
@override
|
||||
String get shows => 'Programas';
|
||||
|
||||
@override
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Soporte de podcasts próximamente';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'No podcasts';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get episode => 'Episode';
|
||||
|
||||
@override
|
||||
String get playlist => 'Lista de reproducción';
|
||||
|
||||
@override
|
||||
String get connectionError => 'Error de conexión';
|
||||
|
||||
@override
|
||||
String get twoColumnGrid => 'Cuadrícula de 2 columnas';
|
||||
|
||||
@override
|
||||
String get threeColumnGrid => 'Cuadrícula de 3 columnas';
|
||||
|
||||
@override
|
||||
String get fromProviders => 'De proveedores';
|
||||
|
||||
@override
|
||||
String get resume => 'Continuar';
|
||||
|
||||
@override
|
||||
String get about => 'Acerca de';
|
||||
|
||||
@override
|
||||
String get inProgress => 'En progreso';
|
||||
|
||||
@override
|
||||
String narratedBy(String narrators) {
|
||||
return 'Narrado por $narrators';
|
||||
}
|
||||
|
||||
@override
|
||||
String get unknownNarrator => 'Narrador desconocido';
|
||||
|
||||
@override
|
||||
String get unknownAuthor => 'Autor desconocido';
|
||||
|
||||
@override
|
||||
String get loadingChapters => 'Cargando capítulos...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% completado';
|
||||
}
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Biblioteca desconocida';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No se encontraron series';
|
||||
|
||||
@override
|
||||
String get ensembleBugReport => 'Informe de errores de Ensemble';
|
||||
|
||||
@override
|
||||
String byOwner(String owner) {
|
||||
return 'Por $owner';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Buscar';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||
|
||||
@override
|
||||
String get lastFmApiKeyHint => 'Obtén una clave gratuita en last.fm/api';
|
||||
|
||||
@override
|
||||
String get swipeToSwitchDevice => 'Desliza para cambiar dispositivo';
|
||||
|
||||
@override
|
||||
String chapterNumber(int number) {
|
||||
return 'Capítulo $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pcmAudio => 'Audio PCM';
|
||||
|
||||
@override
|
||||
String get playOn => 'Reproducir en...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Añadir a la cola en...';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Iniciar radio de $name en...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get book => 'libro';
|
||||
|
||||
@override
|
||||
String get audiobookSingular => 'audiolibro';
|
||||
|
||||
@override
|
||||
String get albumSingular => 'Album';
|
||||
|
||||
@override
|
||||
String get trackSingular => 'Track';
|
||||
|
||||
@override
|
||||
String get podcastSingular => 'Podcast';
|
||||
|
||||
@override
|
||||
String trackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'canciones',
|
||||
one: 'canción',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Usar colores del sistema (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Color de acento';
|
||||
|
||||
@override
|
||||
String get players => 'Dispositivos';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Orden inteligente';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'No disponible';
|
||||
|
||||
@override
|
||||
String get playerStateOff => 'Apagado';
|
||||
|
||||
@override
|
||||
String get playerStateIdle => 'Inactivo';
|
||||
|
||||
@override
|
||||
String get playerStateExternalSource => 'Fuente Externa';
|
||||
|
||||
@override
|
||||
String get playerSelected => 'Seleccionado';
|
||||
|
||||
@override
|
||||
String get actionQueuedForSync => 'Se sincronizará cuando esté en línea';
|
||||
|
||||
@override
|
||||
String pendingOfflineActions(int count) {
|
||||
return '$count sincronización pendiente';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hintsAndTips => 'Consejos y Sugerencias';
|
||||
|
||||
@override
|
||||
String get showHints => 'Mostrar Sugerencias';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||
|
||||
@override
|
||||
String get holdToSync => 'Mantén presionado para sync';
|
||||
|
||||
@override
|
||||
String get swipeToAdjustVolume => 'Desliza para ajustar volumen';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
return 'Playing $albumName';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingPlaylist(String playlistName) {
|
||||
return 'Playing $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for French (`fr`).
|
||||
class SFr extends S {
|
||||
SFr([String locale = 'fr']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Ensemble';
|
||||
|
||||
@override
|
||||
String get connecting => 'Connexion...';
|
||||
|
||||
@override
|
||||
String get connected => 'Connecté';
|
||||
|
||||
@override
|
||||
String get disconnected => 'Déconnecté';
|
||||
|
||||
@override
|
||||
String get serverAddress => 'Adresse du serveur';
|
||||
|
||||
@override
|
||||
String get serverAddressHint => 'ex., music.example.com ou 192.168.1.100';
|
||||
|
||||
@override
|
||||
String get yourName => 'Votre nom';
|
||||
|
||||
@override
|
||||
String get yourFirstName => 'Votre prénom';
|
||||
|
||||
@override
|
||||
String get portOptional => 'Port (Facultatif)';
|
||||
|
||||
@override
|
||||
String get portHint => 'ex., 8095 ou laisser vide';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
|
||||
@override
|
||||
String get username => 'Nom d\'utilisateur';
|
||||
|
||||
@override
|
||||
String get password => 'Mot de passe';
|
||||
|
||||
@override
|
||||
String get detectAndConnect => 'Détecter et connecter';
|
||||
|
||||
@override
|
||||
String get connect => 'Connectér';
|
||||
|
||||
@override
|
||||
String get disconnect => 'Déconnectér';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
return 'Detected: $authType';
|
||||
}
|
||||
|
||||
@override
|
||||
String get serverRequiresAuth => 'Ce serveur requiert une authentification';
|
||||
|
||||
@override
|
||||
String get tailscaleVpnConnection => 'Connexion VPN Tailscale';
|
||||
|
||||
@override
|
||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detection de l\'authentification...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Pas d\'authentification';
|
||||
|
||||
@override
|
||||
String get httpBasicAuth => 'HTTP Basic Auth';
|
||||
|
||||
@override
|
||||
String get authelia => 'Authelia';
|
||||
|
||||
@override
|
||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Ecoutes recemment';
|
||||
|
||||
@override
|
||||
String get discoverArtists => 'Découvrir des artistes';
|
||||
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@override
|
||||
String get discoverAudiobooks => 'Découvrir des livres audio';
|
||||
|
||||
@override
|
||||
String get discoverSeries => 'Découvrir des séries';
|
||||
|
||||
@override
|
||||
String get favoriteAlbums => 'Albums favoris';
|
||||
|
||||
@override
|
||||
String get favoriteArtists => 'Artistes favoris';
|
||||
|
||||
@override
|
||||
String get favoriteTracks => 'Pistes favorites';
|
||||
|
||||
@override
|
||||
String get favoritePlaylists => 'Playlists favorites';
|
||||
|
||||
@override
|
||||
String get favoriteRadioStations => 'Stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get favoritePodcasts => 'Podcasts favoris';
|
||||
|
||||
@override
|
||||
String get searchMusic => 'Rechercher de la musique...';
|
||||
|
||||
@override
|
||||
String get searchForContent => 'Rechercher des artistes, albums ou pistes';
|
||||
|
||||
@override
|
||||
String get recentSearches => 'Recherches recentes';
|
||||
|
||||
@override
|
||||
String get clearSearchHistory => 'Effacer l\'historique de recherche';
|
||||
|
||||
@override
|
||||
String get searchHistoryCleared => 'Historique de recherche efface';
|
||||
|
||||
@override
|
||||
String get libraryOnly => 'Bibliothèque uniquement';
|
||||
|
||||
@override
|
||||
String get retry => 'Reessayer';
|
||||
|
||||
@override
|
||||
String get all => 'Tout';
|
||||
|
||||
@override
|
||||
String get artists => 'Artistes';
|
||||
|
||||
@override
|
||||
String get albums => 'Albums';
|
||||
|
||||
@override
|
||||
String get tracks => 'Pistes';
|
||||
|
||||
@override
|
||||
String get playlists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get audiobooks => 'Livres audio';
|
||||
|
||||
@override
|
||||
String get music => 'Musique';
|
||||
|
||||
@override
|
||||
String get radio => 'Radio';
|
||||
|
||||
@override
|
||||
String get stations => 'Stations';
|
||||
|
||||
@override
|
||||
String get selectLibrary => 'Sélectionner la bibliothèque';
|
||||
|
||||
@override
|
||||
String get noRadioStations => 'Aucune station de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
|
||||
@override
|
||||
String get queue => 'File d\'attente';
|
||||
|
||||
@override
|
||||
String playerQueue(String playerName) {
|
||||
return 'File d\'attente de $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayerSelected => 'Aucun lecteur selectionne';
|
||||
|
||||
@override
|
||||
String get undo => 'Annuler';
|
||||
|
||||
@override
|
||||
String removedItem(String itemName) {
|
||||
return '$itemName supprimé';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settings => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get debugLogs => 'Journaux de debogage';
|
||||
|
||||
@override
|
||||
String get themeMode => 'Mode de theme';
|
||||
|
||||
@override
|
||||
String get light => 'Clair';
|
||||
|
||||
@override
|
||||
String get dark => 'Sombre';
|
||||
|
||||
@override
|
||||
String get system => 'Système';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||
|
||||
@override
|
||||
String get viewAllPlayers => 'Voir tous les lecteurs';
|
||||
|
||||
@override
|
||||
String get copyLogs => 'Copier les journaux';
|
||||
|
||||
@override
|
||||
String get clearLogs => 'Effacer les journaux';
|
||||
|
||||
@override
|
||||
String get copyList => 'Copier la liste';
|
||||
|
||||
@override
|
||||
String get close => 'Fermer';
|
||||
|
||||
@override
|
||||
String allPlayersCount(int count) {
|
||||
return 'Tous les lecteurs ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String errorLoadingPlayers(String error) {
|
||||
return 'Erreur lors du chargement des lecteurs : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logsCopied => 'Journaux copies dans le presse-papiers';
|
||||
|
||||
@override
|
||||
String get logsCleared => 'Journaux effaces';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Pas encore de journaux';
|
||||
|
||||
@override
|
||||
String get infoPlus => 'Info+';
|
||||
|
||||
@override
|
||||
String get warnings => 'Avertissements';
|
||||
|
||||
@override
|
||||
String get errors => 'Erreurs';
|
||||
|
||||
@override
|
||||
String showingEntries(int filtered, int total) {
|
||||
return 'Affichage de $filtered sur $total entrées';
|
||||
}
|
||||
|
||||
@override
|
||||
String get thisDevice => 'Cet appareil';
|
||||
|
||||
@override
|
||||
String get ghostPlayer => 'Lecteur fantome (doublon)';
|
||||
|
||||
@override
|
||||
String get unavailableCorrupt => 'Indisponible/Corrompu';
|
||||
|
||||
@override
|
||||
String playerId(String id) {
|
||||
return 'ID : $id';
|
||||
}
|
||||
|
||||
@override
|
||||
String playerInfo(String available, String provider) {
|
||||
return 'Disponible : $available | Fournisseur : $provider';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareBugReport => 'Partager le rapport de bogue';
|
||||
|
||||
@override
|
||||
String get moreOptions => 'Plus d\'options';
|
||||
|
||||
@override
|
||||
String failedToUpdateFavorite(String error) {
|
||||
return 'Echec de la mise a jour du favori : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPlayersAvailable => 'Aucun lecteur disponible';
|
||||
|
||||
@override
|
||||
String get albumAddedToQueue => 'Album ajouté a la file d\'attente';
|
||||
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
|
||||
|
||||
@override
|
||||
String get play => 'Lire';
|
||||
|
||||
@override
|
||||
String get noPlayersFound => 'Aucun lecteur trouve';
|
||||
|
||||
@override
|
||||
String startingRadio(String name) {
|
||||
return 'Demarrage de la radio $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToStartRadio(String error) {
|
||||
return 'Echec du demarrage de la radio : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String startingRadioOnPlayer(String name, String playerName) {
|
||||
return 'Demarrage de la radio $name sur $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String addedRadioToQueue(String name) {
|
||||
return 'Radio $name ajoutée a la file d\'attente';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToAddToQueue(String error) {
|
||||
return 'Echec de l\'ajout a la file d\'attente : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingRadioStation(String name) {
|
||||
return 'Lecture de $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addedToQueue => 'Ajoute a la file d\'attente';
|
||||
|
||||
@override
|
||||
String get inLibrary => 'Dans la bibliothèque';
|
||||
|
||||
@override
|
||||
String get noAlbumsFound => 'Aucun album trouve';
|
||||
|
||||
@override
|
||||
String playing(String name) {
|
||||
return 'Lecture de $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get playingTrack => 'Lecture de la piste';
|
||||
|
||||
@override
|
||||
String get nowPlaying => 'En cours de lecture';
|
||||
|
||||
@override
|
||||
String markedAsFinished(String name) {
|
||||
return '$name marque comme termine';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkFinished(String error) {
|
||||
return 'Echec du marquage comme termine : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String markedAsUnplayed(String name) {
|
||||
return '$name marque comme non lu';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToMarkUnplayed(String error) {
|
||||
return 'Echec du marquage comme non lu : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlay(String error) {
|
||||
return 'Echec de la lecture : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get markAsFinished => 'Marquer comme termine';
|
||||
|
||||
@override
|
||||
String get markAsUnplayed => 'Marquer comme non lu';
|
||||
|
||||
@override
|
||||
String byAuthor(String author) {
|
||||
return 'Par $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String audiobookCount(int count) {
|
||||
return '$count livre(s) audio';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loading => 'Chargement...';
|
||||
|
||||
@override
|
||||
String bookCount(int count) {
|
||||
return '$count livre(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get books => 'Livres';
|
||||
|
||||
@override
|
||||
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Aucun livre audio';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Aucun album favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Charger les séries';
|
||||
|
||||
@override
|
||||
String get notConnected => 'Not connected to Music Assistant';
|
||||
|
||||
@override
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurer le serveur';
|
||||
|
||||
@override
|
||||
String get noArtistsFound => 'Aucun artiste trouve';
|
||||
|
||||
@override
|
||||
String get noTracksFound => 'Aucune piste trouvee';
|
||||
|
||||
@override
|
||||
String get noPlaylistsFound => 'Aucune playlist trouvee';
|
||||
|
||||
@override
|
||||
String get queueIsEmpty => 'La file d\'attente est vide';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'Aucun resultat trouve';
|
||||
|
||||
@override
|
||||
String get refresh => 'Rafraichir';
|
||||
|
||||
@override
|
||||
String get debugConsole => 'Console de debogage';
|
||||
|
||||
@override
|
||||
String get copy => 'Copier';
|
||||
|
||||
@override
|
||||
String get clear => 'Effacer';
|
||||
|
||||
@override
|
||||
String get noLogsToCopy => 'Aucun journal a copier';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Afficher le debogage';
|
||||
|
||||
@override
|
||||
String get hideDebug => 'Masquer le debogage';
|
||||
|
||||
@override
|
||||
String get chapters => 'Chapitres';
|
||||
|
||||
@override
|
||||
String get noChapters => 'Aucun chapitre';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
return 'Erreur de positionnement : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String error(String error) {
|
||||
return 'Erreur : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get home => 'Accueil';
|
||||
|
||||
@override
|
||||
String get library => 'Bibliothèque';
|
||||
|
||||
@override
|
||||
String get homeScreen => 'Ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get metadataApis => 'API de metadonnees';
|
||||
|
||||
@override
|
||||
String get theme => 'Theme';
|
||||
|
||||
@override
|
||||
String get materialYou => 'Material You';
|
||||
|
||||
@override
|
||||
String get adaptiveTheme => 'Theme adaptatif';
|
||||
|
||||
@override
|
||||
String get language => 'Langue';
|
||||
|
||||
@override
|
||||
String get english => 'Anglais';
|
||||
|
||||
@override
|
||||
String get german => 'Allemand';
|
||||
|
||||
@override
|
||||
String get spanish => 'Espagnol';
|
||||
|
||||
@override
|
||||
String get french => 'Français';
|
||||
|
||||
@override
|
||||
String get noTracksInPlaylist => 'Aucune piste dans la playlist';
|
||||
|
||||
@override
|
||||
String get sortAlphabetically => 'Trier alphabétiquement';
|
||||
|
||||
@override
|
||||
String get sortByYear => 'Trier par année';
|
||||
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Trier par ordre de série';
|
||||
|
||||
@override
|
||||
String get listView => 'Vue en liste';
|
||||
|
||||
@override
|
||||
String get gridView => 'Vue en grille';
|
||||
|
||||
@override
|
||||
String get noBooksInSeries => 'Aucun livre trouve dans cette série';
|
||||
|
||||
@override
|
||||
String get artist => 'Artiste';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
|
||||
@override
|
||||
String get removedFromFavorites => 'Retire des favoris';
|
||||
|
||||
@override
|
||||
String get addedToLibrary => 'Ajoute a la bibliothèque';
|
||||
|
||||
@override
|
||||
String get removedFromLibrary => 'Retire de la bibliothèque';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Ajouter a la bibliothèque';
|
||||
|
||||
@override
|
||||
String get unknown => 'Inconnu';
|
||||
|
||||
@override
|
||||
String get noUpcomingTracks => 'Aucune piste a venir';
|
||||
|
||||
@override
|
||||
String get showAll => 'Tout afficher';
|
||||
|
||||
@override
|
||||
String get showFavoritesOnly => 'Afficher les favoris uniquement';
|
||||
|
||||
@override
|
||||
String get changeView => 'Changer la vue';
|
||||
|
||||
@override
|
||||
String get authors => 'Auteurs';
|
||||
|
||||
@override
|
||||
String get series => 'Series';
|
||||
|
||||
@override
|
||||
String get shows => 'Emissions';
|
||||
|
||||
@override
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'Aucun podcast';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get episode => 'Episode';
|
||||
|
||||
@override
|
||||
String get playlist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get connectionError => 'Erreur de connexion';
|
||||
|
||||
@override
|
||||
String get twoColumnGrid => 'Grille a 2 colonnes';
|
||||
|
||||
@override
|
||||
String get threeColumnGrid => 'Grille a 3 colonnes';
|
||||
|
||||
@override
|
||||
String get fromProviders => 'Des fournisseurs';
|
||||
|
||||
@override
|
||||
String get resume => 'Reprendre';
|
||||
|
||||
@override
|
||||
String get about => 'A propos';
|
||||
|
||||
@override
|
||||
String get inProgress => 'En cours';
|
||||
|
||||
@override
|
||||
String narratedBy(String narrators) {
|
||||
return 'Lu par $narrators';
|
||||
}
|
||||
|
||||
@override
|
||||
String get unknownNarrator => 'Narrateur inconnu';
|
||||
|
||||
@override
|
||||
String get unknownAuthor => 'Auteur inconnu';
|
||||
|
||||
@override
|
||||
String get loadingChapters => 'Chargement des chapitres...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% termine';
|
||||
}
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Aucune série trouvee';
|
||||
|
||||
@override
|
||||
String get ensembleBugReport => 'Rapport de bogue Ensemble';
|
||||
|
||||
@override
|
||||
String byOwner(String owner) {
|
||||
return 'Par $owner';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Rechercher';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Cle API Last.fm';
|
||||
|
||||
@override
|
||||
String get lastFmApiKeyHint => 'Obtenez une cle gratuite sur last.fm/api';
|
||||
|
||||
@override
|
||||
String get swipeToSwitchDevice => 'Balayez pour changer de lecteur';
|
||||
|
||||
@override
|
||||
String chapterNumber(int number) {
|
||||
return 'Chapitre $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pcmAudio => 'Audio PCM';
|
||||
|
||||
@override
|
||||
String get playOn => 'Lire sur...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Demarrer la radio $name sur...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get book => 'livre';
|
||||
|
||||
@override
|
||||
String get audiobookSingular => 'livre audio';
|
||||
|
||||
@override
|
||||
String get albumSingular => 'Album';
|
||||
|
||||
@override
|
||||
String get trackSingular => 'Piste';
|
||||
|
||||
@override
|
||||
String get podcastSingular => 'Podcast';
|
||||
|
||||
@override
|
||||
String trackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count pistes',
|
||||
one: '$count piste',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Couleur d\'accentuation';
|
||||
|
||||
@override
|
||||
String get players => 'Lecteurs';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Tri intelligent';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Indisponible';
|
||||
|
||||
@override
|
||||
String get playerStateOff => 'Eteint';
|
||||
|
||||
@override
|
||||
String get playerStateIdle => 'Inactif';
|
||||
|
||||
@override
|
||||
String get playerStateExternalSource => 'Source externe';
|
||||
|
||||
@override
|
||||
String get playerSelected => 'Selectionne';
|
||||
|
||||
@override
|
||||
String get actionQueuedForSync => 'Sera synchronise une fois en ligne';
|
||||
|
||||
@override
|
||||
String pendingOfflineActions(int count) {
|
||||
return '$count en attente de synchronisation';
|
||||
}
|
||||
|
||||
@override
|
||||
String get hintsAndTips => 'Astuces et conseils';
|
||||
|
||||
@override
|
||||
String get showHints => 'Afficher les astuces';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||
|
||||
@override
|
||||
String get holdToSync => 'Appui long pour synchroniser';
|
||||
|
||||
@override
|
||||
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
|
||||
@override
|
||||
String get skip => 'Passer';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
return 'Lecture de $albumName';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingPlaylist(String playlistName) {
|
||||
return 'Lecture de $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
@@ -84,7 +89,29 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MusicAssistantApp());
|
||||
// Set up Flutter error boundary to catch and log widget build errors
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||
// Still report to Flutter's default handler in debug mode
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from the platform dispatcher (platform channel errors)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||
return true; // Handled
|
||||
};
|
||||
|
||||
// Wrap app in zone to catch async errors
|
||||
runZonedGuarded(
|
||||
() => runApp(const MusicAssistantApp()),
|
||||
(error, stackTrace) {
|
||||
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
||||
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MusicAssistantApp extends StatefulWidget {
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/cache_service.dart';
|
||||
|
||||
/// Provider for managing connection state to Music Assistant server
|
||||
/// Handles WebSocket connection, authentication, and reconnection
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
MusicAssistantAPI? _api;
|
||||
final AuthManager _authManager = AuthManager();
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService = CacheService();
|
||||
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
String? _serverUrl;
|
||||
String? _error;
|
||||
|
||||
// Callbacks for post-connection initialization
|
||||
VoidCallback? onConnected;
|
||||
VoidCallback? onAuthenticated;
|
||||
VoidCallback? onDisconnected;
|
||||
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
CacheService get cacheService => _cacheService;
|
||||
|
||||
ConnectionProvider() {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_serverUrl = await SettingsService.getServerUrl();
|
||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||
await _restoreAuthCredentials();
|
||||
await connectToServer(_serverUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore auth credentials from persistent storage
|
||||
Future<void> _restoreAuthCredentials() async {
|
||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||
if (savedCredentials != null) {
|
||||
_logger.log('🔐 Restoring saved auth credentials...');
|
||||
_authManager.deserializeCredentials(savedCredentials);
|
||||
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
||||
} else {
|
||||
_logger.log('🔐 No saved auth credentials found');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Music Assistant native authentication after WebSocket connection
|
||||
Future<bool> handleMaAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
// First, try stored MA token
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
// No valid token - try stored credentials
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch user profile from MA and set owner name + create/activate local profile
|
||||
Future<void> _fetchAndSetUserProfileName() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) {
|
||||
_logger.log('⚠️ Could not fetch user profile');
|
||||
return;
|
||||
}
|
||||
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
|
||||
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
||||
|
||||
final profileName = (displayName != null && displayName.isNotEmpty)
|
||||
? displayName
|
||||
: username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
|
||||
// Create/activate profile in local database
|
||||
if (DatabaseService.instance.isInitialized && username != null) {
|
||||
await ProfileService.instance.onMaAuthenticated(
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
);
|
||||
_logger.log('✅ Local profile activated: $username');
|
||||
}
|
||||
} else {
|
||||
_logger.log('⚠️ No valid name in profile');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToServer(String serverUrl) async {
|
||||
try {
|
||||
_error = null;
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// Disconnect existing connection
|
||||
await _api?.disconnect();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
// Listen to connection state changes
|
||||
_api!.connectionState.listen(
|
||||
(state) async {
|
||||
_connectionState = state;
|
||||
notifyListeners();
|
||||
|
||||
if (state == MAConnectionState.connected) {
|
||||
_logger.log('🔗 WebSocket connected to MA server');
|
||||
|
||||
if (_api!.authRequired && !_api!.isAuthenticated) {
|
||||
_logger.log('🔐 MA auth required, attempting authentication...');
|
||||
final authenticated = await handleMaAuthentication();
|
||||
if (!authenticated) {
|
||||
_logger.log('❌ MA authentication failed - stopping connection flow');
|
||||
_error = 'Authentication required. Please log in again.';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth required - notify connected
|
||||
onConnected?.call();
|
||||
} else if (state == MAConnectionState.authenticated) {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
onAuthenticated?.call();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
// DON'T clear caches on disconnect - keep showing cached data
|
||||
// This allows instant UI display when app resumes or reconnects
|
||||
// Caches will be refreshed when connection is restored
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('Connection state stream error: $error');
|
||||
_connectionState = MAConnectionState.error;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
await _api!.connect();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
|
||||
_error = errorInfo.userMessage;
|
||||
_connectionState = MAConnectionState.error;
|
||||
_logger.log('Connection error: ${errorInfo.technicalMessage}');
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all caches and state (for logout or server change)
|
||||
void clearCachesOnLogout() {
|
||||
_cacheService.clearAll();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check connection and reconnect if needed (called when app resumes)
|
||||
Future<void> checkAndReconnect() async {
|
||||
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||
|
||||
if (_serverUrl == null) {
|
||||
_logger.log('🔄 No server URL saved, skipping reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectionState != MAConnectionState.connected &&
|
||||
_connectionState != MAConnectionState.authenticated) {
|
||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||
try {
|
||||
await connectToServer(_serverUrl!);
|
||||
_logger.log('🔄 Reconnection successful');
|
||||
} catch (e) {
|
||||
_logger.log('🔄 Reconnection failed: $e');
|
||||
}
|
||||
} else {
|
||||
_logger.log('🔄 Already connected, connection verified');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||
class LibraryProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService;
|
||||
|
||||
MusicAssistantAPI? _api;
|
||||
|
||||
List<Artist> _artists = [];
|
||||
List<Album> _albums = [];
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
// Search state persistence
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Getters
|
||||
List<Artist> get artists => _artists;
|
||||
List<Album> get albums => _albums;
|
||||
List<Track> get tracks => _tracks;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
|
||||
LibraryProvider(this._cacheService);
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get recently played albums with caching
|
||||
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached recent albums');
|
||||
return _cacheService.getCachedRecentAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh recent albums...');
|
||||
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedRecentAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover artists (random) with caching
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover artists');
|
||||
return _cacheService.getCachedDiscoverArtists()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return artists;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover albums (random) with caching
|
||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover albums');
|
||||
return _cacheService.getCachedDiscoverAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||
void invalidateHomeCache() {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIBRARY DATA
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadLibrary() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
final results = await Future.wait([
|
||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||
]);
|
||||
|
||||
_artists = results[0] as List<Artist>;
|
||||
_albums = results[1] as List<Album>;
|
||||
_tracks = results[2] as List<Track>;
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
_artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: showOnlyArtistsWithAlbums,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
_albums = await _api!.getAlbums(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get album tracks with caching
|
||||
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached album tracks for $cacheKey');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
||||
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
||||
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch album tracks: $e');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks with caching
|
||||
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
||||
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
||||
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist albums with caching
|
||||
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
||||
final cacheKey = artistName.toLowerCase();
|
||||
|
||||
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached artist albums for "$artistName"');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||
|
||||
final libraryAlbums = await _api!.getAlbums();
|
||||
final artistAlbums = libraryAlbums.where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final searchResults = await _api!.search(artistName);
|
||||
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
||||
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final allAlbums = <Album>[];
|
||||
final seenNames = <String>{};
|
||||
|
||||
for (final album in [...artistAlbums, ...providerAlbums]) {
|
||||
final key = album.name.toLowerCase();
|
||||
if (!seenNames.contains(key)) {
|
||||
seenNames.add(key);
|
||||
allAlbums.add(album);
|
||||
}
|
||||
}
|
||||
|
||||
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||
return allAlbums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch artist albums: $e');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate album tracks cache
|
||||
void invalidateAlbumTracksCache(String albumId) {
|
||||
_cacheService.invalidateAlbumTracksCache(albumId);
|
||||
}
|
||||
|
||||
/// Invalidate playlist tracks cache
|
||||
void invalidatePlaylistTracksCache(String playlistId) {
|
||||
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH
|
||||
// ============================================================================
|
||||
|
||||
/// Search with caching
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||
final cacheKey = query.toLowerCase().trim();
|
||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query"');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query"...');
|
||||
final results = await _api!.search(query);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
try {
|
||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Search', e);
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||
if (_api == null) return [];
|
||||
|
||||
try {
|
||||
return await _api!.getAlbumTracks(provider, itemId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Get album tracks', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_artists = [];
|
||||
_albums = [];
|
||||
_tracks = [];
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
clearSearchState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return getImageUrl(track, size: size);
|
||||
}
|
||||
|
||||
/// Clear cached track for a player (e.g., after queue transfer)
|
||||
void clearPlayerTrackCache(String playerId) {
|
||||
_cacheService.clearCachedTrackForPlayer(playerId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Number of pending offline actions
|
||||
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
|
||||
|
||||
|
||||
@@ -1,964 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
/// Provider for managing player state, selection, and controls
|
||||
class PlayerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
late final LocalPlayerService _localPlayer;
|
||||
final CacheService _cacheService;
|
||||
|
||||
// Dependencies injected from parent provider
|
||||
MusicAssistantAPI? _api;
|
||||
AuthManager? _authManager;
|
||||
String? _serverUrl;
|
||||
|
||||
// Player selection
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Track? _currentTrack;
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _adjacentPlayerCacheTimer;
|
||||
|
||||
// Local Playback
|
||||
bool _isLocalPlayerPowered = true;
|
||||
StreamSubscription? _localPlayerEventSubscription;
|
||||
StreamSubscription? _playerUpdatedEventSubscription;
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
|
||||
// Pending metadata from player_updated events
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Registration guard
|
||||
Completer<void>? _registrationInProgress;
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||
|
||||
PlayerProvider(this._cacheService, AuthManager authManager) {
|
||||
_authManager = authManager;
|
||||
_localPlayer = LocalPlayerService(authManager);
|
||||
}
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api, String serverUrl) {
|
||||
_api = api;
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
/// Set up event listeners after connection
|
||||
Future<void> setupEventListeners() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Listen to built-in player events
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||
_handleLocalPlayerEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Builtin player event stream error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
// Listen to player_updated events
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||
_handlePlayerUpdatedEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Player updated event stream error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Initialize local playback
|
||||
Future<void> initializeLocalPlayback() async {
|
||||
await _localPlayer.initialize();
|
||||
_isLocalPlayerPowered = true;
|
||||
|
||||
// Wire up skip button callbacks
|
||||
audioHandler.onSkipToNext = () {
|
||||
_logger.log('🎵 Notification: Skip to next pressed');
|
||||
nextTrackSelectedPlayer();
|
||||
};
|
||||
audioHandler.onSkipToPrevious = () {
|
||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||
previousTrackSelectedPlayer();
|
||||
};
|
||||
|
||||
if (_api != null) {
|
||||
await registerLocalPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to adopt an existing ghost player
|
||||
Future<bool> tryAdoptGhostPlayer() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final isFresh = await DeviceIdService.isFreshInstallation();
|
||||
if (!isFresh) {
|
||||
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
||||
return false;
|
||||
}
|
||||
|
||||
final ownerName = await SettingsService.getOwnerName();
|
||||
if (ownerName == null || ownerName.isEmpty) {
|
||||
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
||||
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
||||
if (adoptableId == null) {
|
||||
_logger.log('👻 No matching ghost player found - will generate new ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
||||
await DeviceIdService.adoptPlayerId(adoptableId);
|
||||
|
||||
_logger.log('✅ Successfully adopted ghost player');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register local player with MA server
|
||||
Future<void> registerLocalPlayer() async {
|
||||
if (_api == null) return;
|
||||
|
||||
if (_registrationInProgress != null) {
|
||||
_logger.log('⏳ Registration already in progress, waiting...');
|
||||
return _registrationInProgress!.future;
|
||||
}
|
||||
|
||||
_registrationInProgress = Completer<void>();
|
||||
|
||||
try {
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_logger.log('🆔 Using player ID: $playerId');
|
||||
|
||||
await SettingsService.setBuiltinPlayerId(playerId);
|
||||
final name = await SettingsService.getLocalPlayerName();
|
||||
|
||||
final existingPlayers = await _api!.getPlayers();
|
||||
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||
|
||||
if (existingPlayer != null && existingPlayer.available) {
|
||||
_logger.log('✅ Player already registered and available: $playerId');
|
||||
_startReportingLocalPlayerState();
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
return;
|
||||
} else if (existingPlayer != null && !existingPlayer.available) {
|
||||
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
||||
} else {
|
||||
_logger.log('🆔 Player not found in MA, registering as new');
|
||||
}
|
||||
|
||||
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
||||
await _api!.registerBuiltinPlayer(playerId, name);
|
||||
|
||||
_logger.log('✅ Player registration complete');
|
||||
_startReportingLocalPlayerState();
|
||||
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
} catch (e) {
|
||||
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.completeError(e);
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _startReportingLocalPlayerState() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||
try {
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error reporting local player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reportLocalPlayerState() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Don't try to report state if not authenticated - avoids spamming errors
|
||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||
|
||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (playerId == null) return;
|
||||
|
||||
final isPlaying = _localPlayer.isPlaying;
|
||||
final position = _localPlayer.position.inSeconds;
|
||||
final volume = (_localPlayer.volume * 100).round();
|
||||
final isPaused = !isPlaying && position > 0;
|
||||
|
||||
await _api!.updateBuiltinPlayerState(
|
||||
playerId,
|
||||
powered: _isLocalPlayerPowered,
|
||||
playing: isPlaying,
|
||||
paused: isPaused,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: _localPlayer.volume == 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
||||
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
||||
|
||||
try {
|
||||
final eventPlayerId = event['player_id'] as String?;
|
||||
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
||||
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
||||
return;
|
||||
}
|
||||
|
||||
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
||||
|
||||
switch (command) {
|
||||
case 'play_media':
|
||||
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
||||
|
||||
if (urlPath != null && _serverUrl != null) {
|
||||
String fullUrl;
|
||||
if (urlPath.startsWith('http')) {
|
||||
fullUrl = urlPath;
|
||||
} else {
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
||||
fullUrl = '$baseUrl$path';
|
||||
}
|
||||
|
||||
TrackMetadata metadata;
|
||||
if (_pendingTrackMetadata != null) {
|
||||
metadata = _pendingTrackMetadata!;
|
||||
} else {
|
||||
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
||||
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
||||
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
||||
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
||||
final durationSecs = event['duration'] as int?;
|
||||
|
||||
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
|
||||
metadata = TrackMetadata(
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
}
|
||||
|
||||
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||
_currentNotificationMetadata = metadata;
|
||||
await _localPlayer.playUrl(fullUrl);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
await _localPlayer.stop();
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
await _localPlayer.pause();
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
await _localPlayer.play();
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
final position = event['position'] as int?;
|
||||
if (position != null) {
|
||||
await _localPlayer.seek(Duration(seconds: position));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume_set':
|
||||
final volume = event['volume_level'] as int?;
|
||||
if (volume != null) {
|
||||
await _localPlayer.setVolume(volume / 100.0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'power_on':
|
||||
case 'power_off':
|
||||
case 'power':
|
||||
bool? newPowerState;
|
||||
if (command == 'power_on') {
|
||||
newPowerState = true;
|
||||
} else if (command == 'power_off') {
|
||||
newPowerState = false;
|
||||
} else {
|
||||
newPowerState = event['powered'] as bool?;
|
||||
}
|
||||
|
||||
if (newPowerState != null) {
|
||||
_isLocalPlayerPowered = newPowerState;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error handling local player event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||
try {
|
||||
final playerId = event['player_id'] as String?;
|
||||
if (playerId == null) return;
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||
final playerName = event['name'] as String? ?? playerId;
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
final imageUrl = currentMedia['image_url'] as String?;
|
||||
|
||||
Map<String, dynamic>? metadata;
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
var finalImageUrl = imageUrl;
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
// Use original URL
|
||||
}
|
||||
metadata = {
|
||||
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
||||
};
|
||||
}
|
||||
|
||||
final trackFromEvent = Track(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
uri: currentMedia['uri'] as String?,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
}
|
||||
|
||||
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
||||
|
||||
if (imageUrl != null) {
|
||||
final images = metadata?['images'] as List?;
|
||||
if (images != null && images.isNotEmpty) {
|
||||
final imagePath = images[0]['path'] as String?;
|
||||
if (imagePath != null) {
|
||||
_precacheImage(imagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle notification metadata for local player
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
||||
|
||||
if (currentMedia == null) return;
|
||||
|
||||
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
||||
final album = currentMedia['album'] as String?;
|
||||
var imageUrl = currentMedia['image_url'] as String?;
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
imageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
||||
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final newMetadata = TrackMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
artworkUrl: imageUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType == 'flow_stream') return;
|
||||
|
||||
_pendingTrackMetadata = newMetadata;
|
||||
|
||||
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
||||
(_currentNotificationMetadata!.title != title ||
|
||||
_currentNotificationMetadata!.artist != artist);
|
||||
|
||||
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
||||
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
||||
_currentNotificationMetadata = newMetadata;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error handling player_updated event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER SELECTION
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
int filteredCount = 0;
|
||||
|
||||
_availablePlayers = allPlayers.where((player) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
|
||||
if (nameLower.contains('music assistant mobile')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
|
||||
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also filter "This Device" named players without proper provider
|
||||
if (nameLower == 'this device') {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.playerId.startsWith('ensemble_')) {
|
||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!player.available) {
|
||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||
return true;
|
||||
}
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
_cacheService.updatePlayersLastFetched();
|
||||
|
||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
|
||||
if (_selectedPlayer != null) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
if (stillAvailable) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
// Priority 1: Last selected player
|
||||
if (lastSelectedPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 2: Built-in (local) player
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == builtinPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selectPlayer(playerToSelect);
|
||||
}
|
||||
|
||||
_preloadAdjacentPlayers(preloadAll: true);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
}
|
||||
|
||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||
_selectedPlayer = player;
|
||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
_startPlayerStatePolling();
|
||||
_preloadAdjacentPlayers();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
if (players.isEmpty) return;
|
||||
|
||||
if (preloadAll) {
|
||||
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
_logger.log('🖼️ Preloading complete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedPlayer == null || players.length <= 1) return;
|
||||
|
||||
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
||||
if (currentIndex == -1) return;
|
||||
|
||||
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||
|
||||
final playersToPreload = <Player>{};
|
||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||
|
||||
for (final player in playersToPreload) {
|
||||
_preloadPlayerTrack(player);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadPlayerTrack(Player player) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!player.available || !player.powered) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(player.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final track = queue.currentItem!.track;
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = track.metadata?['images'] != null;
|
||||
|
||||
if (!existingHasImage || newHasImage) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||
}
|
||||
|
||||
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
||||
if (artworkUrl512 != null) {
|
||||
await _precacheImage(artworkUrl512);
|
||||
}
|
||||
} else {
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
if (existingTrack?.metadata?['images'] == null) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error preloading player track for ${player.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _precacheImage(String url) async {
|
||||
try {
|
||||
final imageProvider = NetworkImage(url);
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<void>();
|
||||
late ImageStreamListener listener;
|
||||
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool synchronousCall) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
if (!completer.isCompleted) completer.completeError(exception);
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
|
||||
imageStream.addListener(listener);
|
||||
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
imageStream.removeListener(listener);
|
||||
});
|
||||
} catch (e) {
|
||||
_logger.log('Image precache failed for $url: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadAllPlayerTracks() async {
|
||||
if (_api == null) return;
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
}
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||
try {
|
||||
await _updatePlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error updating player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
bool stateChanged = false;
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final updatedPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
|
||||
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||
|
||||
if (!shouldShowTrack) {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
if (stateChanged) notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
if (_currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
stateChanged = true;
|
||||
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
_localPlayer.updateNotification(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
album: track.album?.name,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: track.duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
if (_selectedPlayer!.isPlaying) {
|
||||
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
||||
} else {
|
||||
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
||||
}
|
||||
|
||||
await refreshPlayers();
|
||||
}
|
||||
|
||||
Future<void> nextTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.nextTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> previousTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.previousTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> refreshPlayers() async {
|
||||
final previousState = _selectedPlayer?.state;
|
||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||
|
||||
await loadAndSelectPlayers(forceRefresh: true);
|
||||
|
||||
bool stateChanged = false;
|
||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||
try {
|
||||
final updatedPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
|
||||
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
} catch (e) {
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> togglePower(String playerId) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
||||
|
||||
if (isLocalPlayer) {
|
||||
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
await _reportLocalPlayerState();
|
||||
await refreshPlayers();
|
||||
} else {
|
||||
final player = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||
? _selectedPlayer!
|
||||
: throw Exception("Player not found"),
|
||||
);
|
||||
|
||||
await _api!.setPower(playerId, !player.powered);
|
||||
await refreshPlayers();
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle power', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set volume', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setMute(String playerId, bool muted) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setMute(playerId, muted);
|
||||
await refreshPlayers();
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set mute', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int position) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.seek(playerId, position);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Seek', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle shuffle', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setRepeatMode(String queueId, String mode) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setRepeatMode(queueId, mode);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set repeat mode', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||
String nextMode;
|
||||
switch (currentMode) {
|
||||
case 'off':
|
||||
case null:
|
||||
nextMode = 'all';
|
||||
break;
|
||||
case 'all':
|
||||
nextMode = 'one';
|
||||
break;
|
||||
case 'one':
|
||||
nextMode = 'off';
|
||||
break;
|
||||
default:
|
||||
nextMode = 'off';
|
||||
}
|
||||
await setRepeatMode(queueId, nextMode);
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = null;
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
_currentTrack = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
clearState();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
class AlbumDetailsScreen extends StatefulWidget {
|
||||
@@ -529,12 +530,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addAlbumToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -609,12 +610,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -902,7 +903,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
@@ -922,7 +923,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
// Album Description
|
||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
@@ -944,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
@@ -1015,7 +1016,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25), // Circular
|
||||
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1193,7 +1194,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1216,7 +1217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
@@ -417,12 +418,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.startRadioOn(widget.artist.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -497,12 +498,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -786,7 +787,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
@@ -807,7 +808,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
@@ -861,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -897,7 +898,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -947,7 +948,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
@@ -968,7 +969,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1004,7 +1005,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
@@ -1024,7 +1025,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1032,7 +1033,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
_buildAlbumSliver(_providerAlbums),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -1104,7 +1105,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: Material(
|
||||
|
||||
@@ -150,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -172,37 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
Hero(
|
||||
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _authorImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Icon always present underneath
|
||||
Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
// Image covers icon when loaded
|
||||
if (_authorImageUrl != null)
|
||||
SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
memCacheWidth: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -287,8 +297,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
),
|
||||
_buildAudiobookSliver(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class AudiobookDetailScreen extends StatefulWidget {
|
||||
final Audiobook audiobook;
|
||||
@@ -566,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
|
||||
// Author
|
||||
Text(
|
||||
@@ -588,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
],
|
||||
|
||||
// Duration & Progress
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Row(
|
||||
children: [
|
||||
if (book.duration != null) ...[
|
||||
@@ -712,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -736,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -767,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -777,7 +778,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
// Use MA chapters if available, otherwise use ABS chapters
|
||||
..._buildChapterSection(book, colorScheme, textTheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'playlist_details_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||
const LibraryPlaylistsScreen({super.key});
|
||||
@@ -168,7 +169,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: playlist.favorite == true
|
||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class PlaylistDetailsScreen extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
@@ -410,12 +411,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -488,12 +489,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -758,7 +759,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
// Owner info
|
||||
if (widget.playlist.owner != null)
|
||||
Text(
|
||||
@@ -775,7 +776,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
// Action buttons row
|
||||
Row(
|
||||
children: [
|
||||
@@ -846,7 +847,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1031,7 +1032,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1054,7 +1055,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -860,8 +860,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class QueueScreen extends StatefulWidget {
|
||||
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get available players, excluding the current source player
|
||||
final allPlayers = maProvider.availablePlayers;
|
||||
final targetPlayers = allPlayers
|
||||
.where((p) => p.playerId != player.playerId && p.available)
|
||||
.toList();
|
||||
|
||||
if (targetPlayers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No other players available to transfer to'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show player picker sheet
|
||||
await showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Transfer Queue To',
|
||||
players: targetPlayers,
|
||||
onPlayerSelected: (targetPlayer) async {
|
||||
try {
|
||||
await maProvider.api?.transferQueue(
|
||||
sourceQueueId: player.playerId,
|
||||
targetQueueId: targetPlayer.playerId,
|
||||
autoPlay: true,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Queue transferred to ${targetPlayer.name}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error transferring queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to transfer queue: $e'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz_rounded),
|
||||
tooltip: 'Transfer queue',
|
||||
onPressed: () => _handleTransferQueue(maProvider),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadQueue,
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'playlist_details_screen.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import 'podcast_detail_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
@@ -486,81 +487,97 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
decoration: const BoxDecoration(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
hintText: S.of(context)!.searchMusic,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
suffixIcon: _hasSearchText
|
||||
? IconButton(
|
||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_hasSearchText = false;
|
||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
||||
_hasSearched = false;
|
||||
_searchError = null;
|
||||
_cachedListItems.clear();
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field container
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
cursorColor: colorScheme.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
hintText: S.of(context)!.searchMusic,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// PERF: Only rebuild when clear button visibility changes
|
||||
final hasText = value.isNotEmpty;
|
||||
if (hasText != _hasSearchText) {
|
||||
setState(() {
|
||||
_hasSearchText = hasText;
|
||||
});
|
||||
}
|
||||
_onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: (query) => _performSearch(query),
|
||||
suffixIcon: _hasSearchText
|
||||
? IconButton(
|
||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_hasSearchText = false;
|
||||
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
|
||||
_hasSearched = false;
|
||||
_searchError = null;
|
||||
_cachedListItems.clear();
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Library-only toggle
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
|
||||
color: _libraryOnly ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
onChanged: (value) {
|
||||
// PERF: Only rebuild when clear button visibility changes
|
||||
final hasText = value.isNotEmpty;
|
||||
if (hasText != _hasSearchText) {
|
||||
setState(() {
|
||||
_hasSearchText = hasText;
|
||||
});
|
||||
}
|
||||
_onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: (query) => _performSearch(query),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Library-only toggle with circle background
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceVariant.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -602,7 +619,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
size: 80,
|
||||
color: colorScheme.onBackground.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.searchForContent,
|
||||
style: TextStyle(
|
||||
@@ -655,7 +672,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
size: 64,
|
||||
color: colorScheme.error.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
_searchError!,
|
||||
style: TextStyle(
|
||||
@@ -664,7 +681,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
FilledButton.tonal(
|
||||
onPressed: () => _performSearch(_searchController.text),
|
||||
child: Text(S.of(context)!.retry),
|
||||
@@ -785,7 +802,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.background,
|
||||
colorScheme.surface.withOpacity(0),
|
||||
colorScheme.background.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1222,7 +1239,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1438,7 +1455,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1632,7 +1649,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1997,7 +2014,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -326,120 +326,301 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Returns the image URL if found, null otherwise
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
/// Generate name variations for fuzzy matching
|
||||
/// Returns a list of name variations to try, in priority order
|
||||
static List<String> _generateNameVariations(String authorName) {
|
||||
final variations = <String>[];
|
||||
final original = authorName.trim();
|
||||
|
||||
// Always try original first
|
||||
variations.add(original);
|
||||
|
||||
// Handle "Lastname, Firstname" format
|
||||
if (original.contains(',')) {
|
||||
final parts = original.split(',').map((p) => p.trim()).toList();
|
||||
if (parts.length == 2) {
|
||||
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
|
||||
}
|
||||
}
|
||||
|
||||
// Try Audnexus (specifically for audiobook authors, uses Audible data)
|
||||
// Normalize and expand initials for better matching
|
||||
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
|
||||
String normalized = original;
|
||||
|
||||
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
|
||||
// Match single letter followed by period and optional space, then another letter
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
|
||||
(m) => '${m.group(1)}. ${m.group(3)}',
|
||||
);
|
||||
|
||||
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
|
||||
// This catches the case where we have "R.R" as a unit
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'(\s)([A-Z])\.([A-Z])'),
|
||||
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
|
||||
);
|
||||
|
||||
// Add periods to standalone single uppercase letters (initials without periods)
|
||||
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'(\s)([A-Z])(\s)'),
|
||||
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
|
||||
);
|
||||
|
||||
if (normalized != original && !variations.contains(normalized)) {
|
||||
variations.add(normalized);
|
||||
}
|
||||
|
||||
// Remove common titles
|
||||
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
|
||||
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
|
||||
String withoutTitle = original;
|
||||
for (final title in titles) {
|
||||
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
|
||||
withoutTitle = withoutTitle.substring(title.length).trim();
|
||||
if (withoutTitle != original) {
|
||||
variations.add(withoutTitle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove suffixes
|
||||
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
|
||||
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
|
||||
String withoutSuffix = withoutTitle;
|
||||
for (final suffix in suffixes) {
|
||||
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
|
||||
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
|
||||
variations.add(withoutSuffix);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split into parts for further variations (use normalized name for better splitting)
|
||||
final nameToSplit = normalized != original ? normalized : withoutSuffix;
|
||||
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// Try first + last only (skip middle names/initials)
|
||||
final firstLast = '${parts.first} ${parts.last}';
|
||||
if (!variations.contains(firstLast)) {
|
||||
variations.add(firstLast);
|
||||
}
|
||||
|
||||
// Check if name starts with initials (single letter or letter with period)
|
||||
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
|
||||
|
||||
// If first part looks like an initial, try just the last name
|
||||
if (isInitial(parts.first)) {
|
||||
if (!variations.contains(parts.last)) {
|
||||
variations.add(parts.last);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
|
||||
// Find where initials end and actual name begins
|
||||
int initialsEnd = 0;
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (isInitial(parts[i])) {
|
||||
initialsEnd = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialsEnd > 0 && initialsEnd < parts.length) {
|
||||
// Try the non-initial part of the name
|
||||
final restOfName = parts.sublist(initialsEnd).join(' ');
|
||||
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
|
||||
variations.add(restOfName);
|
||||
}
|
||||
|
||||
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
|
||||
final initialsNoPeriods = parts.sublist(0, initialsEnd)
|
||||
.map((s) => s.replaceAll('.', ''))
|
||||
.join('');
|
||||
final compactInitials = '$initialsNoPeriods ${parts.last}';
|
||||
if (!variations.contains(compactInitials)) {
|
||||
variations.add(compactInitials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates while preserving order
|
||||
return variations.toSet().toList();
|
||||
}
|
||||
|
||||
/// Try to fetch author image from Audnexus
|
||||
/// Audnexus requires a two-step process:
|
||||
/// 1. Search by name to get the ASIN
|
||||
/// 2. Fetch full author details by ASIN to get the image
|
||||
static Future<String?> _tryAudnexus(String name) async {
|
||||
try {
|
||||
final audnexusUri = Uri.https(
|
||||
'api.audnex.us',
|
||||
'/authors',
|
||||
{'name': authorName},
|
||||
);
|
||||
// Step 1: Search for author to get ASIN
|
||||
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
if (searchData is List && searchData.isNotEmpty) {
|
||||
// Find the best match - prefer exact name matches
|
||||
String? bestAsin;
|
||||
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||
|
||||
if (audnexusResponse.statusCode == 200) {
|
||||
final data = json.decode(audnexusResponse.body);
|
||||
// Audnexus returns an array of authors
|
||||
if (data is List && data.isNotEmpty) {
|
||||
final author = data[0];
|
||||
final imageUrl = author['image'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
for (final result in searchData) {
|
||||
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||
final asin = result['asin'] as String?;
|
||||
if (asin != null) {
|
||||
if (resultName == nameLower) {
|
||||
bestAsin = asin;
|
||||
break; // Exact match found
|
||||
}
|
||||
bestAsin ??= asin; // Keep first result as fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAsin != null) {
|
||||
// Step 2: Fetch full author details by ASIN
|
||||
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
|
||||
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (detailsResponse.statusCode == 200) {
|
||||
final detailsData = json.decode(detailsResponse.body);
|
||||
final imageUrl = detailsData['image'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fall back to Open Library API
|
||||
/// Try to fetch author image from Open Library
|
||||
static Future<String?> _tryOpenLibrary(String name) async {
|
||||
try {
|
||||
final uri = Uri.https(
|
||||
'openlibrary.org',
|
||||
'/search/authors.json',
|
||||
{
|
||||
'q': authorName,
|
||||
'limit': '1',
|
||||
},
|
||||
);
|
||||
|
||||
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
|
||||
'q': name,
|
||||
'limit': '1',
|
||||
});
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final authors = data['docs'] as List?;
|
||||
if (authors != null && authors.isNotEmpty) {
|
||||
final author = authors[0];
|
||||
final authorKey = author['key'] as String?;
|
||||
final authorKey = authors[0]['key'] as String?;
|
||||
if (authorKey != null) {
|
||||
// Open Library author images are available at:
|
||||
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
|
||||
final olid = authorKey.replaceAll('/authors/', '');
|
||||
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
|
||||
|
||||
// Verify the image actually exists (Open Library returns 404 for missing images)
|
||||
// Verify image exists - Open Library returns 302 redirects for valid images
|
||||
try {
|
||||
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
if (imageCheck.statusCode == 200) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
|
||||
if (check.statusCode == 200 || check.statusCode == 301 ||
|
||||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
|
||||
return imageUrl;
|
||||
}
|
||||
} catch (_) {
|
||||
// Image doesn't exist, continue to cache null
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Try to fetch author image from Wikidata (structured Wikipedia data)
|
||||
static Future<String?> _tryWikidata(String name) async {
|
||||
try {
|
||||
// Search for entity by name
|
||||
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||
'action': 'wbsearchentities',
|
||||
'search': name,
|
||||
'language': 'en',
|
||||
'type': 'item',
|
||||
'limit': '3',
|
||||
'format': 'json',
|
||||
});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
final results = searchData['search'] as List?;
|
||||
|
||||
// Try each result to find one with an image
|
||||
for (final result in results ?? []) {
|
||||
final entityId = result['id'] as String?;
|
||||
if (entityId == null) continue;
|
||||
|
||||
// Get the image property (P18)
|
||||
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||
'action': 'wbgetclaims',
|
||||
'entity': entityId,
|
||||
'property': 'P18', // P18 is the "image" property
|
||||
'format': 'json',
|
||||
});
|
||||
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (claimsResponse.statusCode == 200) {
|
||||
final claimsData = json.decode(claimsResponse.body);
|
||||
final claims = claimsData['claims'] as Map<String, dynamic>?;
|
||||
final imageProperty = claims?['P18'] as List?;
|
||||
|
||||
if (imageProperty != null && imageProperty.isNotEmpty) {
|
||||
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
|
||||
if (imageName != null && imageName.isNotEmpty) {
|
||||
// Convert filename to Wikimedia Commons URL
|
||||
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
|
||||
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Open Library author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fall back to Wikipedia API
|
||||
/// Try to fetch author image from Wikipedia
|
||||
static Future<String?> _tryWikipedia(String name) async {
|
||||
try {
|
||||
// Search for author on Wikipedia
|
||||
final searchUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$authorName writer',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
// Search for author - "writer author" suffix helps find the right page
|
||||
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$name writer author',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
final searchResults = searchData['query']?['search'] as List?;
|
||||
if (searchResults != null && searchResults.isNotEmpty) {
|
||||
final pageTitle = searchResults[0]['title'] as String?;
|
||||
final results = searchData['query']?['search'] as List?;
|
||||
if (results != null && results.isNotEmpty) {
|
||||
final pageTitle = results[0]['title'] as String?;
|
||||
if (pageTitle != null) {
|
||||
// Get page image
|
||||
final imageUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
});
|
||||
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (imageResponse.statusCode == 200) {
|
||||
@@ -450,7 +631,6 @@ class MetadataService {
|
||||
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
||||
final imageUrl = thumbnail?['source'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
@@ -459,7 +639,100 @@ class MetadataService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources with fuzzy name matching
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Tries name variations if exact match fails
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
}
|
||||
|
||||
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
|
||||
// Try to get image for the first/primary author
|
||||
if (authorName.contains(',')) {
|
||||
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
|
||||
// Skip if this looks like "Lastname, Firstname" format or has suffixes
|
||||
// - Second part is initials (J.R.R., J.K., etc.)
|
||||
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
|
||||
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
|
||||
if (authors.length == 2) {
|
||||
final secondPart = authors[1].toLowerCase();
|
||||
// Check if second part is a suffix or looks like initials
|
||||
if (suffixes.contains(secondPart) ||
|
||||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
|
||||
// This is "Lastname, First" or "Name, Jr." - don't split
|
||||
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
|
||||
// Both parts have multiple words - likely "Author1, Author2"
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null here - fall through to try the full name
|
||||
}
|
||||
} else if (authors.length > 2) {
|
||||
// Multiple commas = definitely multiple authors
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null - fall through to try full name
|
||||
}
|
||||
}
|
||||
|
||||
// Generate name variations for fuzzy matching
|
||||
final variations = _generateNameVariations(authorName);
|
||||
|
||||
// Try each source with all variations before moving to next source
|
||||
// This prioritizes source quality over name variation
|
||||
|
||||
// 1. Try Audnexus (best for audiobook authors)
|
||||
for (final name in variations) {
|
||||
final result = await _tryAudnexus(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try Open Library
|
||||
for (final name in variations) {
|
||||
final result = await _tryOpenLibrary(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Wikidata (structured data, good for finding exact person)
|
||||
for (final name in variations.take(2)) {
|
||||
final wikidataResult = await _tryWikidata(name);
|
||||
if (wikidataResult != null) {
|
||||
_authorImageCache[cacheKey] = wikidataResult;
|
||||
return wikidataResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try Wikipedia (search-based, good fallback)
|
||||
for (final name in variations.take(3)) {
|
||||
final wikiResult = await _tryWikipedia(name);
|
||||
if (wikiResult != null) {
|
||||
_authorImageCache[cacheKey] = wikiResult;
|
||||
return wikiResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
|
||||
@@ -1082,7 +1082,7 @@ class MusicAssistantAPI {
|
||||
for (final item in providerRoot) {
|
||||
final itemMap = item as Map<String, dynamic>;
|
||||
final path = itemMap['path'] as String? ?? '';
|
||||
final name = itemMap['name'] as String? ?? '';
|
||||
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
|
||||
if (path != 'root' && name != '..') {
|
||||
discoveredLibraries.add({'path': path, 'name': name});
|
||||
}
|
||||
@@ -1101,7 +1101,7 @@ class MusicAssistantAPI {
|
||||
for (final libraryItem in providerRoot) {
|
||||
final libraryMap = libraryItem as Map<String, dynamic>;
|
||||
final libraryPath = libraryMap['path'] as String? ?? '';
|
||||
final libraryName = libraryMap['name'] as String? ?? '';
|
||||
final libraryName = (libraryMap['name'] as String?) ?? (libraryMap['label'] as String?) ?? '';
|
||||
|
||||
// Skip "root" (parent navigation) item
|
||||
if (libraryPath == 'root' || libraryName == '..') continue;
|
||||
@@ -1121,7 +1121,7 @@ class MusicAssistantAPI {
|
||||
// Log library contents to find series
|
||||
for (final item in libraryContents) {
|
||||
final itemMap = item as Map<String, dynamic>;
|
||||
final name = itemMap['name'] as String? ?? '';
|
||||
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
|
||||
final path = itemMap['path'] as String? ?? '';
|
||||
_logger.log('📚 Library item: name=$name, path=$path');
|
||||
}
|
||||
@@ -1129,10 +1129,11 @@ class MusicAssistantAPI {
|
||||
// Look for "Series" folder in the library
|
||||
for (final item in libraryContents) {
|
||||
final itemMap = item as Map<String, dynamic>;
|
||||
final name = (itemMap['name'] as String? ?? '').toLowerCase();
|
||||
final name = ((itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '').toLowerCase();
|
||||
final path = itemMap['path'] as String? ?? '';
|
||||
|
||||
if (name.contains('series') || path.contains('series')) {
|
||||
// Check for "series" in name/path, or abbreviated path ending with "/s"
|
||||
if (name.contains('series') || path.contains('series') || path.endsWith('/s')) {
|
||||
_logger.log('📚 Found series folder: $path');
|
||||
|
||||
// Browse the series folder
|
||||
@@ -1141,13 +1142,15 @@ class MusicAssistantAPI {
|
||||
|
||||
for (final seriesItem in seriesItems) {
|
||||
final seriesMap = seriesItem as Map<String, dynamic>;
|
||||
final seriesName = (seriesMap['name'] as String?) ?? (seriesMap['label'] as String?) ?? '';
|
||||
final seriesPath = seriesMap['path'] as String? ?? '';
|
||||
// Skip parent navigation
|
||||
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
|
||||
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
|
||||
// Skip actual audiobook items (we only want series folders)
|
||||
final mediaType = seriesMap['media_type'] as String?;
|
||||
if (mediaType == 'audiobook' || mediaType == 'track') continue;
|
||||
|
||||
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
|
||||
_logger.log('📚 Series: name=$seriesName, path=$seriesPath, media_type=$mediaType');
|
||||
allSeries.add(AudiobookSeries.fromJson(seriesMap));
|
||||
}
|
||||
break; // Found series in this library, move to next library
|
||||
@@ -2518,6 +2521,44 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer the current queue to another player
|
||||
/// This moves the entire playback queue from source player to target player,
|
||||
/// including shuffle and repeat settings.
|
||||
///
|
||||
/// [sourceQueueId] - The queue ID of the source player (optional, uses first playing player if null)
|
||||
/// [targetQueueId] - The queue ID of the target player (required)
|
||||
/// [autoPlay] - Whether to automatically start playback on the target player (default: true)
|
||||
Future<void> transferQueue({
|
||||
String? sourceQueueId,
|
||||
required String targetQueueId,
|
||||
bool autoPlay = true,
|
||||
}) async {
|
||||
try {
|
||||
_logger.log('🔄 Transferring queue to player: $targetQueueId (source: ${sourceQueueId ?? "auto"}, autoPlay: $autoPlay)');
|
||||
|
||||
final args = <String, dynamic>{
|
||||
'target_queue_id': targetQueueId,
|
||||
'auto_play': autoPlay,
|
||||
};
|
||||
|
||||
// Only include source_queue_id if explicitly provided
|
||||
// When null, MA will use the first playing player
|
||||
if (sourceQueueId != null) {
|
||||
args['source_queue_id'] = sourceQueueId;
|
||||
}
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/transfer',
|
||||
args: args,
|
||||
);
|
||||
|
||||
_logger.log('✅ Queue transferred successfully');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error transferring queue: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUILT-IN PLAYER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
@@ -20,7 +20,17 @@ class SecureStorageService {
|
||||
|
||||
// Auth Token (for stream requests)
|
||||
static Future<String?> getAuthToken() async {
|
||||
return await _storage.read(key: _keyAuthToken);
|
||||
try {
|
||||
return await _storage.read(key: _keyAuthToken);
|
||||
} catch (e) {
|
||||
// Handle decryption errors (e.g., after keystore/signature change)
|
||||
if (e.toString().contains('BadPaddingException') ||
|
||||
e.toString().contains('BAD_DECRYPT')) {
|
||||
await _storage.delete(key: _keyAuthToken);
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setAuthToken(String? token) async {
|
||||
@@ -33,7 +43,17 @@ class SecureStorageService {
|
||||
|
||||
// Music Assistant Native Auth Token (long-lived token)
|
||||
static Future<String?> getMaAuthToken() async {
|
||||
return await _storage.read(key: _keyMaAuthToken);
|
||||
try {
|
||||
return await _storage.read(key: _keyMaAuthToken);
|
||||
} catch (e) {
|
||||
// Handle decryption errors (e.g., after keystore/signature change)
|
||||
if (e.toString().contains('BadPaddingException') ||
|
||||
e.toString().contains('BAD_DECRYPT')) {
|
||||
await _storage.delete(key: _keyMaAuthToken);
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setMaAuthToken(String? token) async {
|
||||
@@ -50,7 +70,18 @@ class SecureStorageService {
|
||||
|
||||
// Auth Credentials (serialized auth strategy credentials)
|
||||
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||
final json = await _storage.read(key: _keyAuthCredentials);
|
||||
String? json;
|
||||
try {
|
||||
json = await _storage.read(key: _keyAuthCredentials);
|
||||
} catch (e) {
|
||||
// Handle decryption errors (e.g., after keystore/signature change)
|
||||
if (e.toString().contains('BadPaddingException') ||
|
||||
e.toString().contains('BAD_DECRYPT')) {
|
||||
await _storage.delete(key: _keyAuthCredentials);
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
@@ -69,7 +100,17 @@ class SecureStorageService {
|
||||
|
||||
// Password
|
||||
static Future<String?> getPassword() async {
|
||||
return await _storage.read(key: _keyPassword);
|
||||
try {
|
||||
return await _storage.read(key: _keyPassword);
|
||||
} catch (e) {
|
||||
// Handle decryption errors (e.g., after keystore/signature change)
|
||||
if (e.toString().contains('BadPaddingException') ||
|
||||
e.toString().contains('BAD_DECRYPT')) {
|
||||
await _storage.delete(key: _keyPassword);
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setPassword(String? password) async {
|
||||
@@ -82,7 +123,17 @@ class SecureStorageService {
|
||||
|
||||
// Audiobookshelf API Token
|
||||
static Future<String?> getAbsApiToken() async {
|
||||
return await _storage.read(key: _keyAbsApiToken);
|
||||
try {
|
||||
return await _storage.read(key: _keyAbsApiToken);
|
||||
} catch (e) {
|
||||
// Handle decryption errors (e.g., after keystore/signature change)
|
||||
if (e.toString().contains('BadPaddingException') ||
|
||||
e.toString().contains('BAD_DECRYPT')) {
|
||||
await _storage.delete(key: _keyAbsApiToken);
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setAbsApiToken(String? token) async {
|
||||
@@ -98,12 +149,21 @@ class SecureStorageService {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
// Migration completed flag key
|
||||
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
|
||||
|
||||
/// Migrate credentials from SharedPreferences to secure storage.
|
||||
/// Call this once during app upgrade to migrate existing users.
|
||||
/// Uses a flag to skip migration on subsequent runs for performance.
|
||||
static Future<void> migrateFromSharedPreferences() async {
|
||||
// Import SharedPreferences for migration
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Skip if migration already completed
|
||||
if (prefs.getBool(_keyMigrationCompleted) == true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate auth token
|
||||
final authToken = prefs.getString('auth_token');
|
||||
if (authToken != null && authToken.isNotEmpty) {
|
||||
@@ -144,5 +204,8 @@ class SecureStorageService {
|
||||
await setAbsApiToken(absApiToken);
|
||||
await prefs.remove('abs_api_token');
|
||||
}
|
||||
|
||||
// Mark migration as completed to skip on future runs
|
||||
await prefs.setBool(_keyMigrationCompleted, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,72 @@ import 'device_id_service.dart';
|
||||
import 'secure_storage_service.dart';
|
||||
|
||||
class SettingsService {
|
||||
// Cached SharedPreferences instance for performance
|
||||
static SharedPreferences? _prefsCache;
|
||||
|
||||
/// Initialize the SharedPreferences cache. Call this early in app startup.
|
||||
static Future<void> initialize() async {
|
||||
_prefsCache ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// Get SharedPreferences instance, using cache if available.
|
||||
static Future<SharedPreferences> _getPrefs() async {
|
||||
return _prefsCache ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
// ============ GENERIC HELPERS ============
|
||||
// These reduce boilerplate for repetitive getter/setter patterns
|
||||
|
||||
/// Get a string value, with optional default
|
||||
static Future<String?> _getString(String key, {String? defaultValue}) async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
/// Set a string value, removing if null/empty when removeIfEmpty is true
|
||||
static Future<void> _setString(String key, String? value, {bool removeIfEmpty = false}) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (removeIfEmpty && (value == null || value.isEmpty)) {
|
||||
await prefs.remove(key);
|
||||
} else if (value != null) {
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a bool value with default
|
||||
static Future<bool> _getBool(String key, {bool defaultValue = false}) async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
/// Set a bool value
|
||||
static Future<void> _setBool(String key, bool value) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(key, value);
|
||||
}
|
||||
|
||||
/// Get an int value, with optional default
|
||||
static Future<int?> _getInt(String key, {int? defaultValue}) async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getInt(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
/// Set an int value, removing if null
|
||||
static Future<void> _setInt(String key, int? value) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (value == null) {
|
||||
await prefs.remove(key);
|
||||
} else {
|
||||
await prefs.setInt(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a key
|
||||
static Future<void> _remove(String key) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
static const String _keyAuthServerUrl = 'auth_server_url';
|
||||
static const String _keyWebSocketPort = 'websocket_port';
|
||||
@@ -76,6 +142,16 @@ class SettingsService {
|
||||
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
|
||||
// Library Sort Order Settings
|
||||
static const String _keyLibraryArtistsSortOrder = 'library_artists_sort_order'; // 'alpha', 'alpha_desc'
|
||||
static const String _keyLibraryAlbumsSortOrder = 'library_albums_sort_order'; // 'alpha', 'alpha_desc', 'year', 'year_desc', 'artist'
|
||||
static const String _keyLibraryTracksSortOrder = 'library_tracks_sort_order'; // 'alpha', 'artist', 'album', 'duration'
|
||||
static const String _keyLibraryPlaylistsSortOrder = 'library_playlists_sort_order'; // 'alpha', 'alpha_desc', 'tracks'
|
||||
static const String _keyLibraryAuthorsSortOrder = 'library_authors_sort_order'; // 'alpha', 'alpha_desc', 'books'
|
||||
static const String _keyLibrarySeriesSortOrder = 'library_series_sort_order'; // 'alpha', 'alpha_desc', 'books'
|
||||
static const String _keyLibraryRadioSortOrder = 'library_radio_sort_order'; // 'alpha', 'alpha_desc'
|
||||
static const String _keyLibraryPodcastsSortOrder = 'library_podcasts_sort_order'; // 'alpha', 'alpha_desc'
|
||||
|
||||
// Audiobookshelf Direct Integration Settings
|
||||
static const String _keyAbsServerUrl = 'abs_server_url';
|
||||
static const String _keyAbsApiToken = 'abs_api_token';
|
||||
@@ -97,29 +173,29 @@ class SettingsService {
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
}
|
||||
|
||||
static Future<void> setServerUrl(String url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyServerUrl, url);
|
||||
}
|
||||
|
||||
static Future<void> clearServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyServerUrl);
|
||||
}
|
||||
|
||||
// Get authentication server URL (returns null if not set, meaning use server URL)
|
||||
static Future<String?> getAuthServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyAuthServerUrl);
|
||||
}
|
||||
|
||||
// Set authentication server URL (null means use same as server URL)
|
||||
static Future<void> setAuthServerUrl(String? url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
if (url == null || url.isEmpty) {
|
||||
await prefs.remove(_keyAuthServerUrl);
|
||||
} else {
|
||||
@@ -129,13 +205,13 @@ class SettingsService {
|
||||
|
||||
// Get custom WebSocket port (null means use default logic)
|
||||
static Future<int?> getWebSocketPort() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getInt(_keyWebSocketPort);
|
||||
}
|
||||
|
||||
// Set custom WebSocket port (null to use default logic)
|
||||
static Future<void> setWebSocketPort(int? port) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
if (port == null) {
|
||||
await prefs.remove(_keyWebSocketPort);
|
||||
} else {
|
||||
@@ -185,13 +261,13 @@ class SettingsService {
|
||||
|
||||
// Get username for authentication
|
||||
static Future<String?> getUsername() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyUsername);
|
||||
}
|
||||
|
||||
// Set username for authentication
|
||||
static Future<void> setUsername(String? username) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
if (username == null || username.isEmpty) {
|
||||
await prefs.remove(_keyUsername);
|
||||
} else {
|
||||
@@ -211,111 +287,43 @@ class SettingsService {
|
||||
|
||||
// Get built-in player ID (persistent UUID for this device)
|
||||
static Future<String?> getBuiltinPlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyBuiltinPlayerId);
|
||||
}
|
||||
|
||||
// Set built-in player ID
|
||||
static Future<void> setBuiltinPlayerId(String id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyBuiltinPlayerId, id);
|
||||
}
|
||||
|
||||
// Theme settings
|
||||
static Future<String?> getThemeMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyThemeMode) ?? 'system';
|
||||
}
|
||||
static Future<String?> getThemeMode() => _getString(_keyThemeMode, defaultValue: 'system');
|
||||
static Future<void> saveThemeMode(String mode) => _setString(_keyThemeMode, mode);
|
||||
|
||||
static Future<void> saveThemeMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyThemeMode, mode);
|
||||
}
|
||||
static Future<bool> getUseMaterialTheme() => _getBool(_keyUseMaterialTheme, defaultValue: false);
|
||||
static Future<void> saveUseMaterialTheme(bool enabled) => _setBool(_keyUseMaterialTheme, enabled);
|
||||
|
||||
static Future<bool> getUseMaterialTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyUseMaterialTheme) ?? false;
|
||||
}
|
||||
static Future<bool> getAdaptiveTheme() => _getBool(_keyAdaptiveTheme, defaultValue: true);
|
||||
static Future<void> saveAdaptiveTheme(bool enabled) => _setBool(_keyAdaptiveTheme, enabled);
|
||||
|
||||
static Future<void> saveUseMaterialTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyUseMaterialTheme, enabled);
|
||||
}
|
||||
|
||||
static Future<bool> getAdaptiveTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyAdaptiveTheme) ?? true; // Default to true
|
||||
}
|
||||
|
||||
static Future<void> saveAdaptiveTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyAdaptiveTheme, enabled);
|
||||
}
|
||||
|
||||
static Future<String?> getCustomColor() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyCustomColor);
|
||||
}
|
||||
|
||||
static Future<void> saveCustomColor(String color) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyCustomColor, color);
|
||||
}
|
||||
static Future<String?> getCustomColor() => _getString(_keyCustomColor);
|
||||
static Future<void> saveCustomColor(String color) => _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);
|
||||
}
|
||||
}
|
||||
static Future<String?> getLocale() => _getString(_keyLocale);
|
||||
static Future<void> saveLocale(String? locale) => _setString(_keyLocale, locale, removeIfEmpty: true);
|
||||
|
||||
// Metadata API Keys
|
||||
static Future<String?> getLastFmApiKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLastFmApiKey);
|
||||
}
|
||||
static Future<String?> getLastFmApiKey() => _getString(_keyLastFmApiKey);
|
||||
static Future<void> setLastFmApiKey(String? key) => _setString(_keyLastFmApiKey, key, removeIfEmpty: true);
|
||||
|
||||
static Future<void> setLastFmApiKey(String? key) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (key == null || key.isEmpty) {
|
||||
await prefs.remove(_keyLastFmApiKey);
|
||||
} else {
|
||||
await prefs.setString(_keyLastFmApiKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> getTheAudioDbApiKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyTheAudioDbApiKey);
|
||||
}
|
||||
|
||||
static Future<void> setTheAudioDbApiKey(String? key) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (key == null || key.isEmpty) {
|
||||
await prefs.remove(_keyTheAudioDbApiKey);
|
||||
} else {
|
||||
await prefs.setString(_keyTheAudioDbApiKey, key);
|
||||
}
|
||||
}
|
||||
static Future<String?> getTheAudioDbApiKey() => _getString(_keyTheAudioDbApiKey);
|
||||
static Future<void> setTheAudioDbApiKey(String? key) => _setString(_keyTheAudioDbApiKey, key, removeIfEmpty: true);
|
||||
|
||||
// Local Playback Settings
|
||||
static Future<bool> getEnableLocalPlayback() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyEnableLocalPlayback) ?? true;
|
||||
}
|
||||
|
||||
static Future<void> setEnableLocalPlayback(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyEnableLocalPlayback, enabled);
|
||||
}
|
||||
static Future<bool> getEnableLocalPlayback() => _getBool(_keyEnableLocalPlayback, defaultValue: true);
|
||||
static Future<void> setEnableLocalPlayback(bool enabled) => _setBool(_keyEnableLocalPlayback, enabled);
|
||||
|
||||
static Future<String> getLocalPlayerName() async {
|
||||
// Derive player name from owner name
|
||||
@@ -325,62 +333,28 @@ class SettingsService {
|
||||
}
|
||||
|
||||
// Fallback to stored local player name or default
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLocalPlayerName) ?? 'Ensemble';
|
||||
}
|
||||
|
||||
static Future<void> setLocalPlayerName(String name) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyLocalPlayerName, name);
|
||||
}
|
||||
static Future<void> setLocalPlayerName(String name) => _setString(_keyLocalPlayerName, name);
|
||||
|
||||
// Owner Name - used to derive player name
|
||||
static Future<String?> getOwnerName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyOwnerName);
|
||||
}
|
||||
|
||||
static Future<void> setOwnerName(String name) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyOwnerName, name.trim());
|
||||
}
|
||||
static Future<String?> getOwnerName() => _getString(_keyOwnerName);
|
||||
static Future<void> setOwnerName(String name) => _setString(_keyOwnerName, name.trim());
|
||||
|
||||
// Last selected player ID - persists user's player selection across sessions
|
||||
static Future<String?> getLastSelectedPlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLastSelectedPlayerId);
|
||||
}
|
||||
|
||||
static Future<void> setLastSelectedPlayerId(String? playerId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (playerId == null || playerId.isEmpty) {
|
||||
await prefs.remove(_keyLastSelectedPlayerId);
|
||||
} else {
|
||||
await prefs.setString(_keyLastSelectedPlayerId, playerId);
|
||||
}
|
||||
}
|
||||
static Future<String?> getLastSelectedPlayerId() => _getString(_keyLastSelectedPlayerId);
|
||||
static Future<void> setLastSelectedPlayerId(String? playerId) =>
|
||||
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
|
||||
|
||||
// Prefer Local Player - always select local player first when available
|
||||
static Future<bool> getPreferLocalPlayer() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setPreferLocalPlayer(bool prefer) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyPreferLocalPlayer, prefer);
|
||||
}
|
||||
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
|
||||
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
|
||||
|
||||
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
|
||||
static Future<bool> getSmartSortPlayers() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keySmartSortPlayers) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setSmartSortPlayers(bool smartSort) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keySmartSortPlayers, smartSort);
|
||||
}
|
||||
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
||||
|
||||
// Helper to create player name with possessive apostrophe
|
||||
// Automatically detects Phone vs Tablet based on screen size
|
||||
@@ -395,7 +369,7 @@ class SettingsService {
|
||||
}
|
||||
|
||||
static Future<void> clearSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.clear();
|
||||
await SecureStorageService.clearAll(); // Also clear secure storage
|
||||
}
|
||||
@@ -407,142 +381,51 @@ class SettingsService {
|
||||
}
|
||||
|
||||
// Home Screen Row Settings (Main rows - default on)
|
||||
static Future<bool> getShowRecentAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowRecentAlbums) ?? true;
|
||||
}
|
||||
static Future<bool> getShowRecentAlbums() => _getBool(_keyShowRecentAlbums, defaultValue: true);
|
||||
static Future<void> setShowRecentAlbums(bool show) => _setBool(_keyShowRecentAlbums, show);
|
||||
|
||||
static Future<void> setShowRecentAlbums(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowRecentAlbums, show);
|
||||
}
|
||||
static Future<bool> getShowDiscoverArtists() => _getBool(_keyShowDiscoverArtists, defaultValue: true);
|
||||
static Future<void> setShowDiscoverArtists(bool show) => _setBool(_keyShowDiscoverArtists, show);
|
||||
|
||||
static Future<bool> getShowDiscoverArtists() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
|
||||
}
|
||||
|
||||
static Future<void> setShowDiscoverArtists(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowDiscoverArtists, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowDiscoverAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
|
||||
}
|
||||
|
||||
static Future<void> setShowDiscoverAlbums(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowDiscoverAlbums, show);
|
||||
}
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
// Home Screen Audiobook Rows (default off - optional)
|
||||
static Future<bool> getShowContinueListeningAudiobooks() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowContinueListeningAudiobooks) ?? false;
|
||||
}
|
||||
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
}
|
||||
static Future<bool> getShowDiscoverAudiobooks() => _getBool(_keyShowDiscoverAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowDiscoverAudiobooks(bool show) => _setBool(_keyShowDiscoverAudiobooks, show);
|
||||
|
||||
static Future<bool> getShowDiscoverAudiobooks() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowDiscoverAudiobooks) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowDiscoverAudiobooks(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowDiscoverAudiobooks, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowDiscoverSeries() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowDiscoverSeries) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowDiscoverSeries(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowDiscoverSeries, show);
|
||||
}
|
||||
static Future<bool> getShowDiscoverSeries() => _getBool(_keyShowDiscoverSeries, defaultValue: false);
|
||||
static Future<void> setShowDiscoverSeries(bool show) => _setBool(_keyShowDiscoverSeries, show);
|
||||
|
||||
// Home Screen Favorites Settings (default off)
|
||||
static Future<bool> getShowFavoriteAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
|
||||
}
|
||||
static Future<bool> getShowFavoriteAlbums() => _getBool(_keyShowFavoriteAlbums, defaultValue: false);
|
||||
static Future<void> setShowFavoriteAlbums(bool show) => _setBool(_keyShowFavoriteAlbums, show);
|
||||
|
||||
static Future<void> setShowFavoriteAlbums(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteAlbums, show);
|
||||
}
|
||||
static Future<bool> getShowFavoriteArtists() => _getBool(_keyShowFavoriteArtists, defaultValue: false);
|
||||
static Future<void> setShowFavoriteArtists(bool show) => _setBool(_keyShowFavoriteArtists, show);
|
||||
|
||||
static Future<bool> getShowFavoriteArtists() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
|
||||
}
|
||||
static Future<bool> getShowFavoriteTracks() => _getBool(_keyShowFavoriteTracks, defaultValue: false);
|
||||
static Future<void> setShowFavoriteTracks(bool show) => _setBool(_keyShowFavoriteTracks, show);
|
||||
|
||||
static Future<void> setShowFavoriteArtists(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteArtists, show);
|
||||
}
|
||||
static Future<bool> getShowFavoritePlaylists() => _getBool(_keyShowFavoritePlaylists, defaultValue: false);
|
||||
static Future<void> setShowFavoritePlaylists(bool show) => _setBool(_keyShowFavoritePlaylists, show);
|
||||
|
||||
static Future<bool> getShowFavoriteTracks() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
|
||||
}
|
||||
static Future<bool> getShowFavoriteRadioStations() => _getBool(_keyShowFavoriteRadioStations, defaultValue: false);
|
||||
static Future<void> setShowFavoriteRadioStations(bool show) => _setBool(_keyShowFavoriteRadioStations, show);
|
||||
|
||||
static Future<void> setShowFavoriteTracks(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoritePlaylists() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoritePlaylists(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoritePlaylists, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoriteRadioStations() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoriteRadioStations(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteRadioStations, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoritePodcasts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoritePodcasts) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoritePodcasts(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoritePodcasts, show);
|
||||
}
|
||||
static Future<bool> getShowFavoritePodcasts() => _getBool(_keyShowFavoritePodcasts, defaultValue: false);
|
||||
static Future<void> setShowFavoritePodcasts(bool show) => _setBool(_keyShowFavoritePodcasts, show);
|
||||
|
||||
// Library Artists Filter - show only artists that have albums in library
|
||||
static Future<bool> getShowOnlyArtistsWithAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
|
||||
}
|
||||
static Future<bool> getShowOnlyArtistsWithAlbums() => _getBool(_keyShowOnlyArtistsWithAlbums, defaultValue: false);
|
||||
static Future<void> setShowOnlyArtistsWithAlbums(bool show) => _setBool(_keyShowOnlyArtistsWithAlbums, show);
|
||||
|
||||
// Home Row Order
|
||||
static Future<List<String>> getHomeRowOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyHomeRowOrder);
|
||||
if (json != null) {
|
||||
try {
|
||||
@@ -566,176 +449,247 @@ class SettingsService {
|
||||
}
|
||||
|
||||
static Future<void> setHomeRowOrder(List<String> order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyHomeRowOrder, jsonEncode(order));
|
||||
}
|
||||
|
||||
// View Mode Settings - Artist Albums
|
||||
static Future<String> getArtistAlbumsSortOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyArtistAlbumsSortOrder) ?? 'alpha';
|
||||
}
|
||||
static Future<String> getArtistAlbumsSortOrder() async => (await _getString(_keyArtistAlbumsSortOrder)) ?? 'alpha';
|
||||
static Future<void> setArtistAlbumsSortOrder(String order) => _setString(_keyArtistAlbumsSortOrder, order);
|
||||
|
||||
static Future<void> setArtistAlbumsSortOrder(String order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyArtistAlbumsSortOrder, order);
|
||||
}
|
||||
|
||||
static Future<String> getArtistAlbumsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyArtistAlbumsViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setArtistAlbumsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyArtistAlbumsViewMode, mode);
|
||||
}
|
||||
static Future<String> getArtistAlbumsViewMode() async => (await _getString(_keyArtistAlbumsViewMode)) ?? 'grid2';
|
||||
static Future<void> setArtistAlbumsViewMode(String mode) => _setString(_keyArtistAlbumsViewMode, mode);
|
||||
|
||||
// View Mode Settings - Library Artists
|
||||
static Future<String> getLibraryArtistsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLibraryArtistsViewMode) ?? 'list';
|
||||
}
|
||||
static Future<String> getLibraryArtistsViewMode() async => (await _getString(_keyLibraryArtistsViewMode)) ?? 'list';
|
||||
|
||||
static Future<void> setLibraryArtistsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryArtistsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Library Albums
|
||||
static Future<String> getLibraryAlbumsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAlbumsViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAlbumsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAlbumsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Library Playlists
|
||||
static Future<String> getLibraryPlaylistsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryPlaylistsViewMode) ?? 'list';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryPlaylistsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryPlaylistsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Author Audiobooks (author detail screen)
|
||||
static Future<String> getAuthorAudiobooksSortOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyAuthorAudiobooksSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setAuthorAudiobooksSortOrder(String order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyAuthorAudiobooksSortOrder, order);
|
||||
}
|
||||
|
||||
static Future<String> getAuthorAudiobooksViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyAuthorAudiobooksViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setAuthorAudiobooksViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyAuthorAudiobooksViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Library Authors
|
||||
static Future<String> getLibraryAuthorsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAuthorsViewMode) ?? 'list';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAuthorsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAuthorsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Library Audiobooks
|
||||
static Future<String> getLibraryAudiobooksViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAudiobooksViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAudiobooksViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAudiobooksViewMode, mode);
|
||||
}
|
||||
|
||||
static Future<String> getLibraryAudiobooksSortOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAudiobooksSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAudiobooksSortOrder(String order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAudiobooksSortOrder, order);
|
||||
}
|
||||
|
||||
static Future<String> getLibrarySeriesViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibrarySeriesViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setLibrarySeriesViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibrarySeriesViewMode, mode);
|
||||
}
|
||||
|
||||
static Future<String> getLibraryRadioViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryRadioViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryRadioViewMode, mode);
|
||||
}
|
||||
|
||||
static Future<String> getLibraryPodcastsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryPodcastsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Series Audiobooks (series detail screen)
|
||||
static Future<String> getSeriesAudiobooksSortOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keySeriesAudiobooksSortOrder) ?? 'series';
|
||||
}
|
||||
|
||||
static Future<void> setSeriesAudiobooksSortOrder(String order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keySeriesAudiobooksSortOrder, order);
|
||||
}
|
||||
|
||||
static Future<String> getSeriesAudiobooksViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keySeriesAudiobooksViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setSeriesAudiobooksViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keySeriesAudiobooksViewMode, mode);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Artists
|
||||
static Future<String> getLibraryArtistsSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryArtistsSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryArtistsSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryArtistsSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Albums
|
||||
static Future<String> getLibraryAlbumsSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAlbumsSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAlbumsSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAlbumsSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Tracks
|
||||
static Future<String> getLibraryTracksSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryTracksSortOrder) ?? 'artist';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryTracksSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryTracksSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Playlists
|
||||
static Future<String> getLibraryPlaylistsSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryPlaylistsSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryPlaylistsSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryPlaylistsSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Authors
|
||||
static Future<String> getLibraryAuthorsSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryAuthorsSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryAuthorsSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryAuthorsSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Series
|
||||
static Future<String> getLibrarySeriesSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibrarySeriesSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibrarySeriesSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibrarySeriesSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Radio
|
||||
static Future<String> getLibraryRadioSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryRadioSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryRadioSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryRadioSortOrder, order);
|
||||
}
|
||||
|
||||
// Library Sort Order Settings - Podcasts
|
||||
static Future<String> getLibraryPodcastsSortOrder() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLibraryPodcastsSortOrder) ?? 'alpha';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryPodcastsSortOrder(String order) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLibraryPodcastsSortOrder, order);
|
||||
}
|
||||
|
||||
// Audiobookshelf Settings
|
||||
static Future<String?> getAbsServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyAbsServerUrl);
|
||||
}
|
||||
|
||||
static Future<void> setAbsServerUrl(String url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyAbsServerUrl, url);
|
||||
}
|
||||
|
||||
@@ -748,17 +702,17 @@ class SettingsService {
|
||||
}
|
||||
|
||||
static Future<bool> getAbsEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyAbsEnabled) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setAbsEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyAbsEnabled, enabled);
|
||||
}
|
||||
|
||||
static Future<void> clearAbsSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyAbsServerUrl);
|
||||
await prefs.remove(_keyAbsEnabled);
|
||||
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
|
||||
@@ -768,7 +722,7 @@ class SettingsService {
|
||||
|
||||
/// Get list of enabled library paths (all enabled by default if not set)
|
||||
static Future<List<String>?> getEnabledAbsLibraries() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyEnabledAbsLibraries);
|
||||
if (json == null) return null; // null means all libraries are enabled
|
||||
try {
|
||||
@@ -781,19 +735,19 @@ class SettingsService {
|
||||
|
||||
/// Set list of enabled library paths
|
||||
static Future<void> setEnabledAbsLibraries(List<String> paths) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyEnabledAbsLibraries, jsonEncode(paths));
|
||||
}
|
||||
|
||||
/// Clear enabled libraries (reverts to all enabled)
|
||||
static Future<void> clearEnabledAbsLibraries() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyEnabledAbsLibraries);
|
||||
}
|
||||
|
||||
/// Get discovered libraries [{path: String, name: String}]
|
||||
static Future<List<Map<String, String>>?> getDiscoveredAbsLibraries() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveredAbsLibraries);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -806,7 +760,7 @@ class SettingsService {
|
||||
|
||||
/// Save discovered libraries
|
||||
static Future<void> setDiscoveredAbsLibraries(List<Map<String, String>> libraries) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyDiscoveredAbsLibraries, jsonEncode(libraries));
|
||||
}
|
||||
|
||||
@@ -839,37 +793,37 @@ class SettingsService {
|
||||
|
||||
/// Get whether hints are enabled (default: true)
|
||||
static Future<bool> getShowHints() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyShowHints) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether hints are enabled
|
||||
static Future<void> setShowHints(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyShowHints, show);
|
||||
}
|
||||
|
||||
/// Check if user has ever used the player reveal gesture
|
||||
static Future<bool> getHasUsedPlayerReveal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has used the player reveal gesture
|
||||
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
|
||||
}
|
||||
|
||||
/// Check if user has completed onboarding (seen welcome screen)
|
||||
static Future<bool> getHasCompletedOnboarding() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
|
||||
}
|
||||
|
||||
/// Mark that user has completed onboarding
|
||||
static Future<void> setHasCompletedOnboarding(bool completed) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||
}
|
||||
|
||||
@@ -879,13 +833,13 @@ class SettingsService {
|
||||
/// When enabled, holding still while adjusting volume enters precision mode
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
static Future<void> setVolumePrecisionMode(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyVolumePrecisionMode, enabled);
|
||||
}
|
||||
|
||||
@@ -894,7 +848,7 @@ class SettingsService {
|
||||
|
||||
/// Get cached podcast cover URLs (iTunes high-res)
|
||||
static Future<Map<String, String>> getPodcastCoverCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyPodcastCoverCache);
|
||||
if (json == null) return {};
|
||||
try {
|
||||
@@ -907,7 +861,7 @@ class SettingsService {
|
||||
|
||||
/// Save podcast cover cache
|
||||
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
|
||||
}
|
||||
|
||||
|
||||
@@ -124,3 +124,51 @@ class Durations {
|
||||
static const Duration normal = Duration(milliseconds: 300);
|
||||
static const Duration slow = Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
/// Button size tokens
|
||||
class ButtonSizes {
|
||||
ButtonSizes._();
|
||||
|
||||
// Touch target sizes (Material Design minimum: 48x48)
|
||||
static const double sm = 32.0;
|
||||
static const double md = 36.0;
|
||||
static const double lg = 44.0;
|
||||
static const double xl = 48.0;
|
||||
static const double xxl = 56.0;
|
||||
|
||||
// Icon sizes within buttons
|
||||
static const double iconSm = 16.0;
|
||||
static const double iconMd = 18.0;
|
||||
static const double iconLg = 20.0;
|
||||
static const double iconXl = 24.0;
|
||||
}
|
||||
|
||||
/// Semantic status colors for consistent feedback
|
||||
/// Use with Theme brightness for proper contrast
|
||||
class StatusColors {
|
||||
StatusColors._();
|
||||
|
||||
// Success states
|
||||
static const Color success = Color(0xFF4CAF50);
|
||||
static const Color successLight = Color(0xFF81C784);
|
||||
static const Color successDark = Color(0xFF388E3C);
|
||||
|
||||
// Error states
|
||||
static const Color error = Color(0xFFF44336);
|
||||
static const Color errorLight = Color(0xFFE57373);
|
||||
static const Color errorDark = Color(0xFFD32F2F);
|
||||
|
||||
// Warning states
|
||||
static const Color warning = Color(0xFFFF9800);
|
||||
static const Color warningLight = Color(0xFFFFB74D);
|
||||
static const Color warningDark = Color(0xFFF57C00);
|
||||
|
||||
// Info states
|
||||
static const Color info = Color(0xFF2196F3);
|
||||
static const Color infoLight = Color(0xFF64B5F6);
|
||||
static const Color infoDark = Color(0xFF1976D2);
|
||||
|
||||
// Favorites (heart icon)
|
||||
static const Color favorite = Color(0xFFF44336);
|
||||
static const Color favoriteActive = Color(0xFFE53935);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -36,6 +37,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
@@ -117,6 +119,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap navigation
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
@@ -128,7 +134,12 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/artist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -38,6 +39,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
@@ -110,6 +112,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap navigation
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
@@ -121,7 +127,12 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
||||
@@ -73,6 +73,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Queue state
|
||||
PlayerQueue? _queue;
|
||||
String? _queuePlayerId; // Track which player the queue belongs to
|
||||
bool _isLoadingQueue = false;
|
||||
bool _isQueueDragging = false; // True while queue item is being dragged
|
||||
bool _queuePanelTargetOpen = false; // Target state for queue panel (separate from animation value)
|
||||
@@ -380,6 +381,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
});
|
||||
}
|
||||
|
||||
/// Force collapse player to mini state, instantly closing queue panel if open.
|
||||
/// Used for queue transfer where we want to go directly to mini player.
|
||||
void forceCollapse() {
|
||||
if (_isVerticalDragging) return;
|
||||
// Instantly close queue panel without animation
|
||||
_queuePanelController.value = 0;
|
||||
_queuePanelTargetOpen = false;
|
||||
// Collapse player
|
||||
_controller.duration = _collapseDuration;
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
/// Handle vertical drag start - begin gesture-driven expansion
|
||||
void _handleVerticalDragStart(DragStartDetails details) {
|
||||
_isVerticalDragging = true;
|
||||
@@ -512,9 +525,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear queue if player changed (e.g., after queue transfer)
|
||||
if (_queuePlayerId != null && _queuePlayerId != player.playerId) {
|
||||
debugPrint('🔀 Player changed from $_queuePlayerId to ${player.playerId}, clearing queue');
|
||||
_queue = null;
|
||||
_queuePlayerId = player.playerId;
|
||||
}
|
||||
|
||||
// 1. Show cached queue immediately (if available)
|
||||
// Only load from cache if we don't already have a queue (preserves optimistic updates)
|
||||
if (_queue == null) {
|
||||
_queuePlayerId = player.playerId;
|
||||
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
||||
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
@@ -555,6 +576,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
|
||||
setState(() {
|
||||
_queue = freshQueue;
|
||||
_queuePlayerId = player.playerId;
|
||||
_isLoadingQueue = false;
|
||||
});
|
||||
}
|
||||
@@ -1229,7 +1251,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
// PERF: Use select() to only rebuild when adaptive theme setting changes
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
|
||||
// Get full themeProvider for methods that need adaptive color schemes
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
@@ -1247,7 +1272,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
: null;
|
||||
|
||||
// Extract colors for adaptive theme
|
||||
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
||||
if (adaptiveTheme && imageUrl != null) {
|
||||
_extractColors(imageUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
globalPlayerKey.currentState?.collapse();
|
||||
}
|
||||
|
||||
/// Force collapse player to mini state, instantly closing queue panel if open.
|
||||
/// Used for queue transfer where we want to go directly to mini player.
|
||||
static void forceCollapsePlayer() {
|
||||
globalPlayerKey.currentState?.forceCollapse();
|
||||
}
|
||||
|
||||
/// Check if the player is currently expanded
|
||||
static bool get isPlayerExpanded =>
|
||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||
@@ -426,15 +432,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Watch provider to trigger rebuilds when connection state changes
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
// PERF: Use select() to only rebuild when connection state or player changes
|
||||
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
|
||||
(p) => (p.isConnected, p.selectedPlayer != null),
|
||||
);
|
||||
|
||||
// Compute whether this is a first-time user ready for welcome screen.
|
||||
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||
final isFirstTimeUserReady = _settingsLoaded &&
|
||||
!_hasCompletedOnboarding &&
|
||||
provider.isConnected &&
|
||||
provider.selectedPlayer != null;
|
||||
isConnected &&
|
||||
hasSelectedPlayer;
|
||||
|
||||
// Show welcome backdrop if:
|
||||
// 1. Already in hint mode (_isHintModeActive), OR
|
||||
@@ -480,7 +488,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
||||
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||
|
||||
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||
/// through alphabetically sorted lists.
|
||||
///
|
||||
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
|
||||
/// section you're currently in via a popup indicator.
|
||||
class LetterScrollbar extends StatefulWidget {
|
||||
/// The scrollable child widget (ListView, GridView, etc.)
|
||||
final Widget child;
|
||||
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
|
||||
/// The scroll controller for the child
|
||||
final ScrollController controller;
|
||||
|
||||
/// List of items to extract letters from (must be sorted alphabetically)
|
||||
/// Each item's first character is used to determine the letter
|
||||
/// List of strings to extract letters from (should match visual sort order).
|
||||
/// Each item's first character is used to determine the letter shown in the popup.
|
||||
final List<String> items;
|
||||
|
||||
/// Callback when user taps/drags to a specific index
|
||||
/// Callback when user taps/drags to a specific index.
|
||||
final void Function(int index)? onScrollToIndex;
|
||||
|
||||
/// Callback when drag state changes (for disabling scroll-to-hide)
|
||||
final void Function(bool isDragging)? onDragStateChanged;
|
||||
|
||||
/// Fixed item height for lists. Enables precise scroll calculation.
|
||||
final double? itemExtent;
|
||||
|
||||
/// Number of columns for grid layouts.
|
||||
final int? crossAxisCount;
|
||||
|
||||
/// Aspect ratio of grid children (width / height).
|
||||
final double? childAspectRatio;
|
||||
|
||||
/// Spacing between grid rows (mainAxisSpacing)
|
||||
final double? mainAxisSpacing;
|
||||
|
||||
/// Horizontal padding of the scrollable area
|
||||
final double? horizontalPadding;
|
||||
|
||||
/// Bottom padding to prevent scrollbar from going behind bottom nav/mini player
|
||||
final double bottomPadding;
|
||||
|
||||
const LetterScrollbar({
|
||||
super.key,
|
||||
required this.child,
|
||||
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
|
||||
required this.items,
|
||||
this.onScrollToIndex,
|
||||
this.onDragStateChanged,
|
||||
this.itemExtent,
|
||||
this.crossAxisCount,
|
||||
this.childAspectRatio,
|
||||
this.mainAxisSpacing,
|
||||
this.horizontalPadding,
|
||||
this.bottomPadding = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final position = widget.controller.position;
|
||||
if (position.maxScrollExtent > 0) {
|
||||
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
|
||||
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
|
||||
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||
setState(() {
|
||||
_scrollFraction = newFraction;
|
||||
_currentLetter = _getLetterAtFraction(newFraction);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of letter -> first index for that letter
|
||||
Map<String, int> get _letterIndexMap {
|
||||
final map = <String, int>{};
|
||||
for (int i = 0; i < widget.items.length; i++) {
|
||||
final item = widget.items[i];
|
||||
if (item.isNotEmpty) {
|
||||
final letter = item[0].toUpperCase();
|
||||
if (!map.containsKey(letter)) {
|
||||
map[letter] = i;
|
||||
}
|
||||
}
|
||||
/// Normalizes a character to a letter for the scrollbar.
|
||||
String _normalizeToLetter(String char) {
|
||||
if (char.isEmpty) return '';
|
||||
final upper = char[0].toUpperCase();
|
||||
if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
|
||||
return upper;
|
||||
}
|
||||
return map;
|
||||
return '#';
|
||||
}
|
||||
|
||||
String _getLetterAtPosition(double position, double maxHeight) {
|
||||
if (widget.items.isEmpty || maxHeight <= 0) return '';
|
||||
/// Get the letter at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLetterAtFraction(double fraction) {
|
||||
if (widget.items.isEmpty) return '';
|
||||
|
||||
// Calculate which item index corresponds to this position
|
||||
final fraction = (position / maxHeight).clamp(0.0, 1.0);
|
||||
final index = (fraction * (widget.items.length - 1)).round();
|
||||
|
||||
if (index >= 0 && index < widget.items.length) {
|
||||
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
|
||||
if (index < widget.items.length) {
|
||||
final item = widget.items[index];
|
||||
if (item.isNotEmpty) {
|
||||
return item[0].toUpperCase();
|
||||
return _normalizeToLetter(item[0]);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _scrollToLetter(String letter) {
|
||||
final letterMap = _letterIndexMap;
|
||||
if (letterMap.containsKey(letter)) {
|
||||
final index = letterMap[letter]!;
|
||||
if (widget.onScrollToIndex != null) {
|
||||
widget.onScrollToIndex!(index);
|
||||
} else {
|
||||
// Estimate scroll position based on index
|
||||
// This is approximate - works better with fixed height items
|
||||
final scrollController = widget.controller;
|
||||
if (scrollController.hasClients) {
|
||||
final maxScroll = scrollController.position.maxScrollExtent;
|
||||
final fraction = index / widget.items.length;
|
||||
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
||||
|
||||
// Update scroll fraction immediately for smooth thumb tracking
|
||||
setState(() {
|
||||
_scrollFraction = fraction;
|
||||
});
|
||||
|
||||
scrollController.jumpTo(targetScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
widget.onDragStateChanged?.call(true);
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
_scrollToPosition(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
_scrollToPosition(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
widget.onDragStateChanged?.call(false);
|
||||
}
|
||||
|
||||
void _updateLetterAndScroll(double position) {
|
||||
/// Scroll proportionally based on drag position
|
||||
void _scrollToPosition(double position) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
final letter = _getLetterAtPosition(position, height);
|
||||
if (letter.isNotEmpty && letter != _currentLetter) {
|
||||
setState(() {
|
||||
_currentLetter = letter;
|
||||
});
|
||||
_scrollToLetter(letter);
|
||||
if (renderBox == null || !widget.controller.hasClients) return;
|
||||
|
||||
// Account for bottom padding in the effective track height
|
||||
final totalHeight = renderBox.size.height;
|
||||
final effectiveHeight = totalHeight - widget.bottomPadding;
|
||||
if (effectiveHeight <= 0) return;
|
||||
|
||||
// Calculate scroll fraction from position (clamped to effective area)
|
||||
final clampedPosition = position.clamp(0.0, effectiveHeight);
|
||||
final fraction = clampedPosition / effectiveHeight;
|
||||
|
||||
// Get letter for display
|
||||
final letter = _getLetterAtFraction(fraction);
|
||||
|
||||
// Scroll proportionally
|
||||
final maxScroll = widget.controller.position.maxScrollExtent;
|
||||
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
||||
|
||||
setState(() {
|
||||
_scrollFraction = fraction;
|
||||
if (letter.isNotEmpty) {
|
||||
_currentLetter = letter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
widget.controller.jumpTo(targetScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
// The scrollable content
|
||||
widget.child,
|
||||
|
||||
// The draggable scrollbar area (right edge) - wider grab area
|
||||
// The draggable scrollbar area (right edge)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 32, // Wider grab area
|
||||
bottom: widget.bottomPadding,
|
||||
width: 32,
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _handleDragStart,
|
||||
onVerticalDragUpdate: _handleDragUpdate,
|
||||
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final trackHeight = constraints.maxHeight;
|
||||
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
||||
final availableTrack = trackHeight - thumbHeight;
|
||||
final thumbTop = availableTrack * _scrollFraction;
|
||||
final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
|
||||
// Thumb that tracks scroll position
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 50),
|
||||
curve: Curves.linear,
|
||||
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
if (_isDragging && _currentLetter.isNotEmpty)
|
||||
Positioned(
|
||||
right: 48,
|
||||
top: _dragPosition - 28,
|
||||
top: (_dragPosition - 28).clamp(
|
||||
0.0,
|
||||
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
|
||||
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../services/settings_service.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';
|
||||
@@ -41,9 +40,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
Timer? _refreshTimer;
|
||||
|
||||
// Track vertical drag for dismissal
|
||||
double _dragOffset = 0;
|
||||
bool _isDragging = false;
|
||||
|
||||
// Threshold for drag-to-dismiss (in animation value, 0.0-1.0)
|
||||
static const double _dismissThreshold = 0.7;
|
||||
|
||||
// Scroll controller for when player list overflows
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isScrollable = false;
|
||||
@@ -154,13 +155,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Trigger single 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) {
|
||||
void fireBounce() {
|
||||
if (!bounceFired) {
|
||||
bounceFired = true;
|
||||
GlobalPlayerOverlay.triggerBounce();
|
||||
}
|
||||
}
|
||||
|
||||
void checkBounce() {
|
||||
if (_revealController.value < 0.3) {
|
||||
fireBounce();
|
||||
}
|
||||
}
|
||||
|
||||
// If user already dragged past bounce threshold, fire immediately
|
||||
if (_revealController.value < 0.3) {
|
||||
fireBounce();
|
||||
}
|
||||
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.addListener(checkBounce);
|
||||
_revealController.reverse().then((_) {
|
||||
@@ -201,11 +213,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Ignore swipe up when not scrollable - cards should stay fixed
|
||||
if (delta <= 0) return;
|
||||
|
||||
// Swiping down = dismiss gesture
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragOffset += delta;
|
||||
});
|
||||
// Swiping down = dismiss gesture - directly control animation controller
|
||||
_isDragging = true;
|
||||
|
||||
// Convert drag delta to animation value change
|
||||
// Use average card travel distance (~150px) as reference
|
||||
const dragSensitivity = 150.0;
|
||||
final valueDelta = delta / dragSensitivity;
|
||||
|
||||
// Decrease animation value (cards move down)
|
||||
final newValue = (_revealController.value - valueDelta).clamp(0.0, 1.0);
|
||||
_revealController.value = newValue;
|
||||
}
|
||||
|
||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||
@@ -213,28 +231,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
// If not dragging (was scrolling), don't dismiss
|
||||
if (!_isDragging) return;
|
||||
_isDragging = false;
|
||||
|
||||
// Dismiss if dragged down enough or with enough velocity
|
||||
if (_dragOffset > 100 || velocity > 500) {
|
||||
// Dismiss if dragged past threshold or with enough velocity
|
||||
if (_revealController.value < _dismissThreshold || 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;
|
||||
});
|
||||
// Spring back to fully revealed
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
@@ -322,7 +333,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../common/empty_state.dart';
|
||||
import '../global_player_overlay.dart';
|
||||
|
||||
/// Panel that displays the current playback queue with drag-to-reorder
|
||||
/// and swipe-left-to-delete functionality.
|
||||
@@ -49,10 +50,14 @@ class QueuePanel extends StatefulWidget {
|
||||
State<QueuePanel> createState() => _QueuePanelState();
|
||||
}
|
||||
|
||||
class _QueuePanelState extends State<QueuePanel> {
|
||||
class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateMixin {
|
||||
List<QueueItem> _items = [];
|
||||
final GlobalKey _stackKey = GlobalKey();
|
||||
|
||||
// Transfer dropdown animation
|
||||
late AnimationController _dropdownController;
|
||||
bool _showingTransferDropdown = false;
|
||||
|
||||
// Drag state
|
||||
int? _dragIndex;
|
||||
int? _dragStartIndex;
|
||||
@@ -90,6 +95,10 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = List.from(widget.queue?.items ?? []);
|
||||
_dropdownController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -125,6 +134,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dropdownController.dispose();
|
||||
_pendingReorderTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -181,6 +191,176 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openTransferDropdown() {
|
||||
if (_showingTransferDropdown) return;
|
||||
setState(() => _showingTransferDropdown = true);
|
||||
_dropdownController.forward();
|
||||
}
|
||||
|
||||
void _closeTransferDropdown() {
|
||||
if (!_showingTransferDropdown) return;
|
||||
_dropdownController.reverse().then((_) {
|
||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTransferQueue(BuildContext context) {
|
||||
if (_showingTransferDropdown) {
|
||||
_closeTransferDropdown();
|
||||
} else {
|
||||
_openTransferDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
List<Player> _getTargetPlayers() {
|
||||
// Use selected player (current player) as source, not the queue's player ID
|
||||
// This ensures correct filtering after queue transfer when player switches
|
||||
final sourcePlayerId = widget.maProvider.selectedPlayer?.playerId;
|
||||
if (sourcePlayerId == null) return [];
|
||||
return widget.maProvider.availablePlayers
|
||||
.where((p) => p.playerId != sourcePlayerId && p.available)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget _buildTransferDropdown() {
|
||||
final targetPlayers = _getTargetPlayers();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Use a lighter shade of the queue panel background color
|
||||
final hsl = HSLColor.fromColor(widget.backgroundColor);
|
||||
final menuBackground = hsl.withLightness((hsl.lightness + 0.04).clamp(0.0, 1.0)).toColor();
|
||||
// Use theme's onSurface for text (matches library dropdown styling)
|
||||
final menuTextColor = colorScheme.onSurface;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 280, maxWidth: 220),
|
||||
decoration: BoxDecoration(
|
||||
color: menuBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title header (matches library dropdown section headers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Transfer to...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: menuTextColor.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Player list
|
||||
if (targetPlayers.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Text(
|
||||
'No other players available',
|
||||
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
itemCount: targetPlayers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = targetPlayers[index];
|
||||
|
||||
// Status color
|
||||
Color statusColor;
|
||||
if (!player.powered) {
|
||||
statusColor = Colors.grey;
|
||||
} else if (player.state == 'playing') {
|
||||
statusColor = Colors.green;
|
||||
} else {
|
||||
statusColor = Colors.orange;
|
||||
}
|
||||
|
||||
// Match library dropdown item styling
|
||||
return InkWell(
|
||||
onTap: () => _transferToPlayer(player),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.speaker_rounded, color: menuTextColor, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
player.name,
|
||||
style: TextStyle(color: menuTextColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _transferToPlayer(Player targetPlayer) async {
|
||||
final sourcePlayerId = widget.queue?.playerId;
|
||||
if (sourcePlayerId == null) return;
|
||||
|
||||
// Close dropdown first
|
||||
_closeTransferDropdown();
|
||||
|
||||
try {
|
||||
await widget.maProvider.api?.transferQueue(
|
||||
sourceQueueId: sourcePlayerId,
|
||||
targetQueueId: targetPlayer.playerId,
|
||||
autoPlay: true,
|
||||
);
|
||||
|
||||
debugPrint('QueuePanel: Queue transferred to ${targetPlayer.name}');
|
||||
|
||||
// Clear source player's track cache since its queue was transferred away
|
||||
widget.maProvider.clearPlayerTrackCache(sourcePlayerId);
|
||||
|
||||
// Force collapse to mini player (closes queue instantly + collapses)
|
||||
GlobalPlayerOverlay.forceCollapsePlayer();
|
||||
|
||||
// Switch to the target player - mini player will update to show new player
|
||||
widget.maProvider.selectPlayer(targetPlayer);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error transferring queue: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _resetSwipeState() {
|
||||
_swipeStart = null;
|
||||
_swipeLast = null;
|
||||
@@ -506,41 +686,55 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
},
|
||||
child: Container(
|
||||
color: widget.backgroundColor,
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
onPressed: widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
// Main content column
|
||||
Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: widget.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Transfer queue button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showingTransferDropdown ? Icons.close : Icons.swap_horiz_rounded,
|
||||
color: _showingTransferDropdown ? widget.primaryColor : widget.textColor.withOpacity(0.7),
|
||||
size: IconSizes.sm,
|
||||
),
|
||||
onPressed: () => _handleTransferQueue(context),
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Transfer queue to another player',
|
||||
),
|
||||
// Clear queue button
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: _handleClearQueue,
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Clear queue',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: widget.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Clear queue button
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: _handleClearQueue,
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Clear queue',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Queue content
|
||||
Expanded(
|
||||
// Queue content
|
||||
Expanded(
|
||||
child: widget.isLoading
|
||||
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
|
||||
: widget.queue == null || _items.isEmpty
|
||||
@@ -585,7 +779,49 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Floating dropdown overlay for transfer
|
||||
if (_showingTransferDropdown) ...[
|
||||
// Tap-to-dismiss backdrop
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _closeTransferDropdown,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// Dropdown menu with open/close animation
|
||||
Positioned(
|
||||
top: widget.topPadding + 52, // Below header
|
||||
right: 56, // Aligned with transfer button
|
||||
child: AnimatedBuilder(
|
||||
animation: CurvedAnimation(
|
||||
parent: _dropdownController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final value = _dropdownController.value;
|
||||
final curvedValue = Curves.easeInOut.transform(value);
|
||||
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
widthFactor: 1.0,
|
||||
heightFactor: curvedValue,
|
||||
child: Opacity(
|
||||
opacity: curvedValue,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildTransferDropdown(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -775,3 +1011,4 @@ class _VelocitySample {
|
||||
|
||||
_VelocitySample(this.position, this.timeMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ Future<void> showPlayerPickerSheet({
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true, // Required for overlays like QueuePanel
|
||||
builder: (context) => PlayerPickerSheet(
|
||||
title: title,
|
||||
players: players,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -5,10 +6,11 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/playlist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
class PlaylistCard extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
@@ -22,31 +24,47 @@ class PlaylistCard extends StatelessWidget {
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistCard> createState() => _PlaylistCardState();
|
||||
}
|
||||
|
||||
class _PlaylistCardState extends State<PlaylistCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap navigation
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
playlist: widget.playlist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -55,7 +73,7 @@ class PlaylistCard extends StatelessWidget {
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
@@ -91,11 +109,11 @@ class PlaylistCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
@@ -107,7 +125,7 @@ class PlaylistCard extends StatelessWidget {
|
||||
),
|
||||
// Owner/track count
|
||||
Text(
|
||||
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
|
||||
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -6,10 +7,11 @@ import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/podcast_detail_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PodcastCard extends StatelessWidget {
|
||||
class PodcastCard extends StatefulWidget {
|
||||
final MediaItem podcast;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
@@ -25,34 +27,50 @@ class PodcastCard extends StatelessWidget {
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PodcastCard> createState() => _PodcastCardState();
|
||||
}
|
||||
|
||||
class _PodcastCardState extends State<PodcastCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
// Use provider's getPodcastImageUrl which includes iTunes cache
|
||||
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
|
||||
final imageUrl = maProvider.getPodcastImageUrl(widget.podcast, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
// PERF: Use appropriate cache size based on display size
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap navigation
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PodcastDetailScreen(
|
||||
podcast: podcast,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
podcast: widget.podcast,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -61,7 +79,7 @@ class PodcastCard extends StatelessWidget {
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix,
|
||||
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
@@ -97,11 +115,11 @@ class PodcastCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
// Podcast title
|
||||
Hero(
|
||||
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix,
|
||||
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
podcast.name,
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
@@ -113,7 +131,7 @@ class PodcastCard extends StatelessWidget {
|
||||
),
|
||||
// Podcast author (if available)
|
||||
Text(
|
||||
_getAuthor(podcast),
|
||||
_getAuthor(widget.podcast),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -5,8 +6,9 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
class RadioStationCard extends StatelessWidget {
|
||||
class RadioStationCard extends StatefulWidget {
|
||||
final MediaItem radioStation;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
@@ -20,25 +22,41 @@ class RadioStationCard extends StatelessWidget {
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RadioStationCard> createState() => _RadioStationCardState();
|
||||
}
|
||||
|
||||
class _RadioStationCardState extends State<RadioStationCard> {
|
||||
bool _isTapping = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
|
||||
final imageUrl = maProvider.api?.getImageUrl(widget.radioStation, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
onTap: widget.onTap ?? () {
|
||||
// Prevent double-tap actions
|
||||
if (_isTapping) return;
|
||||
_isTapping = true;
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
// Play the radio station on selected player
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
if (selectedPlayer != null) {
|
||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
|
||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, widget.radioStation);
|
||||
}
|
||||
|
||||
// Reset after debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isTapping = false;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -47,7 +65,7 @@ class RadioStationCard extends StatelessWidget {
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
tag: HeroTags.radioCover + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
@@ -84,11 +102,11 @@ class RadioStationCard extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: 36, // Fixed height for 2 lines of text
|
||||
child: Hero(
|
||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
tag: HeroTags.radioTitle + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
radioStation.name,
|
||||
widget.radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.7-beta+45
|
||||
version: 2.8.8-beta+46
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||