Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f35d1a26 | ||
|
|
6918569a82 | ||
|
|
8cf35cfe78 | ||
|
|
42bc1ed16c | ||
|
|
d908cf93a2 | ||
|
|
e21e1eb4fd | ||
|
|
9aa81ef09f | ||
|
|
8d2adb6fc5 | ||
|
|
99ee131712 | ||
|
|
f51ea9cb22 | ||
|
|
9773f28cf7 | ||
|
|
2856ca1e8b | ||
|
|
c832502c49 | ||
|
|
675779c108 | ||
|
|
54a9e6e2b4 | ||
|
|
870acabad1 | ||
|
|
d3a5542c68 | ||
|
|
b93918219f | ||
|
|
a23f9ff05f | ||
|
|
1921a20884 | ||
|
|
516faf6f6a | ||
|
|
d7b7bbcf0d | ||
|
|
3503978185 | ||
|
|
020ed250f4 | ||
|
|
e5be64a6cd | ||
|
|
b85fadc551 | ||
|
|
3b8339f94f | ||
|
|
c00fb3dc78 | ||
|
|
ff34479169 | ||
|
|
d7127ea7da | ||
|
|
29b4887d0d | ||
|
|
18603c34e1 | ||
|
|
5ed2bf7725 | ||
|
|
67a99c88a0 | ||
|
|
ffbb41580b | ||
|
|
6942b56cfb | ||
|
|
14ba524ed0 | ||
|
|
49fc2a77e7 | ||
|
|
6632fa14c6 | ||
|
|
a0cac2174f | ||
|
|
056d1fb854 | ||
|
|
a4cc7115bf | ||
|
|
1f3188fa6c | ||
|
|
9527ba341e | ||
|
|
79e9e02f53 | ||
|
|
d14a4b99c6 | ||
|
|
7e72b08f44 | ||
|
|
801952482f | ||
|
|
bae1154bcf |
@@ -31,8 +31,8 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.24.0'
|
||||
channel: 'stable'
|
||||
flutter-version: '3.38.0'
|
||||
channel: 'master'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
@@ -63,10 +63,7 @@ jobs:
|
||||
|
||||
- name: Setup signing keystore
|
||||
run: |
|
||||
# Decode keystore from secret (persistent key for consistent signatures)
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
@@ -75,12 +72,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,10 +83,5 @@ jobs:
|
||||
|
||||
- name: Upload APK summary
|
||||
run: |
|
||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
|
||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -36,7 +36,8 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
### Queue Management
|
||||
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe left to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe right to play next** - Move tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
@@ -50,7 +51,9 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||
- **Audiobooks** - Browse by title, series, or author with progress tracking
|
||||
- **Favorites Filter** - Toggle to show only your favorite items
|
||||
- **Providers Filter** - Toggle to show only specific provider items
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
- **Provider Icons** - Provider icons on library covers
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
@@ -86,16 +89,18 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
|
||||
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Release Notes Draft
|
||||
|
||||
## v2.9.2-beta (TBD)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Audiobooks
|
||||
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
|
||||
|
||||
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
|
||||
|
||||
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
|
||||
|
||||
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
|
||||
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
|
||||
- Sort handler now properly supports ascending and descending year sorts
|
||||
|
||||
#### Library Sorting
|
||||
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
|
||||
|
||||
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
|
||||
|
||||
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
|
||||
|
||||
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
|
||||
|
||||
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
|
||||
|
||||
#### Provider Management
|
||||
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
|
||||
|
||||
### Technical Changes
|
||||
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
|
||||
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
|
||||
- Provider filter now validates against available providers when toggling, not just on initial load
|
||||
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
|
||||
- Options menu provider toggles now use Consumer to watch Provider state directly
|
||||
@@ -59,7 +59,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
ext.kotlin_version = '2.1.0'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath 'com.android.tools.build:gradle:8.7.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ 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
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1,336 @@
|
||||
# Per-Provider Sync Implementation
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Core Issue
|
||||
|
||||
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
|
||||
|
||||
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
|
||||
|
||||
### Previous Approach (Failed)
|
||||
|
||||
The initial implementation tried to filter client-side using `provider_mappings`:
|
||||
|
||||
```dart
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
|
||||
|
||||
### Race Condition Issue
|
||||
|
||||
Additionally, when rapidly toggling providers, a race condition occurred:
|
||||
1. User toggles Provider A off
|
||||
2. Sync starts for remaining providers
|
||||
3. User toggles Provider B off before sync completes
|
||||
4. First sync completes with wrong filter state
|
||||
5. UI shows incorrect results
|
||||
|
||||
## Solution: Per-Provider Sync with Source Tracking
|
||||
|
||||
### Concept
|
||||
|
||||
Instead of relying on `provider_mappings`, we:
|
||||
1. **Sync each provider separately** using the MA API's `provider` parameter
|
||||
2. **Tag each item** with which provider instance(s) returned it
|
||||
3. **Store source tracking** in the local database
|
||||
4. **Filter client-side** using the source tracking data (instant)
|
||||
5. **Background sync** keeps data fresh without blocking UI
|
||||
|
||||
### Why This Works
|
||||
|
||||
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Update
|
||||
|
||||
**File:** `lib/database/database.dart`
|
||||
|
||||
Added `sourceProviders` column to `LibraryCache` table:
|
||||
|
||||
```dart
|
||||
class LibraryCache extends Table {
|
||||
// ... existing columns ...
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
}
|
||||
```
|
||||
|
||||
**Schema version:** 5 → 6
|
||||
|
||||
**Migration:**
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await customStatement(
|
||||
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Service Updates
|
||||
|
||||
**File:** `lib/services/database_service.dart`
|
||||
|
||||
Added `sourceProvider` parameter to `cacheItem()`:
|
||||
|
||||
```dart
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider, // NEW: tracks which provider this item came from
|
||||
})
|
||||
```
|
||||
|
||||
Added new method to retrieve items with source tracking:
|
||||
|
||||
```dart
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
|
||||
```
|
||||
|
||||
Returns tuples of `(itemData, sourceProviderIds)`.
|
||||
|
||||
### 3. SyncService Rewrite
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```dart
|
||||
// Source provider tracking for client-side filtering
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
```
|
||||
|
||||
#### Per-Provider Sync Logic
|
||||
|
||||
```dart
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
// If specific providers are requested, sync each separately
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
for (final providerId in providerInstanceIds) {
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
// ... etc
|
||||
]);
|
||||
|
||||
// Track source provider for each item
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
_albumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Filtering Methods
|
||||
|
||||
```dart
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return true; // No tracking = show
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Similar methods for artists, audiobooks, playlists
|
||||
```
|
||||
|
||||
### 4. Library Screen Updates
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
#### Hybrid Provider Toggle Handler
|
||||
|
||||
```dart
|
||||
void _handleProviderToggle(String providerId, bool enabled) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Instant UI update via global provider toggle
|
||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||
setState(() {}); // Rebuild with client-side filtering
|
||||
|
||||
// Background sync after 1.5s debounce
|
||||
_providerFilterDebounce?.cancel();
|
||||
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
|
||||
if (mounted) {
|
||||
await maProvider.forceLibrarySync();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated Tab Builders
|
||||
|
||||
Artists tab now uses source tracking:
|
||||
|
||||
```dart
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
|
||||
selector: (_, provider) => (
|
||||
provider.artists,
|
||||
provider.isLoading,
|
||||
provider.enabledProviderIds.toSet()
|
||||
),
|
||||
builder: (context, data, _) {
|
||||
final (allArtists, isLoading, enabledProviders) = data;
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
final syncService = SyncService.instance;
|
||||
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getArtistsFilteredByProviders(enabledProviders)
|
||||
: allArtists;
|
||||
|
||||
// ... rest of build
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Similar updates for albums, playlists, and audiobooks tabs.
|
||||
|
||||
### 5. Provider Instance Model
|
||||
|
||||
**File:** `lib/models/provider_instance.dart` (new file)
|
||||
|
||||
```dart
|
||||
class ProviderInstance {
|
||||
final String instanceId; // e.g., "spotify--abc123"
|
||||
final String domain; // e.g., "spotify"
|
||||
final String name; // e.g., "Spotify (John's Account)"
|
||||
final bool available;
|
||||
|
||||
/// Known music provider domains
|
||||
static const Set<String> musicProviderDomains = {
|
||||
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
|
||||
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
|
||||
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
|
||||
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
|
||||
};
|
||||
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
|
||||
│ - Updates enabledProviderIds │
|
||||
│ - Triggers setState() for instant UI update │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. UI Rebuilds Immediately │
|
||||
│ - Selector detects enabledProviderIds change │
|
||||
│ - Calls SyncService.getArtistsFilteredByProviders() │
|
||||
│ - Filters using source tracking (instant, no network) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Background Sync (after 1.5s debounce) │
|
||||
│ - forceLibrarySync() called │
|
||||
│ - Syncs each enabled provider separately │
|
||||
│ - Updates source tracking in database │
|
||||
│ - UI refreshes with fresh data │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
|
||||
| `lib/database/database.g.dart` | Auto-generated from schema changes |
|
||||
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
|
||||
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
|
||||
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
|
||||
| `lib/models/provider_instance.dart` | New model for provider instances |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Fresh install: First sync should populate source tracking
|
||||
- [ ] Toggle single provider: UI updates instantly
|
||||
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
|
||||
- [ ] Two Spotify accounts: Toggling one only shows that account's items
|
||||
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
|
||||
- [ ] Legacy cache: Items without source tracking show regardless of filter
|
||||
- [ ] Pull to refresh: Refreshes source tracking data
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
|
||||
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
|
||||
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Incremental sync:** Only sync changed items instead of full library
|
||||
2. **Provider-specific cache age:** Different sync intervals per provider
|
||||
3. **Offline source tracking:** Persist source tracking independently of full cache
|
||||
|
||||
## Branch
|
||||
|
||||
All changes are on the `feature/per-provider-sync` branch.
|
||||
|
||||
```bash
|
||||
git checkout feature/per-provider-sync
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat: Add per-provider sync with source tracking for instant filtering
|
||||
|
||||
- Add sourceProviders column to database schema (v6) for tracking which
|
||||
provider instance each item came from
|
||||
- Update SyncService to sync each enabled provider separately and tag
|
||||
items with their source provider
|
||||
- Add client-side filtering methods that use source tracking to properly
|
||||
differentiate between multiple accounts of the same provider type
|
||||
- Update library UI to use instant client-side filtering with background
|
||||
sync for data freshness
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Provider Filter Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
|
||||
|
||||
## The Problem
|
||||
|
||||
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
|
||||
|
||||
## What Works vs What Failed
|
||||
|
||||
### Works: Different Provider Types
|
||||
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
|
||||
|
||||
### Failed: Multiple Accounts of Same Provider
|
||||
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The provider_mappings Problem
|
||||
|
||||
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
|
||||
|
||||
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
|
||||
```json
|
||||
{
|
||||
"provider_mappings": [
|
||||
{"provider_instance": "spotify--userA_id", "item_id": "..."},
|
||||
{"provider_instance": "spotify--userB_id", "item_id": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. MA's internal database stores that the artist exists
|
||||
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
|
||||
3. MA doesn't track WHICH account originally added the item to the library
|
||||
|
||||
### MA Web UI Behavior
|
||||
|
||||
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
|
||||
|
||||
## API Investigation
|
||||
|
||||
### Server-Side Filtering Parameter
|
||||
|
||||
The MA server's `library_items` method accepts a `provider` parameter:
|
||||
- Parameter name: `provider` (string or list of strings)
|
||||
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
|
||||
- Expected behavior: Filter to only return items from the specified provider's library
|
||||
|
||||
### What We Tried
|
||||
|
||||
1. **Client-side filtering based on provider_mappings**
|
||||
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
|
||||
|
||||
2. **Server-side filtering with `provider` parameter**
|
||||
- Result: Partially implemented but behavior was inconsistent
|
||||
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
|
||||
|
||||
3. **Using `provider_instance_id_or_domain` parameter**
|
||||
- Result: This parameter is for getting specific items by ID, not for filtering library_items
|
||||
|
||||
## Code Locations
|
||||
|
||||
### Files Modified (in stash)
|
||||
|
||||
The implementation attempt is preserved in git stash:
|
||||
```bash
|
||||
git stash list # Look for "provider-filter-and-dropdown-work"
|
||||
git stash show -p stash@{0} # View the changes
|
||||
```
|
||||
|
||||
Key files that were modified:
|
||||
|
||||
1. **lib/providers/music_assistant_provider.dart**
|
||||
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
|
||||
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
|
||||
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
|
||||
- `loadDisabledProviders()`: Load from local settings
|
||||
- `filterByProvider()`: Client-side filtering (enhanced version)
|
||||
- `isItemAllowedByProviderFilter()`: Check if item should be visible
|
||||
|
||||
2. **lib/services/music_assistant_api.dart**
|
||||
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
|
||||
- Added `MusicProvider` model parsing from `/providers` endpoint
|
||||
- `musicProviders` getter: List of available music providers
|
||||
|
||||
3. **lib/services/sync_service.dart**
|
||||
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
|
||||
- Pass provider filter to API calls during sync
|
||||
|
||||
4. **lib/services/settings_service.dart**
|
||||
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
|
||||
- `setDisabledMusicProviders()`: Save disabled providers
|
||||
- `toggleProviderEnabled()`: Toggle and persist
|
||||
|
||||
5. **lib/screens/settings_screen.dart**
|
||||
- UI for toggling individual providers on/off
|
||||
- Display provider name and instance ID
|
||||
|
||||
6. **lib/screens/new_library_screen.dart**
|
||||
- Integration with provider filtering
|
||||
|
||||
7. **lib/models/music_provider.dart** (new file)
|
||||
- Model for MA provider instances
|
||||
- Fields: `instanceId`, `domain`, `name`, `available`
|
||||
|
||||
## Race Condition Issue
|
||||
|
||||
A race condition was identified where:
|
||||
1. Initial app sync starts WITHOUT provider filter
|
||||
2. User toggles provider filter
|
||||
3. `clearCache()` is called
|
||||
4. New sync with provider filter tries to start
|
||||
5. "Sync already in progress" - new sync is skipped
|
||||
6. Result: Old unfiltered data remains
|
||||
|
||||
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
|
||||
|
||||
## Potential Solutions to Explore
|
||||
|
||||
### 1. Investigate MA Server-Side Filtering
|
||||
|
||||
The MA server should support filtering by provider. Need to verify:
|
||||
- Does the `provider` parameter work for filtering between same-type providers?
|
||||
- Is there a different parameter or API endpoint?
|
||||
- Check MA source code: https://github.com/music-assistant/server
|
||||
|
||||
### 2. Library Sync Per Provider
|
||||
|
||||
Instead of syncing all items and filtering, sync each provider's library separately:
|
||||
```dart
|
||||
for (final provider in enabledProviders) {
|
||||
final items = await api.getAlbums(provider: provider.instanceId);
|
||||
// Store with provider association
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Track Item Origin
|
||||
|
||||
Store which provider originally added each item:
|
||||
- Modify database schema to track `source_provider`
|
||||
- When syncing, record which provider the item came from
|
||||
- Filter based on source, not playability
|
||||
|
||||
### 4. Use MA's Internal Library State
|
||||
|
||||
MA might have internal state about which items are in which provider's library. Investigate:
|
||||
- `/music/library/items` endpoint
|
||||
- Provider-specific library endpoints
|
||||
- MA's database schema for library associations
|
||||
|
||||
### 5. Check MA User Settings
|
||||
|
||||
The MA web UI's provider filter might be stored in user settings:
|
||||
```dart
|
||||
final userInfo = await api.getCurrentUserInfo();
|
||||
// Check for provider_filter or similar field
|
||||
```
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Log Messages to Watch
|
||||
|
||||
```
|
||||
🔌 Selected provider for filter: spotify--xxxxx
|
||||
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
|
||||
🔍 API getArtists returned XXX items
|
||||
🔌 filterByProvider: XXX → YYY (disabled=[...])
|
||||
```
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Configure two Spotify accounts in MA
|
||||
2. Add different artists to each account's library
|
||||
3. In Ensemble, select only one Spotify account
|
||||
4. Verify library shows ONLY that account's items
|
||||
5. Switch to other account, verify different items
|
||||
|
||||
### Expected vs Actual
|
||||
|
||||
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
|
||||
- **Actual**: Artist appears regardless of which account is selected
|
||||
|
||||
## MA API Reference
|
||||
|
||||
### Get Providers
|
||||
```
|
||||
Command: providers
|
||||
Returns: List of provider instances with type, domain, instanceId, name, available
|
||||
```
|
||||
|
||||
### Library Items with Filter
|
||||
```
|
||||
Command: music/artists/library_items
|
||||
Args: {
|
||||
limit: int,
|
||||
offset: int,
|
||||
provider: string | list<string>, // Filter to specific provider(s)
|
||||
album_artists_only: bool,
|
||||
order_by: string
|
||||
}
|
||||
```
|
||||
|
||||
### Relevant MA Source Files
|
||||
|
||||
- `music_assistant/server/controllers/music.py` - Library item retrieval
|
||||
- `music_assistant/server/models/provider.py` - Provider model
|
||||
- `music_assistant/server/providers/` - Individual provider implementations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
|
||||
|
||||
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Audiobook Provider Filtering Fix
|
||||
|
||||
## Date: January 17, 2026 (Continuation)
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
|
||||
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
|
||||
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
|
||||
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing capability-based provider filtering for the music library, two issues remained:
|
||||
|
||||
### Issue 1: Audiobooks Not Filtering During Sync
|
||||
|
||||
The bug report showed:
|
||||
```
|
||||
📊 Source tracking: 252 albums, 87 artists have provider info
|
||||
📚 Total audiobooks from browse: 186
|
||||
```
|
||||
|
||||
- Albums and artists had provider source tracking
|
||||
- Audiobooks had NO provider source tracking (not listed)
|
||||
- All 186 audiobooks were returned regardless of provider selection
|
||||
|
||||
### Issue 2: UI Not Updating Instantly
|
||||
|
||||
When toggling providers in the library menu:
|
||||
- Music tabs weren't updating immediately
|
||||
- Audiobooks required manual pull-to-refresh
|
||||
- Changes only appeared after ~600ms (debounce timer)
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Cause 1: Browse API Ignoring Provider Filter
|
||||
|
||||
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
|
||||
|
||||
```dart
|
||||
// BEFORE: providerInstanceIds was ignored when using browse API
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Cause 2: `notifyListeners()` Called Too Late
|
||||
|
||||
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
|
||||
|
||||
```dart
|
||||
// BEFORE: notifyListeners only called after 600ms debounce
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
forceLibrarySync();
|
||||
notifyListeners(); // Too late! UI should update immediately
|
||||
});
|
||||
```
|
||||
|
||||
### Cause 3: Audiobook Tabs Not Listening for Changes
|
||||
|
||||
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
|
||||
|
||||
```dart
|
||||
// BEFORE: context.read doesn't listen for changes
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
// ... widget doesn't rebuild when enabledProviders changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solutions
|
||||
|
||||
### Fix 1: Browse API Provider Filtering
|
||||
|
||||
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
|
||||
|
||||
Added logic to filter library paths by provider ID before browsing:
|
||||
|
||||
```dart
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// If specific providers are requested, filter libraries to only matching providers
|
||||
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
// Extract provider ID from library path (everything before "://")
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
|
||||
// If no libraries match the requested providers, return empty
|
||||
if (librariesToBrowse.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Immediate `notifyListeners()` Call
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
|
||||
|
||||
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// Cancel any pending debounce timer
|
||||
_providerFilterDebounceTimer?.cancel();
|
||||
|
||||
// Start debounced sync - waits 600ms for user to finish toggling
|
||||
// This refreshes the cache with new data from enabled providers
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logger.log('🔄 Debounce complete, starting library sync...');
|
||||
forceLibrarySync();
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 3: Audiobook Tabs Use Selector
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
// Use Selector to rebuild when enabledProviderIds changes
|
||||
return Selector<MusicAssistantProvider, Set<String>>(
|
||||
selector: (_, provider) => provider.enabledProviderIds.toSet(),
|
||||
builder: (context, enabledProviders, _) {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Client-side filtering using SyncService source tracking for instant updates
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// ... rest of widget
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 4: Enhanced Sync Logging
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
Added audiobook counts to logging:
|
||||
|
||||
```dart
|
||||
// Per-provider log (line 237)
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Source tracking summary (line 323)
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow After Fixes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
│ │
|
||||
│ 1. toggleProviderEnabled() called │
|
||||
│ 2. _enabledProviderIds updated │
|
||||
│ 3. notifyListeners() called IMMEDIATELY │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Selectors Detect Change │
|
||||
│ │
|
||||
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
|
||||
│ Audiobook tabs: Selector<..., Set<String>> │
|
||||
│ │
|
||||
│ → All tabs rebuild immediately │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Filtering (Instant) │
|
||||
│ │
|
||||
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
|
||||
│ │
|
||||
│ → Uses EXISTING cache with source tracking │
|
||||
│ → Instant UI update, no network required │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Background Sync (After 600ms Debounce) │
|
||||
│ │
|
||||
│ - Refreshes cache with new data from enabled providers │
|
||||
│ - Updates source tracking │
|
||||
│ - Only needed when ENABLING providers (to fetch new data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
|
||||
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
|
||||
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
|
||||
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fixes
|
||||
|
||||
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
|
||||
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
|
||||
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
|
||||
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
|
||||
- [ ] Toggle between different Spotify accounts, verify correct filtering
|
||||
- [ ] Sync with ABS library filtering enabled
|
||||
- [ ] Verify audiobooks appear in source tracking log
|
||||
- [ ] Toggle ABS provider off in library menu
|
||||
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
|
||||
- [ ] Toggle ABS provider back on
|
||||
- [ ] Verify audiobooks reappear after sync completes
|
||||
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
|
||||
|
||||
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
|
||||
|
||||
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Podcast Provider Counts Showing Incorrectly
|
||||
|
||||
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
|
||||
|
||||
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
|
||||
|
||||
**Solution**:
|
||||
- Added `inLibrary` field to `ProviderMapping` model
|
||||
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
|
||||
- Updated podcast filtering in library screen to check `inLibrary` field
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/media_item.dart` - Added `inLibrary` field
|
||||
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
|
||||
- `lib/screens/new_library_screen.dart` - Updated filtering logic
|
||||
|
||||
### 2. iTunes Podcasts Not Appearing in Library
|
||||
|
||||
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
|
||||
|
||||
**Investigation**:
|
||||
- Added `itunes_podcasts` to provider capabilities and music provider domains
|
||||
- Added synthetic provider instance creation for search-only providers
|
||||
- Added debug logging to understand podcast loading
|
||||
|
||||
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
|
||||
|
||||
### 3. Episode Loading Failing for Non-Library Podcasts
|
||||
|
||||
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
|
||||
|
||||
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
|
||||
|
||||
### 4. Search Filtering Rework
|
||||
|
||||
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
|
||||
|
||||
**Solution**:
|
||||
- Removed `filterSearchResults()` calls from search methods
|
||||
- Added client-side `_filterByEnabledProviders()` helper
|
||||
- Applied filtering only when `_libraryOnly` toggle is ON
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
|
||||
- `lib/screens/search_screen.dart` - Added client-side filtering
|
||||
|
||||
### 5. Library Buttons Not Updating Immediately
|
||||
|
||||
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
|
||||
|
||||
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart`
|
||||
- `lib/screens/album_details_screen.dart`
|
||||
- `lib/screens/artist_details_screen.dart`
|
||||
|
||||
## Code Changes
|
||||
|
||||
### ProviderMapping.inLibrary Field
|
||||
|
||||
```dart
|
||||
// lib/models/media_item.dart
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
final bool inLibrary; // NEW: indicates ownership
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderMapping(
|
||||
// ... other fields ...
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Podcast Provider Counting
|
||||
|
||||
```dart
|
||||
// lib/providers/music_assistant_provider.dart
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Update Pattern
|
||||
|
||||
```dart
|
||||
// Example from podcast_detail_screen.dart
|
||||
Future<void> _toggleLibrary() async {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(...).catchError((e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Search Filtering
|
||||
|
||||
```dart
|
||||
// lib/screens/search_screen.dart
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results;
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true;
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// Applied when setting results:
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Capabilities Update
|
||||
|
||||
```dart
|
||||
// lib/models/provider_instance.dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'}, // NEW
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
// ... other providers
|
||||
};
|
||||
```
|
||||
|
||||
## Summary of All Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
|
||||
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
|
||||
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
|
||||
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
|
||||
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
|
||||
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
|
||||
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
|
||||
|
||||
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
|
||||
|
||||
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
|
||||
|
||||
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
|
||||
@@ -0,0 +1,263 @@
|
||||
# Provider Filtering Improvements
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Provider Capability Mapping
|
||||
|
||||
**File:** `lib/models/provider_instance.dart`
|
||||
|
||||
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
|
||||
|
||||
```dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Self-hosted media servers (can have music and audiobooks)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Audiobook-only providers
|
||||
'audiobookshelf': {'audiobooks'},
|
||||
|
||||
// Radio providers
|
||||
'tunein': {'radio'},
|
||||
'radiobrowser': {'radio'},
|
||||
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
```
|
||||
|
||||
**Added helper methods:**
|
||||
```dart
|
||||
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
|
||||
bool supportsContentType(String category) => supportedContentTypes.contains(category);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Radio providers never show in Artists tab (by design, not by count)
|
||||
- Works immediately, even with empty libraries
|
||||
- Disabled providers still show in menu (if they support the content type)
|
||||
- Clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Provider Filtering in MusicAssistantProvider
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Updated `getRelevantProvidersForCategory()` to filter by capability:
|
||||
|
||||
```dart
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
// ... get counts ...
|
||||
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
result.add((provider, count));
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Simplified Library Menu Provider Filter
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Simplified the options menu overlay to show all providers that support the current category:
|
||||
|
||||
**Before (clunky):**
|
||||
```dart
|
||||
if (widget.relevantProviders.where((p) {
|
||||
final hasItems = p.$2 > 0;
|
||||
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
|
||||
!widget.enabledProviderIds.contains(p.$1.instanceId);
|
||||
return hasItems || isDisabledByUser;
|
||||
}).length > 1) ...[
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```dart
|
||||
// relevantProviders is pre-filtered by capability
|
||||
if (widget.relevantProviders.length > 1) ...[
|
||||
// Show all providers that support this category
|
||||
...widget.relevantProviders.map((providerData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Removed Provider Toggles from Settings Screen
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
|
||||
|
||||
- Removed `_providerEnabled` state variable
|
||||
- Removed provider loading code from `_loadSettings()`
|
||||
- Removed the entire Music Providers UI section (~100 lines)
|
||||
|
||||
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
|
||||
|
||||
---
|
||||
|
||||
### 5. Fixed Audiobooks Library Provider Filtering
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
|
||||
|
||||
**Fixed `_buildBooksAuthorsTab()`:**
|
||||
```dart
|
||||
final syncService = SyncService.instance;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// Filter by favorites if enabled
|
||||
if (_showFavoritesOnly) {
|
||||
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
// Group filtered audiobooks by author (dynamic, not cached)
|
||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
||||
for (final book in audiobooks) {
|
||||
final authorName = book.authorsString;
|
||||
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed `_buildAllBooksTab()`:**
|
||||
- Same provider filtering pattern
|
||||
- Dynamic sorting after filtering
|
||||
- Updated PageStorageKey to include provider count for proper rebuild
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Provider Filter Menu Disappearing
|
||||
|
||||
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
|
||||
|
||||
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
|
||||
|
||||
### Audiobooks Not Filtering by Provider
|
||||
|
||||
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
|
||||
|
||||
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
|
||||
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
|
||||
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Provider Capability Map │
|
||||
│ (ProviderInstance.providerCapabilities) │
|
||||
│ │
|
||||
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
|
||||
│ audiobookshelf → {audiobooks} │
|
||||
│ tunein → {radio} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ getRelevantProvidersForCategory(category) │
|
||||
│ │
|
||||
│ - Filters _availableMusicProviders by capability │
|
||||
│ - Returns only providers that support the category │
|
||||
│ - Includes item counts for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Menu (Options) │
|
||||
│ │
|
||||
│ - Shows providers section if > 1 providers support category │
|
||||
│ - All relevant providers shown (can toggle on/off) │
|
||||
│ - Instant UI update via setState() │
|
||||
│ - Background sync after 1.5s debounce │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tab Build Methods │
|
||||
│ │
|
||||
│ - Get enabledProviderIds from MusicAssistantProvider │
|
||||
│ - Filter data using SyncService source tracking │
|
||||
│ - Display filtered results │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
|
||||
|
||||
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Music tabs filter by provider correctly
|
||||
- [x] Provider menu shows only relevant providers per category
|
||||
- [x] Radio providers don't appear in Artists/Albums tabs
|
||||
- [x] Spotify appears in Audiobooks tab (has audiobooks)
|
||||
- [x] Audiobooks Authors tab filters by provider
|
||||
- [x] Audiobooks All Books tab filters by provider
|
||||
- [ ] Audiobooks Series tab filters by provider (not implemented)
|
||||
- [x] Disabled providers can be re-enabled from menu
|
||||
- [x] Settings screen no longer has provider toggles
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
|
||||
@@ -0,0 +1,221 @@
|
||||
# Feature: Replace "Play On" Popups with Player Selector
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
|
||||
|
||||
## Current State
|
||||
|
||||
### PlayerPickerSheet Popup
|
||||
Used in 7+ places:
|
||||
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
|
||||
- `playlist_details_screen.dart` - Play Playlist, Play Radio
|
||||
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
|
||||
- `podcast_detail_screen.dart` - Play Episode
|
||||
- `audiobook_detail_screen.dart` - Play Audiobook
|
||||
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
|
||||
|
||||
### Player Selector (Reveal Overlay)
|
||||
- Location: `lib/widgets/player/player_reveal_overlay.dart`
|
||||
- Shows vertical list of all players with artwork, current track, sync status
|
||||
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
|
||||
- Triggered by pull-down gesture or device button tap
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Modify PlayerRevealOverlay
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart`
|
||||
|
||||
Add new optional parameters:
|
||||
|
||||
```dart
|
||||
class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom;
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints;
|
||||
|
||||
// NEW: Callback when player is selected for a pending action
|
||||
final void Function(Player player)? onPlayerSelected;
|
||||
|
||||
// NEW: Context hint to display instead of default hints
|
||||
// e.g., "Select player to play album"
|
||||
final String? contextHint;
|
||||
|
||||
// NEW: Icon for context hint (default: speaker icon)
|
||||
final IconData? contextHintIcon;
|
||||
```
|
||||
|
||||
### 2. Modify Hint Display
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
|
||||
// New:
|
||||
: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
### 3. Modify Player Card onTap
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
|
||||
// New:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (widget.onPlayerSelected != null) {
|
||||
// Execute the pending action with selected player
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
// Default behavior: just select the player
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Add Helper Method to GlobalPlayerOverlay
|
||||
|
||||
**File:** `lib/widgets/global_player_overlay.dart`
|
||||
|
||||
Add a static method to show player selector with pending action:
|
||||
|
||||
```dart
|
||||
/// Show player selector for a "Play on" action
|
||||
///
|
||||
/// [contextHint] - Message shown instead of default hints
|
||||
/// [onPlayerSelected] - Called when user taps a player
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
/// contextHint: "Select player to play album",
|
||||
/// onPlayerSelected: (player) {
|
||||
/// provider.playAlbum(albumId, playerId: player.playerId);
|
||||
/// },
|
||||
/// );
|
||||
/// ```
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(Player player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Implementation: trigger the player reveal overlay with these parameters
|
||||
// May need to store pending action in state and pass to overlay
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Update Calling Sites
|
||||
|
||||
Replace `showPlayerPickerSheet()` calls with new method:
|
||||
|
||||
**Example - album_details_screen.dart:**
|
||||
|
||||
```dart
|
||||
// Current (around line 1305-1316):
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Play Album',
|
||||
onPlayerSelected: (player) async {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
},
|
||||
);
|
||||
|
||||
// New:
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
|
||||
onPlayerSelected: (player) async {
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Add Localizations
|
||||
|
||||
**File:** `lib/l10n/app_en.arb` (and other language files)
|
||||
|
||||
```json
|
||||
"selectPlayerToPlay": "Select player to play",
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"selectPlayerToPlayRadio": "Select player to start radio",
|
||||
"selectPlayerToPlayEpisode": "Select player to play episode",
|
||||
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
|
||||
2. `lib/widgets/global_player_overlay.dart` - Add helper method
|
||||
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
|
||||
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
|
||||
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
|
||||
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
|
||||
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
|
||||
8. `lib/l10n/app_en.arb` - Add new strings
|
||||
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
|
||||
|
||||
## Exception: Queue Transfer
|
||||
|
||||
**Keep the popup for `queue_screen.dart`** because:
|
||||
- Needs to filter out current player (can't transfer to self)
|
||||
- Needs to filter out certain player types
|
||||
- Different UX intent (transfer vs play)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Modify `PlayerRevealOverlay` to accept new parameters
|
||||
2. Add helper method to `GlobalPlayerOverlay`
|
||||
3. Test with one screen (album_details_screen.dart)
|
||||
4. Add localizations
|
||||
5. Migrate remaining screens
|
||||
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Album details: Play Album button
|
||||
- [ ] Album details: Play From Here (track context menu)
|
||||
- [ ] Album details: Play Radio (track context menu)
|
||||
- [ ] Playlist details: Play Playlist button
|
||||
- [ ] Playlist details: Play Radio (track context menu)
|
||||
- [ ] Search: Play album from results
|
||||
- [ ] Search: Play playlist from results
|
||||
- [ ] Search: Play radio from results
|
||||
- [ ] Podcast detail: Play episode
|
||||
- [ ] Audiobook detail: Play audiobook
|
||||
- [ ] Queue transfer still works with popup
|
||||
- [ ] Long-press to sync still works in player selector
|
||||
- [ ] Dismiss by tapping backdrop works
|
||||
- [ ] Dismiss by swiping down works
|
||||
@@ -10,4 +10,7 @@ class NetworkConstants {
|
||||
|
||||
/// Default HTTP port
|
||||
static const int defaultHttpPort = 80;
|
||||
|
||||
/// Maximum number of pending WebSocket requests to prevent memory leaks
|
||||
static const int maxPendingRequests = 100;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
|
||||
/// Whether this item was deleted on the server
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -226,6 +230,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
|
||||
}
|
||||
// Migration from v5 to v6: Add source_providers column for client-side filtering
|
||||
if (from < 6) {
|
||||
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -368,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ============================================
|
||||
|
||||
/// Cache a library item
|
||||
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
|
||||
Future<void> cacheItem({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required String data,
|
||||
String? sourceProvider,
|
||||
}) async {
|
||||
final cacheKey = '${itemType}_$itemId';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
// If sourceProvider is specified, we need to merge with existing providers
|
||||
if (sourceProvider != null) {
|
||||
final existing = await getCachedItem(itemType, itemId);
|
||||
List<String> providers = [];
|
||||
if (existing != null) {
|
||||
try {
|
||||
final existingProviders = existing.sourceProviders;
|
||||
if (existingProviders.isNotEmpty && existingProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
(await Future.value(existingProviders))
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.replaceAll('"', '')
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) => s.trim()),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!providers.contains(sourceProvider)) {
|
||||
providers.add(sourceProvider);
|
||||
}
|
||||
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
));
|
||||
} else {
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached items by type
|
||||
@@ -650,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearSearchHistory() async {
|
||||
await delete(searchHistory).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Batch size for chunked inserts to prevent SQLite limits
|
||||
static const int _batchChunkSize = 500;
|
||||
|
||||
/// Batch cache multiple items efficiently using drift batch API
|
||||
/// This is much faster than individual inserts for large datasets.
|
||||
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
|
||||
///
|
||||
/// [items] - List of items to cache, each containing:
|
||||
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
|
||||
/// - itemId: the item's ID
|
||||
/// - data: JSON string of the item
|
||||
/// - sourceProviders: list of provider instance IDs
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
// Process in chunks to avoid SQLite variable limits
|
||||
for (var i = 0; i < items.length; i += _batchChunkSize) {
|
||||
final chunk = items.skip(i).take(_batchChunkSize).toList();
|
||||
|
||||
await batch((b) {
|
||||
for (final item in chunk) {
|
||||
final cacheKey = '${item.itemType}_${item.itemId}';
|
||||
final sourceProvidersJson = item.sourceProviders.isEmpty
|
||||
? '[]'
|
||||
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
b.insert(
|
||||
libraryCache,
|
||||
LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
data: item.data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for batch cache operations
|
||||
class BatchCacheItem {
|
||||
final String itemType;
|
||||
final String itemId;
|
||||
final String data;
|
||||
final List<String> sourceProviders;
|
||||
|
||||
const BatchCacheItem({
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
this.sourceProviders = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -856,9 +856,24 @@ class $LibraryCacheTable extends LibraryCache
|
||||
defaultConstraints:
|
||||
GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
static const VerificationMeta _sourceProvidersMeta =
|
||||
const VerificationMeta('sourceProviders');
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[cacheKey, itemType, itemId, data, lastSynced, isDeleted];
|
||||
late final GeneratedColumn<String> sourceProviders = GeneratedColumn<String>(
|
||||
'source_providers', aliasedName, false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant('[]'));
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
cacheKey,
|
||||
itemType,
|
||||
itemId,
|
||||
data,
|
||||
lastSynced,
|
||||
isDeleted,
|
||||
sourceProviders
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -905,6 +920,12 @@ class $LibraryCacheTable extends LibraryCache
|
||||
context.handle(_isDeletedMeta,
|
||||
isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta));
|
||||
}
|
||||
if (data.containsKey('source_providers')) {
|
||||
context.handle(
|
||||
_sourceProvidersMeta,
|
||||
sourceProviders.isAcceptableOrUnknown(
|
||||
data['source_providers']!, _sourceProvidersMeta));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -926,6 +947,8 @@ class $LibraryCacheTable extends LibraryCache
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_synced'])!,
|
||||
isDeleted: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!,
|
||||
sourceProviders: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string, data['${effectivePrefix}source_providers'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,13 +977,18 @@ class LibraryCacheData extends DataClass
|
||||
|
||||
/// Whether this item was deleted on the server
|
||||
final bool isDeleted;
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
final String sourceProviders;
|
||||
const LibraryCacheData(
|
||||
{required this.cacheKey,
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
required this.lastSynced,
|
||||
required this.isDeleted});
|
||||
required this.isDeleted,
|
||||
required this.sourceProviders});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
@@ -970,6 +998,7 @@ class LibraryCacheData extends DataClass
|
||||
map['data'] = Variable<String>(data);
|
||||
map['last_synced'] = Variable<DateTime>(lastSynced);
|
||||
map['is_deleted'] = Variable<bool>(isDeleted);
|
||||
map['source_providers'] = Variable<String>(sourceProviders);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -981,6 +1010,7 @@ class LibraryCacheData extends DataClass
|
||||
data: Value(data),
|
||||
lastSynced: Value(lastSynced),
|
||||
isDeleted: Value(isDeleted),
|
||||
sourceProviders: Value(sourceProviders),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -994,6 +1024,7 @@ class LibraryCacheData extends DataClass
|
||||
data: serializer.fromJson<String>(json['data']),
|
||||
lastSynced: serializer.fromJson<DateTime>(json['lastSynced']),
|
||||
isDeleted: serializer.fromJson<bool>(json['isDeleted']),
|
||||
sourceProviders: serializer.fromJson<String>(json['sourceProviders']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1006,6 +1037,7 @@ class LibraryCacheData extends DataClass
|
||||
'data': serializer.toJson<String>(data),
|
||||
'lastSynced': serializer.toJson<DateTime>(lastSynced),
|
||||
'isDeleted': serializer.toJson<bool>(isDeleted),
|
||||
'sourceProviders': serializer.toJson<String>(sourceProviders),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1015,7 +1047,8 @@ class LibraryCacheData extends DataClass
|
||||
String? itemId,
|
||||
String? data,
|
||||
DateTime? lastSynced,
|
||||
bool? isDeleted}) =>
|
||||
bool? isDeleted,
|
||||
String? sourceProviders}) =>
|
||||
LibraryCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
itemType: itemType ?? this.itemType,
|
||||
@@ -1023,6 +1056,7 @@ class LibraryCacheData extends DataClass
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
);
|
||||
LibraryCacheData copyWithCompanion(LibraryCacheCompanion data) {
|
||||
return LibraryCacheData(
|
||||
@@ -1033,6 +1067,9 @@ class LibraryCacheData extends DataClass
|
||||
lastSynced:
|
||||
data.lastSynced.present ? data.lastSynced.value : this.lastSynced,
|
||||
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
|
||||
sourceProviders: data.sourceProviders.present
|
||||
? data.sourceProviders.value
|
||||
: this.sourceProviders,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1044,14 +1081,15 @@ class LibraryCacheData extends DataClass
|
||||
..write('itemId: $itemId, ')
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(cacheKey, itemType, itemId, data, lastSynced, isDeleted);
|
||||
int get hashCode => Object.hash(
|
||||
cacheKey, itemType, itemId, data, lastSynced, isDeleted, sourceProviders);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
@@ -1061,7 +1099,8 @@ class LibraryCacheData extends DataClass
|
||||
other.itemId == this.itemId &&
|
||||
other.data == this.data &&
|
||||
other.lastSynced == this.lastSynced &&
|
||||
other.isDeleted == this.isDeleted);
|
||||
other.isDeleted == this.isDeleted &&
|
||||
other.sourceProviders == this.sourceProviders);
|
||||
}
|
||||
|
||||
class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
@@ -1071,6 +1110,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
final Value<String> data;
|
||||
final Value<DateTime> lastSynced;
|
||||
final Value<bool> isDeleted;
|
||||
final Value<String> sourceProviders;
|
||||
final Value<int> rowid;
|
||||
const LibraryCacheCompanion({
|
||||
this.cacheKey = const Value.absent(),
|
||||
@@ -1079,6 +1119,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
this.data = const Value.absent(),
|
||||
this.lastSynced = const Value.absent(),
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
LibraryCacheCompanion.insert({
|
||||
@@ -1088,6 +1129,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : cacheKey = Value(cacheKey),
|
||||
itemType = Value(itemType),
|
||||
@@ -1101,6 +1143,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Expression<String>? data,
|
||||
Expression<DateTime>? lastSynced,
|
||||
Expression<bool>? isDeleted,
|
||||
Expression<String>? sourceProviders,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -1110,6 +1153,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (data != null) 'data': data,
|
||||
if (lastSynced != null) 'last_synced': lastSynced,
|
||||
if (isDeleted != null) 'is_deleted': isDeleted,
|
||||
if (sourceProviders != null) 'source_providers': sourceProviders,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -1121,6 +1165,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Value<String>? data,
|
||||
Value<DateTime>? lastSynced,
|
||||
Value<bool>? isDeleted,
|
||||
Value<String>? sourceProviders,
|
||||
Value<int>? rowid}) {
|
||||
return LibraryCacheCompanion(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
@@ -1129,6 +1174,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -1154,6 +1200,9 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (isDeleted.present) {
|
||||
map['is_deleted'] = Variable<bool>(isDeleted.value);
|
||||
}
|
||||
if (sourceProviders.present) {
|
||||
map['source_providers'] = Variable<String>(sourceProviders.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -1169,6 +1218,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -3507,6 +3557,7 @@ typedef $$LibraryCacheTableCreateCompanionBuilder = LibraryCacheCompanion
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
@@ -3517,6 +3568,7 @@ typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
Value<String> data,
|
||||
Value<DateTime> lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -3546,6 +3598,10 @@ class $$LibraryCacheTableFilterComposer
|
||||
|
||||
ColumnFilters<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableOrderingComposer
|
||||
@@ -3574,6 +3630,10 @@ class $$LibraryCacheTableOrderingComposer
|
||||
|
||||
ColumnOrderings<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableAnnotationComposer
|
||||
@@ -3602,6 +3662,9 @@ class $$LibraryCacheTableAnnotationComposer
|
||||
|
||||
GeneratedColumn<bool> get isDeleted =>
|
||||
$composableBuilder(column: $table.isDeleted, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
@@ -3636,6 +3699,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
Value<String> data = const Value.absent(),
|
||||
Value<DateTime> lastSynced = const Value.absent(),
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion(
|
||||
@@ -3645,6 +3709,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
@@ -3654,6 +3719,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion.insert(
|
||||
@@ -3663,6 +3729,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
|
||||
@@ -1140,6 +1140,10 @@
|
||||
"audiobookLibraries": "Audiobook Libraries",
|
||||
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
|
||||
"unknownLibrary": "Unknown Library",
|
||||
"musicProviders": "Music Providers",
|
||||
"musicProvidersDescription": "Choose which accounts to show in your library",
|
||||
"libraryRefreshing": "Refreshing library...",
|
||||
"cannotDisableLastProvider": "Cannot disable the last provider",
|
||||
"noSeriesFound": "No Series Found",
|
||||
"ensembleBugReport": "Ensemble Bug Report",
|
||||
"byOwner": "By {owner}",
|
||||
@@ -1288,6 +1292,16 @@
|
||||
"description": "Description for smart sort players setting"
|
||||
},
|
||||
|
||||
"disableAutoSwitch": "Disable Auto-Switch",
|
||||
"@disableAutoSwitch": {
|
||||
"description": "Setting to disable automatic player switching"
|
||||
},
|
||||
|
||||
"disableAutoSwitchDescription": "Never automatically switch to a different player",
|
||||
"@disableAutoSwitchDescription": {
|
||||
"description": "Description for disable auto-switch setting"
|
||||
},
|
||||
|
||||
"playerStateUnavailable": "Unavailable",
|
||||
"@playerStateUnavailable": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1343,6 +1357,21 @@
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "Settings section title for display options"
|
||||
},
|
||||
|
||||
"showProviderIcons": "Show Provider Icons",
|
||||
"@showProviderIcons": {
|
||||
"description": "Toggle label for showing provider icons on album art"
|
||||
},
|
||||
|
||||
"showProviderIconsDescription": "Display provider logos on album art",
|
||||
"@showProviderIconsDescription": {
|
||||
"description": "Description for show provider icons toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
@@ -1431,5 +1460,248 @@
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
},
|
||||
|
||||
"failedToPlayRadioStation": "Failed to play radio station: {error}",
|
||||
"@failedToPlayRadioStation": {
|
||||
"description": "Error when playing radio station fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"itemAlreadyInLibrary": "Item is already in library",
|
||||
"@itemAlreadyInLibrary": {
|
||||
"description": "Message when trying to add item already in library"
|
||||
},
|
||||
|
||||
"failedToAddToLibrary": "Failed to add to library",
|
||||
"@failedToAddToLibrary": {
|
||||
"description": "Error when adding to library fails"
|
||||
},
|
||||
|
||||
"cannotFindLibraryId": "Cannot find library ID for removal",
|
||||
"@cannotFindLibraryId": {
|
||||
"description": "Error when library ID not found for removal"
|
||||
},
|
||||
|
||||
"failedToRemoveFromLibrary": "Failed to remove from library",
|
||||
"@failedToRemoveFromLibrary": {
|
||||
"description": "Error when removing from library fails"
|
||||
},
|
||||
|
||||
"failedToPlayAlbum": "Failed to play album: {error}",
|
||||
"@failedToPlayAlbum": {
|
||||
"description": "Error when playing album fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingOnPlayer": "Playing {name} on {playerName}",
|
||||
"@playingOnPlayer": {
|
||||
"description": "Status when playing item on specific player",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayPlaylist": "Failed to play playlist: {error}",
|
||||
"@failedToPlayPlaylist": {
|
||||
"description": "Error when playing playlist fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
|
||||
"@failedToPlayAudiobook": {
|
||||
"description": "Error when playing audiobook fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noOtherPlayersAvailable": "No other players available",
|
||||
"@noOtherPlayersAvailable": {
|
||||
"description": "Message when no other players available for transfer"
|
||||
},
|
||||
|
||||
"transferQueue": "Transfer queue",
|
||||
"@transferQueue": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"clearQueue": "Clear queue",
|
||||
"@clearQueue": {
|
||||
"description": "Tooltip for clear queue button"
|
||||
},
|
||||
|
||||
"syncingPlayer": "Syncing {playerName}...",
|
||||
"@syncingPlayer": {
|
||||
"description": "Status when syncing player",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"switchPlayer": "Switch Player",
|
||||
"@switchPlayer": {
|
||||
"description": "Label for switch player action"
|
||||
},
|
||||
|
||||
"logsCopiedToClipboard": "Logs copied to clipboard",
|
||||
"@logsCopiedToClipboard": {
|
||||
"description": "Confirmation when logs copied"
|
||||
},
|
||||
|
||||
"auto": "Auto",
|
||||
"@auto": {
|
||||
"description": "Auto option for language selection"
|
||||
},
|
||||
|
||||
"transferQueueTo": "Transfer Queue To",
|
||||
"@transferQueueTo": {
|
||||
"description": "Title for transfer queue dialog"
|
||||
},
|
||||
|
||||
"queueTransferredTo": "Queue transferred to {playerName}",
|
||||
"@queueTransferredTo": {
|
||||
"description": "Success message when queue transferred",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToTransferQueue": "Failed to transfer queue: {error}",
|
||||
"@failedToTransferQueue": {
|
||||
"description": "Error when queue transfer fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToLoadQueue": "Failed to load queue: {error}",
|
||||
"@failedToLoadQueue": {
|
||||
"description": "Error when queue loading fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"transferTo": "Transfer to...",
|
||||
"@transferTo": {
|
||||
"description": "Label for transfer destination selection"
|
||||
},
|
||||
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"selectPlayerToPlay": "Select a player",
|
||||
"@selectPlayerToPlay": {
|
||||
"description": "Generic hint for selecting a player in player selector"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"@selectPlayerToPlayAlbum": {
|
||||
"description": "Hint for selecting player to play album"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"@selectPlayerToPlayPlaylist": {
|
||||
"description": "Hint for selecting player to play playlist"
|
||||
},
|
||||
|
||||
"selectPlayerForRadio": "Select player for radio",
|
||||
"@selectPlayerForRadio": {
|
||||
"description": "Hint for selecting player to start radio"
|
||||
},
|
||||
|
||||
"selectPlayerForEpisode": "Select player for episode",
|
||||
"@selectPlayerForEpisode": {
|
||||
"description": "Hint for selecting player to play episode"
|
||||
},
|
||||
|
||||
"selectPlayerForAudiobook": "Select player for audiobook",
|
||||
"@selectPlayerForAudiobook": {
|
||||
"description": "Hint for selecting player to play audiobook"
|
||||
},
|
||||
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"localConnection": "Server URL",
|
||||
"@localConnection": {
|
||||
"description": "Direct connection mode toggle label"
|
||||
},
|
||||
|
||||
"remoteConnection": "Remote Access ID",
|
||||
"@remoteConnection": {
|
||||
"description": "HA Remote Access connection mode toggle label"
|
||||
},
|
||||
|
||||
"remoteAccessId": "Remote Access ID",
|
||||
"@remoteAccessId": {
|
||||
"description": "Remote access ID field label"
|
||||
},
|
||||
|
||||
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
|
||||
"@remoteAccessIdHint": {
|
||||
"description": "Hint for remote access ID field"
|
||||
},
|
||||
|
||||
"remoteAccessNote": "For Home Assistant add-on users only",
|
||||
"@remoteAccessNote": {
|
||||
"description": "Note about remote access availability"
|
||||
},
|
||||
|
||||
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
|
||||
"@pleaseEnterRemoteId": {
|
||||
"description": "Validation message for remote access ID"
|
||||
},
|
||||
|
||||
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
|
||||
"@remoteConnectionFailed": {
|
||||
"description": "Error message when remote connection fails"
|
||||
},
|
||||
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"@scanQrCode": {
|
||||
"description": "Title for QR code scanner screen"
|
||||
},
|
||||
|
||||
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
|
||||
"@pointCameraAtQr": {
|
||||
"description": "Instructions for scanning QR code"
|
||||
},
|
||||
|
||||
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
|
||||
"@cameraPermissionDenied": {
|
||||
"description": "Error message when camera permission is denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
|
||||
/// be consistent with the languages listed in the S.supportedLocales
|
||||
/// property.
|
||||
abstract class S {
|
||||
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
S(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
@@ -84,7 +85,8 @@ abstract class S {
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -1563,6 +1565,30 @@ abstract class S {
|
||||
/// **'Unknown Library'**
|
||||
String get unknownLibrary;
|
||||
|
||||
/// No description provided for @musicProviders.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Providers'**
|
||||
String get musicProviders;
|
||||
|
||||
/// No description provided for @musicProvidersDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which accounts to show in your library'**
|
||||
String get musicProvidersDescription;
|
||||
|
||||
/// No description provided for @libraryRefreshing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refreshing library...'**
|
||||
String get libraryRefreshing;
|
||||
|
||||
/// No description provided for @cannotDisableLastProvider.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot disable the last provider'**
|
||||
String get cannotDisableLastProvider;
|
||||
|
||||
/// No description provided for @noSeriesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1737,6 +1763,18 @@ abstract class S {
|
||||
/// **'Sort by status (playing, on, off) instead of alphabetically'**
|
||||
String get smartSortPlayersDescription;
|
||||
|
||||
/// Setting to disable automatic player switching
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable Auto-Switch'**
|
||||
String get disableAutoSwitch;
|
||||
|
||||
/// Description for disable auto-switch setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never automatically switch to a different player'**
|
||||
String get disableAutoSwitchDescription;
|
||||
|
||||
/// Player state when unavailable
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1797,6 +1835,24 @@ abstract class S {
|
||||
/// **'Display helpful tips for discovering features'**
|
||||
String get showHintsDescription;
|
||||
|
||||
/// Settings section title for display options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display'**
|
||||
String get display;
|
||||
|
||||
/// Toggle label for showing provider icons on album art
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Provider Icons'**
|
||||
String get showProviderIcons;
|
||||
|
||||
/// Description for show provider icons toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display provider logos on album art'**
|
||||
String get showProviderIconsDescription;
|
||||
|
||||
/// Hint shown when mini player bounces
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1892,6 +1948,240 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-press a podcast and tap the heart to add it to favorites'**
|
||||
String get longPressPodcastHint;
|
||||
|
||||
/// Error when playing radio station fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play radio station: {error}'**
|
||||
String failedToPlayRadioStation(String error);
|
||||
|
||||
/// Message when trying to add item already in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Item is already in library'**
|
||||
String get itemAlreadyInLibrary;
|
||||
|
||||
/// Error when adding to library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to add to library'**
|
||||
String get failedToAddToLibrary;
|
||||
|
||||
/// Error when library ID not found for removal
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot find library ID for removal'**
|
||||
String get cannotFindLibraryId;
|
||||
|
||||
/// Error when removing from library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to remove from library'**
|
||||
String get failedToRemoveFromLibrary;
|
||||
|
||||
/// Error when playing album fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play album: {error}'**
|
||||
String failedToPlayAlbum(String error);
|
||||
|
||||
/// Status when playing item on specific player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playing {name} on {playerName}'**
|
||||
String playingOnPlayer(String name, String playerName);
|
||||
|
||||
/// Error when playing playlist fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play playlist: {error}'**
|
||||
String failedToPlayPlaylist(String error);
|
||||
|
||||
/// Error when playing audiobook fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play audiobook: {error}'**
|
||||
String failedToPlayAudiobook(String error);
|
||||
|
||||
/// Message when no other players available for transfer
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No other players available'**
|
||||
String get noOtherPlayersAvailable;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue'**
|
||||
String get transferQueue;
|
||||
|
||||
/// Tooltip for clear queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear queue'**
|
||||
String get clearQueue;
|
||||
|
||||
/// Status when syncing player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Syncing {playerName}...'**
|
||||
String syncingPlayer(String playerName);
|
||||
|
||||
/// Label for switch player action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch Player'**
|
||||
String get switchPlayer;
|
||||
|
||||
/// Confirmation when logs copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Logs copied to clipboard'**
|
||||
String get logsCopiedToClipboard;
|
||||
|
||||
/// Auto option for language selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto'**
|
||||
String get auto;
|
||||
|
||||
/// Title for transfer queue dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer Queue To'**
|
||||
String get transferQueueTo;
|
||||
|
||||
/// Success message when queue transferred
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue transferred to {playerName}'**
|
||||
String queueTransferredTo(String playerName);
|
||||
|
||||
/// Error when queue transfer fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to transfer queue: {error}'**
|
||||
String failedToTransferQueue(String error);
|
||||
|
||||
/// Error when queue loading fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to load queue: {error}'**
|
||||
String failedToLoadQueue(String error);
|
||||
|
||||
/// Label for transfer destination selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer to...'**
|
||||
String get transferTo;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue to another player'**
|
||||
String get transferQueueToPlayer;
|
||||
|
||||
/// Generic hint for selecting a player in player selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a player'**
|
||||
String get selectPlayerToPlay;
|
||||
|
||||
/// Hint for selecting player to play album
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play album'**
|
||||
String get selectPlayerToPlayAlbum;
|
||||
|
||||
/// Hint for selecting player to play playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play playlist'**
|
||||
String get selectPlayerToPlayPlaylist;
|
||||
|
||||
/// Hint for selecting player to start radio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for radio'**
|
||||
String get selectPlayerForRadio;
|
||||
|
||||
/// Hint for selecting player to play episode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for episode'**
|
||||
String get selectPlayerForEpisode;
|
||||
|
||||
/// Hint for selecting player to play audiobook
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for audiobook'**
|
||||
String get selectPlayerForAudiobook;
|
||||
|
||||
/// Hint for selecting player to add to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
|
||||
/// Direct connection mode toggle label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server URL'**
|
||||
String get localConnection;
|
||||
|
||||
/// HA Remote Access connection mode toggle label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote Access ID'**
|
||||
String get remoteConnection;
|
||||
|
||||
/// Remote access ID field label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote Access ID'**
|
||||
String get remoteAccessId;
|
||||
|
||||
/// Hint for remote access ID field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find this in Music Assistant Settings → Remote Access'**
|
||||
String get remoteAccessIdHint;
|
||||
|
||||
/// Note about remote access availability
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For Home Assistant add-on users only'**
|
||||
String get remoteAccessNote;
|
||||
|
||||
/// Validation message for remote access ID
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter your Remote Access ID'**
|
||||
String get pleaseEnterRemoteId;
|
||||
|
||||
/// Error message when remote connection fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
|
||||
String get remoteConnectionFailed;
|
||||
|
||||
/// Title for QR code scanner screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan QR Code'**
|
||||
String get scanQrCode;
|
||||
|
||||
/// Instructions for scanning QR code
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
|
||||
String get pointCameraAtQr;
|
||||
|
||||
/// Error message when camera permission is denied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Camera permission is required to scan QR codes'**
|
||||
String get cameraPermissionDenied;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
@@ -1903,27 +2193,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_SDelegate old) => false;
|
||||
}
|
||||
|
||||
S lookupS(Locale locale) {
|
||||
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'de': return SDe();
|
||||
case 'en': return SEn();
|
||||
case 'es': return SEs();
|
||||
case 'fr': return SFr();
|
||||
case 'de':
|
||||
return SDe();
|
||||
case 'en':
|
||||
return SEn();
|
||||
case 'es':
|
||||
return SEs();
|
||||
case 'fr':
|
||||
return SFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.'
|
||||
);
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SDe extends S {
|
||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
String get portDescription =>
|
||||
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
|
||||
@override
|
||||
String get username => 'Benutzername';
|
||||
@@ -60,10 +61,12 @@ class SDe extends S {
|
||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
String get authServerUrlHint =>
|
||||
'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,49 @@ class SDe extends S {
|
||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
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';
|
||||
String get httpNotEncrypted =>
|
||||
'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
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)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Bitte gib Benutzername und Passwort ein';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get authFailed =>
|
||||
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
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.';
|
||||
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.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Keine Authentifizierung';
|
||||
@@ -221,10 +233,12 @@ class SDe extends S {
|
||||
String get noRadioStations => 'Keine Radiosender';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||
String get addRadioStationsHint =>
|
||||
'Füge Radiosender in Music Assistant hinzu';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
String get searchFailed =>
|
||||
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
|
||||
@override
|
||||
String get queue => 'Warteschlange';
|
||||
@@ -471,37 +485,43 @@ class SDe extends S {
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
@@ -513,7 +533,8 @@ class SDe extends S {
|
||||
String get notConnectedTitle => 'Nicht verbunden';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
String get connectHint =>
|
||||
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Server konfigurieren';
|
||||
@@ -549,7 +570,8 @@ class SDe extends S {
|
||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
String get noDebugLogsYet =>
|
||||
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Debug anzeigen';
|
||||
@@ -649,10 +671,12 @@ class SDe extends S {
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
String get showRandomAudiobooks =>
|
||||
'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
String get showRandomSeries =>
|
||||
'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||
@@ -667,16 +691,19 @@ class SDe extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
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';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Farben aus Album- und Künstlercover extrahieren';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
@@ -785,17 +812,32 @@ class SDe extends S {
|
||||
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
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';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Keine Serien gefunden';
|
||||
|
||||
@@ -808,16 +850,19 @@ class SDe extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
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';
|
||||
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.';
|
||||
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';
|
||||
@@ -889,13 +934,22 @@ class SDe extends S {
|
||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
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';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||
@@ -927,7 +981,18 @@ class SDe extends S {
|
||||
String get showHints => 'Hinweise anzeigen';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
String get showHintsDescription =>
|
||||
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||
@@ -945,13 +1010,15 @@ class SDe extends S {
|
||||
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.';
|
||||
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';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1034,159 @@ class SDe extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
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';
|
||||
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';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEn extends S {
|
||||
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.';
|
||||
String get portDescription =>
|
||||
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
@@ -60,10 +61,12 @@ class SEn extends S {
|
||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||
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';
|
||||
String get authServerUrlDescription =>
|
||||
'Leave empty if authentication is on the same server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,7 +83,8 @@ class SEn extends S {
|
||||
String get unencryptedConnection => 'Unencrypted Connection';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||
@@ -89,31 +93,37 @@ class SEn extends S {
|
||||
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||
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)';
|
||||
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.';
|
||||
String get authFailed =>
|
||||
'Authentication failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||
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.';
|
||||
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.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Cannot determine authentication requirements. Please check server URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'No Authentication';
|
||||
@@ -471,37 +481,43 @@ class SEn extends S {
|
||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
String get longPressTrackHint =>
|
||||
'Long-press a track and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Load Series';
|
||||
@@ -513,7 +529,8 @@ class SEn extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||
String get connectHint =>
|
||||
'Connect to your Music Assistant server to start listening';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configure Server';
|
||||
@@ -667,16 +684,19 @@ class SEn extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
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';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extract colors from album and artist artwork';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
@@ -791,11 +811,25 @@ class SEn extends S {
|
||||
String get audiobookLibraries => 'Audiobook Libraries';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choose which Audiobookshelf libraries to include';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unknown Library';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No Series Found';
|
||||
|
||||
@@ -808,16 +842,19 @@ class SEn extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||
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';
|
||||
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.';
|
||||
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';
|
||||
@@ -889,13 +926,22 @@ class SEn extends S {
|
||||
String get preferLocalPlayer => 'Prefer Local Player';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||
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';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Sort by status (playing, on, off) instead of alphabetically';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Unavailable';
|
||||
@@ -927,7 +973,18 @@ class SEn extends S {
|
||||
String get showHints => 'Show Hints';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||
String get showHintsDescription =>
|
||||
'Display helpful tips for discovering features';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Pull to select players';
|
||||
@@ -945,13 +1002,15 @@ class SEn extends S {
|
||||
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.';
|
||||
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';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1026,159 @@ class SEn extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
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';
|
||||
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';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEs extends S {
|
||||
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.';
|
||||
String get portDescription =>
|
||||
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
|
||||
@override
|
||||
String get username => 'Usuario';
|
||||
@@ -57,13 +58,16 @@ class SEs extends S {
|
||||
String get disconnect => 'Desconectar';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||
String get authServerUrlOptional =>
|
||||
'URL del servidor de autenticación (Opcional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||
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';
|
||||
String get authServerUrlDescription =>
|
||||
'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +84,49 @@ class SEs extends S {
|
||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||
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';
|
||||
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)';
|
||||
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';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Por favor, introduce usuario y contraseña';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
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.';
|
||||
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.';
|
||||
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.';
|
||||
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';
|
||||
@@ -221,10 +234,12 @@ class SEs extends S {
|
||||
String get noRadioStations => 'No hay emisoras de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Añade emisoras de radio en Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
String get searchFailed =>
|
||||
'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
|
||||
@override
|
||||
String get queue => 'Cola';
|
||||
@@ -264,7 +279,8 @@ class SEs extends S {
|
||||
String get system => 'Sistema';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
String get pullToRefresh =>
|
||||
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Ver registros de depuración';
|
||||
@@ -301,7 +317,8 @@ class SEs extends S {
|
||||
String get logsCleared => 'Registros borrados';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||
String get playerListCopied =>
|
||||
'Lista de reproductores copiada al portapapeles';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Aún no hay registros';
|
||||
@@ -471,37 +488,43 @@ class SEs extends S {
|
||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
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í';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
@@ -513,7 +536,8 @@ class SEs extends S {
|
||||
String get notConnectedTitle => 'No conectado';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
String get connectHint =>
|
||||
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurar servidor';
|
||||
@@ -549,7 +573,8 @@ class SEs extends S {
|
||||
String get noLogsToCopy => 'No hay registros para copiar';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
String get noDebugLogsYet =>
|
||||
'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Mostrar depuración';
|
||||
@@ -564,7 +589,8 @@ class SEs extends S {
|
||||
String get noChapters => 'Sin capítulos';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||
String get noChapterInfo =>
|
||||
'Este audiolibro no tiene información de capítulos';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -637,7 +663,8 @@ class SEs extends S {
|
||||
String get artist => 'Artista';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Mostrar álbumes reproducidos recientemente';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||
@@ -649,34 +676,41 @@ class SEs extends S {
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Mostrar audiolibros aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
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';
|
||||
String get showFavoriteArtists =>
|
||||
'Mostrar una fila de tus artistas favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
@@ -774,7 +808,8 @@ class SEs extends S {
|
||||
String get loadingChapters => 'Cargando capítulos...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'No hay información de capítulos disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,17 +820,32 @@ class SEs extends S {
|
||||
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
||||
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';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Biblioteca desconocida';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No se encontraron series';
|
||||
|
||||
@@ -808,16 +858,19 @@ class SEs extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
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';
|
||||
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.';
|
||||
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';
|
||||
@@ -889,13 +942,22 @@ class SEs extends S {
|
||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
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';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'No disponible';
|
||||
@@ -927,7 +989,18 @@ class SEs extends S {
|
||||
String get showHints => 'Mostrar Sugerencias';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||
String get showHintsDescription =>
|
||||
'Mostrar consejos útiles para descubrir funciones';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||
@@ -945,13 +1018,15 @@ class SEs extends S {
|
||||
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.';
|
||||
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';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1042,159 @@ class SEs extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
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';
|
||||
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';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SFr extends S {
|
||||
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.';
|
||||
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';
|
||||
@@ -60,10 +61,12 @@ class SFr extends S {
|
||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||
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';
|
||||
String get authServerUrlDescription =>
|
||||
'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,50 @@ class SFr extends S {
|
||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
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';
|
||||
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)';
|
||||
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';
|
||||
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.';
|
||||
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.';
|
||||
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.';
|
||||
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.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Pas d\'authentification';
|
||||
@@ -128,7 +141,8 @@ class SFr extends S {
|
||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||
String get pressBackToMinimize =>
|
||||
'Appuyez a nouveau sur retour pour minimiser';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Ecoutes recemment';
|
||||
@@ -221,10 +235,12 @@ class SFr extends S {
|
||||
String get noRadioStations => 'Aucune station de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Ajouter des stations de radio dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
String get searchFailed =>
|
||||
'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
|
||||
@override
|
||||
String get queue => 'File d\'attente';
|
||||
@@ -264,7 +280,8 @@ class SFr extends S {
|
||||
String get system => 'Système';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
String get pullToRefresh =>
|
||||
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||
@@ -301,7 +318,8 @@ class SFr extends S {
|
||||
String get logsCleared => 'Journaux effaces';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
String get playerListCopied =>
|
||||
'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Pas encore de journaux';
|
||||
@@ -471,37 +489,43 @@ class SFr extends S {
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
@@ -513,7 +537,8 @@ class SFr extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
String get connectHint =>
|
||||
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurer le serveur';
|
||||
@@ -549,7 +574,8 @@ class SFr extends S {
|
||||
String get noLogsToCopy => 'Aucun journal a copier';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
String get noDebugLogsYet =>
|
||||
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Afficher le debogage';
|
||||
@@ -564,7 +590,8 @@ class SFr extends S {
|
||||
String get noChapters => 'Aucun chapitre';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
String get noChapterInfo =>
|
||||
'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -637,10 +664,12 @@ class SFr extends S {
|
||||
String get artist => 'Artiste';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Afficher les albums écoutes recemment';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||
String get showRandomArtists =>
|
||||
'Afficher des artistes aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
@@ -649,34 +678,43 @@ class SFr extends S {
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||
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';
|
||||
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';
|
||||
String get showFavoriteArtists =>
|
||||
'Afficher une rangee de vos artistes favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||
String get showFavoriteTracks =>
|
||||
'Afficher une rangee de vos pistes favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||
String get showFavoritePlaylists =>
|
||||
'Afficher une rangee de vos playlists favorites';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Afficher une rangee de vos stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||
String get showFavoritePodcasts =>
|
||||
'Afficher une rangee de vos podcasts favoris';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
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';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
@@ -721,13 +759,15 @@ class SFr extends S {
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||
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';
|
||||
String get addPodcastsHint =>
|
||||
'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
@@ -774,7 +814,8 @@ class SFr extends S {
|
||||
String get loadingChapters => 'Chargement des chapitres...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'Aucune information de chapitre disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,17 +826,32 @@ class SFr extends S {
|
||||
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
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';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Aucune série trouvee';
|
||||
|
||||
@@ -808,16 +864,19 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
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';
|
||||
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.';
|
||||
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';
|
||||
@@ -840,7 +899,8 @@ class SFr extends S {
|
||||
String get playOn => 'Lire sur...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||
String get addAlbumToQueueOn =>
|
||||
'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
@@ -877,7 +937,8 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||
String get materialYouDescription =>
|
||||
'Utiliser les couleurs systeme (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Couleur d\'accentuation';
|
||||
@@ -889,13 +950,22 @@ class SFr extends S {
|
||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||
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';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Indisponible';
|
||||
@@ -927,7 +997,18 @@ class SFr extends S {
|
||||
String get showHints => 'Afficher les astuces';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
String get showHintsDescription =>
|
||||
'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||
@@ -939,19 +1020,22 @@ class SFr extends S {
|
||||
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
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.';
|
||||
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';
|
||||
String get dismissPlayerHint =>
|
||||
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1051,159 @@ class SFr extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
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';
|
||||
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';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
}
|
||||
|
||||
@@ -223,8 +223,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
// No haptic - Android back gesture provides its own
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
@@ -329,7 +330,31 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Also include hidden WebView for remote WebRTC bridge
|
||||
final musicProvider = context.read<MusicAssistantProvider>();
|
||||
return Stack(
|
||||
children: [
|
||||
GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
|
||||
// WebView for WebRTC - must be VISIBLE to avoid Android throttling
|
||||
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: musicProvider.remoteBridgeWebView,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,10 @@ class ProviderMapping {
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
/// Whether this item is in the library for this specific provider instance.
|
||||
/// True = this provider "owns" the item (user added it from this account)
|
||||
/// False = item exists but wasn't added from this provider
|
||||
final bool inLibrary;
|
||||
|
||||
ProviderMapping({
|
||||
required this.itemId,
|
||||
@@ -23,15 +27,24 @@ class ProviderMapping {
|
||||
required this.providerInstance,
|
||||
required this.available,
|
||||
this.audioFormat,
|
||||
this.inLibrary = true,
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
// Default to true if not present (items from library_items API are in library)
|
||||
final inLibraryValue = json['in_library'];
|
||||
final inLibrary = inLibraryValue == null
|
||||
? true // Default: items fetched from library are in library
|
||||
: inLibraryValue == true || inLibraryValue == 1;
|
||||
|
||||
return ProviderMapping(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
providerDomain: json['provider_domain'] as String? ?? '',
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
inLibrary: inLibrary,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +55,7 @@ class ProviderMapping {
|
||||
'provider_instance': providerInstance,
|
||||
'available': available,
|
||||
if (audioFormat != null) 'audio_format': audioFormat,
|
||||
'in_library': inLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -118,6 +132,17 @@ class MediaItem {
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MediaItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
provider == other.provider;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, provider);
|
||||
}
|
||||
|
||||
class Artist extends MediaItem {
|
||||
@@ -421,9 +446,20 @@ class Audiobook extends MediaItem {
|
||||
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||
final item = MediaItem.fromJson(json);
|
||||
|
||||
// Parse year
|
||||
// Parse year - check top level first, then metadata.release_date (ABS format)
|
||||
int? year;
|
||||
final yearValue = json['year'];
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
dynamic yearValue = json['year'];
|
||||
if (yearValue == null && metadata != null) {
|
||||
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
|
||||
final releaseDate = metadata['release_date'];
|
||||
if (releaseDate is String && releaseDate.isNotEmpty) {
|
||||
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
|
||||
yearValue = releaseDate.split('-').first;
|
||||
} else if (releaseDate is int) {
|
||||
yearValue = releaseDate;
|
||||
}
|
||||
}
|
||||
if (yearValue is int) {
|
||||
year = yearValue;
|
||||
} else if (yearValue is String) {
|
||||
@@ -453,8 +489,6 @@ class Audiobook extends MediaItem {
|
||||
|
||||
// Parse chapters - check top level first, then metadata
|
||||
List<Chapter>? chapters;
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
|
||||
dynamic chaptersData = json['chapters'];
|
||||
if (chaptersData == null && metadata != null) {
|
||||
chaptersData = metadata['chapters'];
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Represents a music provider instance from Music Assistant.
|
||||
///
|
||||
/// Each provider (like Spotify, Tidal) can have multiple instances
|
||||
/// (e.g., two different Spotify accounts). This class represents
|
||||
/// a single provider instance with its unique ID.
|
||||
class ProviderInstance {
|
||||
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
|
||||
final String instanceId;
|
||||
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this instance (e.g., "Spotify (John's Account)")
|
||||
final String name;
|
||||
|
||||
/// Whether this provider is currently available/connected
|
||||
final bool available;
|
||||
|
||||
ProviderInstance({
|
||||
required this.instanceId,
|
||||
required this.domain,
|
||||
required this.name,
|
||||
required this.available,
|
||||
});
|
||||
|
||||
/// Provider capabilities - maps domain to supported content types
|
||||
/// This determines which tabs/categories show which providers
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services (support artists, albums, tracks, playlists)
|
||||
// Spotify also has audiobooks and podcasts
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Self-hosted media servers (can have music, audiobooks, and podcasts)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Audiobook and podcast providers
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'},
|
||||
// Radio providers (tunein also has podcasts)
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
'radiobrowser': {'radio'},
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
|
||||
/// Known music provider domains that provide library content
|
||||
static const Set<String> musicProviderDomains = {
|
||||
// Streaming services
|
||||
'spotify',
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'soundcloud',
|
||||
'apple_music',
|
||||
'amazon_music',
|
||||
// Self-hosted / local
|
||||
'plex',
|
||||
'subsonic',
|
||||
'opensubsonic',
|
||||
'jellyfin',
|
||||
'emby',
|
||||
'navidrome',
|
||||
'audiobookshelf',
|
||||
'filesystem',
|
||||
'itunes_podcasts',
|
||||
// Other
|
||||
'tunein',
|
||||
'radiobrowser',
|
||||
'snapcast',
|
||||
'fully_kiosk',
|
||||
};
|
||||
|
||||
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
|
||||
/// Get supported content types for this provider
|
||||
Set<String> get supportedContentTypes =>
|
||||
providerCapabilities[domain] ?? <String>{};
|
||||
|
||||
/// Check if this provider supports a specific content type/category
|
||||
bool supportsContentType(String category) =>
|
||||
supportedContentTypes.contains(category);
|
||||
|
||||
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderInstance(
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
|
||||
available: json['available'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'instance_id': instanceId,
|
||||
'domain': domain,
|
||||
'name': name,
|
||||
'available': available,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderInstance($instanceId: $name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProviderInstance &&
|
||||
runtimeType == other.runtimeType &&
|
||||
instanceId == other.instanceId;
|
||||
|
||||
@override
|
||||
int get hashCode => instanceId.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Represents a provider manifest from Music Assistant.
|
||||
///
|
||||
/// Contains metadata about a provider type including its icons.
|
||||
/// This is separate from ProviderInstance which represents a configured
|
||||
/// instance of a provider.
|
||||
class ProviderManifest {
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this provider type
|
||||
final String name;
|
||||
|
||||
/// Description of the provider
|
||||
final String? description;
|
||||
|
||||
/// SVG icon as XML string
|
||||
final String? iconSvg;
|
||||
|
||||
/// SVG icon for dark theme as XML string
|
||||
final String? iconSvgDark;
|
||||
|
||||
/// Monochrome SVG icon as XML string
|
||||
final String? iconSvgMonochrome;
|
||||
|
||||
/// Material Design icon name (fallback)
|
||||
final String? icon;
|
||||
|
||||
ProviderManifest({
|
||||
required this.domain,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.iconSvg,
|
||||
this.iconSvgDark,
|
||||
this.iconSvgMonochrome,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// Whether this manifest has any SVG icon available
|
||||
bool get hasSvgIcon =>
|
||||
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
|
||||
|
||||
/// Get the best available SVG icon for the given brightness
|
||||
/// Prefers dark icon for dark themes, falls back to regular or monochrome
|
||||
String? getSvgIcon({bool isDark = false}) {
|
||||
if (isDark && iconSvgDark != null) {
|
||||
return iconSvgDark;
|
||||
}
|
||||
return iconSvg ?? iconSvgMonochrome;
|
||||
}
|
||||
|
||||
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderManifest(
|
||||
domain: json['domain'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
iconSvg: json['icon_svg'] as String?,
|
||||
iconSvgDark: json['icon_svg_dark'] as String?,
|
||||
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderManifest($domain: $name)';
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
|
||||
/// Callback types for connection events
|
||||
typedef OnConnected = Future<void> Function();
|
||||
typedef OnAuthenticated = Future<void> Function();
|
||||
typedef OnDisconnected = void Function();
|
||||
|
||||
/// Provider for connection state management.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to handle:
|
||||
/// - WebSocket connection lifecycle
|
||||
/// - Authentication flow (MA token, credentials)
|
||||
/// - Reconnection logic
|
||||
/// - Connection state broadcasting
|
||||
///
|
||||
/// Note: This is a partial extraction. Full integration requires updating
|
||||
/// all consumers to use this provider directly.
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final AuthManager _authManager = AuthManager();
|
||||
|
||||
MusicAssistantAPI? _api;
|
||||
String? _serverUrl;
|
||||
String? _error;
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
|
||||
StreamSubscription? _connectionStateSubscription;
|
||||
|
||||
// Callbacks for connection events - set by the facade provider
|
||||
OnConnected? onConnected;
|
||||
OnAuthenticated? onAuthenticated;
|
||||
OnDisconnected? onDisconnected;
|
||||
|
||||
// User settings captured during authentication
|
||||
List<String> _providerFilter = [];
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
|
||||
bool get isConnected =>
|
||||
_connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
// ============================================================================
|
||||
// CONNECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Connect to the Music Assistant server
|
||||
Future<void> connect(String serverUrl) async {
|
||||
try {
|
||||
_error = null;
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// Dispose the old API to stop any pending reconnects
|
||||
_api?.dispose();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = _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 _handleAuthentication();
|
||||
if (!authenticated) {
|
||||
_error = 'Authentication required. Please log in again.';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth required, trigger callback
|
||||
await onConnected?.call();
|
||||
} else if (state == MAConnectionState.authenticated) {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
await onAuthenticated?.call();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
_logger.log('📡 Disconnected - keeping cached data for instant resume');
|
||||
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<bool> _handleAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
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 _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
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 _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchUserSettings() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) return;
|
||||
|
||||
// Set profile name
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
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');
|
||||
}
|
||||
|
||||
// Capture provider filter
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
}
|
||||
|
||||
// Capture player filter
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
if (_playerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Player filter active: ${_playerFilter.length} players allowed');
|
||||
}
|
||||
} else {
|
||||
_playerFilter = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the server
|
||||
Future<void> disconnect() async {
|
||||
_connectionStateSubscription?.cancel();
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check connection and reconnect if needed
|
||||
Future<void> checkAndReconnect() async {
|
||||
if (_serverUrl != null && !isConnected) {
|
||||
_logger.log('🔄 Attempting reconnection...');
|
||||
await connect(_serverUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription?.cancel();
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
/// Callback type for when sleep timer expires and should pause playback
|
||||
typedef OnSleepTimerExpired = void Function();
|
||||
|
||||
/// Provider for sleep timer functionality.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
|
||||
/// This provider manages:
|
||||
/// - Timed sleep (pause after X minutes)
|
||||
/// - End of track sleep (pause when current track ends)
|
||||
class SleepTimerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
/// Whether a sleep timer is currently active
|
||||
bool get isActive => _sleepTimerMinutes != null;
|
||||
|
||||
/// Current timer setting: null = off, -1 = end of track, positive = minutes
|
||||
int? get minutes => _sleepTimerMinutes;
|
||||
|
||||
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
|
||||
Duration? get remaining {
|
||||
if (_sleepTimerEndTime == null) return null;
|
||||
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMER CONTROL
|
||||
// ============================================================================
|
||||
|
||||
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
|
||||
void setTimer(int? minutes) {
|
||||
// Cancel any existing timer
|
||||
_cancelInternal();
|
||||
|
||||
_sleepTimerMinutes = minutes;
|
||||
|
||||
if (minutes == null) {
|
||||
// Timer off
|
||||
_logger.log('😴 Sleep timer: OFF');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes == -1) {
|
||||
// End of track - handled in player state updates
|
||||
_sleepTimerEndTime = null; // No fixed end time
|
||||
_logger.log('😴 Sleep timer: End of track');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timer for specified minutes
|
||||
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
|
||||
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
|
||||
|
||||
// Create the actual timer
|
||||
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel sleep timer
|
||||
void cancel() {
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
_logger.log('😴 Sleep timer: Cancelled');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelInternal() {
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimer = null;
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_sleepTimerDisplayTimer = null;
|
||||
}
|
||||
|
||||
void _onTimerExpired() {
|
||||
_logger.log('😴 Sleep timer expired - pausing playback');
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called when track ends - checks if "end of track" sleep timer is active
|
||||
void checkEndOfTrack() {
|
||||
if (_sleepTimerMinutes == -1) {
|
||||
_logger.log('😴 End of track sleep timer triggered');
|
||||
_sleepTimerMinutes = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
@@ -223,7 +223,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -254,12 +253,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add album to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -280,13 +302,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -294,13 +310,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove album from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling album library: $e');
|
||||
@@ -673,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
void _navigateToArtist() {
|
||||
// Navigate to the first artist if available
|
||||
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||
final artist = widget.album.artists!.first;
|
||||
if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
|
||||
final artist = _displayAlbum.artists!.first;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||
Navigator.push(
|
||||
@@ -850,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.album.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -914,7 +956,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
_displayAlbum.artistsString,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
@@ -1229,13 +1271,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(
|
||||
@@ -1244,48 +1281,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
startIndex: startIndex,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
@@ -308,7 +309,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -339,12 +339,36 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add artist to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -365,13 +389,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -379,13 +397,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove artist from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling artist library: $e');
|
||||
@@ -402,171 +433,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
void _showRadioMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.startRadioOn(widget.artist.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddToQueueMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToAddToQueue,
|
||||
onPlayerSelected: (player) async {
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadArtistImage() async {
|
||||
@@ -1072,37 +1005,46 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0, // Square album art
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: album.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Spacing.vGap8,
|
||||
|
||||
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final _logger = DebugLogger();
|
||||
late List<Audiobook> _audiobooks;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
@@ -407,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -212,13 +212,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.setCurrentAudiobook(_audiobook);
|
||||
@@ -227,9 +222,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
widget.audiobook,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsFinished() async {
|
||||
@@ -505,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.audiobook.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
// Progress indicator
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
size: 16,
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||
|
||||
void _copyLogs() {
|
||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||
// Copy only the filtered entries that are currently displayed
|
||||
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: filteredLogs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.logsCopied),
|
||||
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
report,
|
||||
subject: S.of(context)!.ensembleBugReport,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.error('Share failed', error: e);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isGeneratingReport = false);
|
||||
|
||||
@@ -5,10 +5,12 @@ import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/auth/auth_strategy.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/debug/debug_console.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'qr_scanner_screen.dart';
|
||||
|
||||
enum ConnectionMode { local, remote }
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@@ -24,6 +26,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
|
||||
final FocusNode _usernameFocusNode = FocusNode();
|
||||
|
||||
bool _isConnecting = false;
|
||||
@@ -36,6 +39,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
|
||||
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||
|
||||
// Remote connection state
|
||||
ConnectionMode _connectionMode = ConnectionMode.local;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -74,7 +80,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_authServerUrlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_remoteIdController.dispose();
|
||||
_usernameFocusNode.dispose();
|
||||
// Note: Don't stop _remoteBridge here - it needs to stay alive for the session
|
||||
// It will be stopped when the app closes or user disconnects
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -443,6 +452,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
if (provider.isConnected) {
|
||||
// Clear remote access ID since this is a direct/local connection
|
||||
await SettingsService.clearRemoteAccessId();
|
||||
|
||||
// For first-time users, wait for player selection so the welcome
|
||||
// overlay can appear immediately without home screen flash.
|
||||
// This matches the logic in AppStartup._checkAndConnect().
|
||||
@@ -477,6 +489,104 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect via remote bridge (WebRTC tunnel)
|
||||
Future<void> _connectRemote() async {
|
||||
final remoteId = _remoteIdController.text.trim();
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
if (remoteId.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterRemoteId;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterCredentials;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
_addDebugLog('Starting remote connection to: $remoteId');
|
||||
|
||||
try {
|
||||
// Start the remote bridge via provider (WebView is already in widget tree)
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final port = await provider.startRemoteBridge(remoteId, username, password);
|
||||
|
||||
if (port == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.remoteConnectionFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_addDebugLog('Remote bridge started on port $port');
|
||||
|
||||
// Save credentials BEFORE connecting - needed for MA authentication via bridge
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// Connect to the local bridge endpoint
|
||||
final serverUrl = 'ws://localhost:$port';
|
||||
|
||||
_addDebugLog('Connecting to bridge at $serverUrl');
|
||||
// Pass remoteId as cloud URL for Sendspin to connect directly
|
||||
await provider.connectToServer(serverUrl, isRemoteMode: true, remoteCloudUrl: remoteId);
|
||||
|
||||
// Wait for connection
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (provider.isConnected) break;
|
||||
}
|
||||
|
||||
if (provider.isConnected) {
|
||||
_addDebugLog('Connected successfully via remote bridge');
|
||||
|
||||
// Save remote access ID and credentials for future auto-reconnect
|
||||
await SettingsService.setRemoteAccessId(remoteId);
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// For first-time users, wait for player selection
|
||||
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
if (!hasCompletedOnboarding) {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.selectedPlayer != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_error = S.of(context)!.connectionFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_addDebugLog('Remote connection error: $e');
|
||||
setState(() {
|
||||
_error = 'Remote connection failed: ${e.toString()}';
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -509,9 +619,260 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Server URL
|
||||
// Connection Mode Toggle
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _isConnecting ? null : () {
|
||||
setState(() {
|
||||
_connectionMode = ConnectionMode.local;
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_rounded,
|
||||
size: 20,
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.localConnection,
|
||||
style: TextStyle(
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontWeight: _connectionMode == ConnectionMode.local
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _isConnecting ? null : () {
|
||||
setState(() {
|
||||
_connectionMode = ConnectionMode.remote;
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_rounded,
|
||||
size: 20,
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.remoteConnection,
|
||||
style: TextStyle(
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontWeight: _connectionMode == ConnectionMode.remote
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// REMOTE MODE: Remote Access ID + Credentials
|
||||
if (_connectionMode == ConnectionMode.remote) ...[
|
||||
Text(
|
||||
S.of(context)!.remoteAccessId,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.remoteAccessIdHint,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _remoteIdController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ABCD1234-EFGH5-6IJKL-MNOP7890',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.vpn_key_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.qr_code_scanner_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
onPressed: _isConnecting ? null : () async {
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QrScannerScreen(),
|
||||
),
|
||||
);
|
||||
if (result != null && result.isNotEmpty) {
|
||||
_remoteIdController.text = result;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Username for remote
|
||||
Text(
|
||||
S.of(context)!.username,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
focusNode: _connectionMode == ConnectionMode.remote ? _usernameFocusNode : null,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.username,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.person_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password for remote
|
||||
Text(
|
||||
S.of(context)!.password,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.password,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _connectRemote(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// LOCAL MODE: Server URL, Port, etc.
|
||||
if (_connectionMode == ConnectionMode.local) ...[
|
||||
Text(
|
||||
S.of(context)!.serverAddress,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
@@ -856,6 +1217,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
], // End of local mode conditional
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -902,7 +1264,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: (_isConnecting || _isDetectingAuth)
|
||||
? null
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
: _connectionMode == ConnectionMode.remote
|
||||
? _connectRemote
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
@@ -944,7 +1308,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
|
||||
_connectionMode == ConnectionMode.remote
|
||||
? S.of(context)!.connect
|
||||
: (_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -226,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
// Copy logs button for debugging
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 20),
|
||||
onPressed: () {
|
||||
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${DebugLogger().entries.length} log entries copied'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Copy logs',
|
||||
),
|
||||
// Sync indicator - shows when library is syncing in background
|
||||
ListenableBuilder(
|
||||
listenable: SyncService.instance,
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
@@ -378,20 +378,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _addPlaylistToQueue() {
|
||||
@@ -552,20 +545,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
@@ -706,30 +692,44 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.playlist.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.playlist.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -66,7 +66,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -97,12 +96,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'podcast',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add podcast to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -123,13 +145,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -137,13 +153,25 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove podcast from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling podcast library: $e');
|
||||
@@ -190,10 +218,38 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
List<MediaItem> episodes = [];
|
||||
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
try {
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Primary episode load failed: $e');
|
||||
}
|
||||
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||
@@ -252,22 +308,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForEpisode,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
// Set podcast context before playing so player UI shows correct podcast name
|
||||
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
@@ -430,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// A screen for scanning QR codes to get the Remote Access ID.
|
||||
class QrScannerScreen extends StatefulWidget {
|
||||
const QrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScannerScreen> createState() => _QrScannerScreenState();
|
||||
}
|
||||
|
||||
class _QrScannerScreenState extends State<QrScannerScreen> {
|
||||
late final MobileScannerController _controller;
|
||||
bool _hasScanned = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
facing: CameraFacing.back,
|
||||
formats: [BarcodeFormat.qrCode], // Only scan QR codes
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_hasScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
final String? code = barcode.rawValue;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
final remoteId = _extractRemoteId(code);
|
||||
|
||||
if (remoteId != null && remoteId.isNotEmpty) {
|
||||
_hasScanned = true;
|
||||
Navigator.of(context).pop(remoteId);
|
||||
return;
|
||||
} else {
|
||||
// Show what was scanned for debugging
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Scanned: $code'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the remote ID from various QR code formats
|
||||
String? _extractRemoteId(String code) {
|
||||
// Format 1: Raw remote ID (alphanumeric)
|
||||
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Format 2: ma://remote/<id>
|
||||
if (code.startsWith('ma://remote/')) {
|
||||
return code.replaceFirst('ma://remote/', '');
|
||||
}
|
||||
|
||||
// Format 3: URL from app.music-assistant.io
|
||||
// Examples:
|
||||
// - https://app.music-assistant.io/?cloudInstanceId=<id>
|
||||
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
|
||||
if (code.contains('music-assistant.io')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check query parameters in main URL
|
||||
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
|
||||
for (final param in queryParams) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
|
||||
final fragment = uri.fragment;
|
||||
if (fragment.contains('?')) {
|
||||
final fragmentQuery = fragment.split('?').last;
|
||||
final fragmentParams = Uri.splitQueryString(fragmentQuery);
|
||||
for (final param in queryParams) {
|
||||
final value = fragmentParams[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ID is in path segments
|
||||
for (final segment in uri.pathSegments) {
|
||||
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format 4: Other URL formats - try to extract from query params or path
|
||||
if (code.contains('://')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check common query param names
|
||||
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
// Last path segment if it looks like an ID
|
||||
if (uri.pathSegments.isNotEmpty) {
|
||||
final last = uri.pathSegments.last;
|
||||
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(S.of(context)!.scanQrCode),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
// Toggle torch
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, child) {
|
||||
return Icon(
|
||||
state.torchState == TorchState.on
|
||||
? Icons.flash_on
|
||||
: Icons.flash_off,
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera preview
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Camera error: ${error.errorCode.name}',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
|
||||
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Scan overlay with instruction
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Scan frame
|
||||
Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Instruction text
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.pointCameraAtQr,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (_statusMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +85,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
|
||||
if (targetPlayers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No other players available to transfer to'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.noOtherPlayersAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -96,7 +96,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
// Show player picker sheet
|
||||
await showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Transfer Queue To',
|
||||
title: S.of(context)!.transferQueueTo,
|
||||
players: targetPlayers,
|
||||
onPlayerSelected: (targetPlayer) async {
|
||||
try {
|
||||
@@ -109,7 +109,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Queue transferred to ${targetPlayer.name}'),
|
||||
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -119,7 +119,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to transfer queue: $e'),
|
||||
content: Text(S.of(context)!.failedToTransferQueue(e.toString())),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -152,7 +152,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz_rounded),
|
||||
tooltip: 'Transfer queue',
|
||||
tooltip: S.of(context)!.transferQueue,
|
||||
onPressed: () => _handleTransferQueue(maProvider),
|
||||
),
|
||||
IconButton(
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import '../widgets/artist_avatar.dart';
|
||||
@@ -169,6 +168,27 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return filters;
|
||||
}
|
||||
|
||||
/// Filter search results by enabled providers (only when in-library mode is on)
|
||||
/// Uses the client-side provider toggles from the library screen
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results; // No filter = show all
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true; // No mappings = show
|
||||
// Item is allowed if it has inLibrary=true for any enabled provider
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Animate to a specific filter
|
||||
void _animateToFilter(String filter) {
|
||||
final filters = _getAvailableFilters();
|
||||
@@ -428,12 +448,21 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
@@ -2385,7 +2414,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play radio station: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2443,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showRadioOnMenu(Track track) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -2470,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Add track to queue on current player (1 tap)
|
||||
@@ -2582,7 +2604,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2633,7 +2655,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// Item is library-only, can't add
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Item is already in library')),
|
||||
SnackBar(content: Text(S.of(context)!.itemAlreadyInLibrary)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -2669,7 +2691,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to add to library')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToAddToLibrary)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2696,7 +2718,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
if (libraryItemId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot find library ID for removal')),
|
||||
SnackBar(content: Text(S.of(context)!.cannotFindLibraryId)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2728,7 +2750,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to remove from library')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToRemoveFromLibrary)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2872,7 +2894,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling artist favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2903,7 +2925,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play album: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2913,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showAlbumPlayOnMenu(Album album) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -2927,7 +2944,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${album.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(album.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -2935,14 +2952,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play album: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Add album to queue
|
||||
@@ -3051,7 +3066,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling album favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3082,7 +3097,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play playlist: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3092,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showPlaylistPlayOnMenu(Playlist playlist) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3106,7 +3116,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${playlist.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(playlist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3114,14 +3124,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play playlist: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Play audiobook on current player
|
||||
@@ -3141,7 +3149,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${audiobook.name}'),
|
||||
content: Text(S.of(context)!.playing(audiobook.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3149,7 +3157,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play audiobook: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3159,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showAudiobookPlayOnMenu(Audiobook audiobook) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3173,7 +3176,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${audiobook.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(audiobook.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3181,14 +3184,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play audiobook: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggle audiobook favorite status
|
||||
@@ -3266,7 +3267,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3276,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showRadioPlayOnMenu(MediaItem radio) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3290,7 +3286,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${radio.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(radio.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3298,14 +3294,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play radio station: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/// Play podcast (navigate to detail screen where episodes can be played)
|
||||
@@ -3333,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _showPodcastPlayOnMenu(MediaItem podcast) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlay,
|
||||
onPlayerSelected: (player) async {
|
||||
// Select the player, then navigate to podcast detail
|
||||
maProvider.selectPlayer(player);
|
||||
@@ -3359,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
|
||||
@@ -38,17 +38,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showDiscoverSeries = false;
|
||||
// Home row order
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Audiobook libraries
|
||||
List<Map<String, String>> _discoveredLibraries = [];
|
||||
Map<String, bool> _libraryEnabled = {};
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _disableAutoSwitch = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
bool _showOnlyArtistsWithAlbums = false;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -86,26 +84,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load home row order
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
|
||||
// Load audiobook library settings
|
||||
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
||||
final enabled = await SettingsService.getEnabledAbsLibraries();
|
||||
final libraryEnabled = <String, bool>{};
|
||||
for (final lib in discovered) {
|
||||
final path = lib['path'] ?? '';
|
||||
// null means all enabled
|
||||
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||
}
|
||||
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
// Load library settings
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -122,13 +111,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveredLibraries = discovered;
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -649,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
RadioListTile<String?>(
|
||||
title: Text(S.of(context)!.system),
|
||||
subtitle: const Text('Auto'),
|
||||
subtitle: Text(S.of(context)!.auto),
|
||||
value: null,
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -658,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('English'),
|
||||
title: Text(S.of(context)!.english),
|
||||
value: 'en',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -667,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Deutsch'),
|
||||
title: Text(S.of(context)!.german),
|
||||
value: 'de',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -676,7 +664,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Español'),
|
||||
title: Text(S.of(context)!.spanish),
|
||||
value: 'es',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -685,7 +673,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Français'),
|
||||
title: Text(S.of(context)!.french),
|
||||
value: 'fr',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -767,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
S.of(context)!.disableAutoSwitch,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.disableAutoSwitchDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _disableAutoSwitch,
|
||||
onChanged: (value) {
|
||||
setState(() => _disableAutoSwitch = value);
|
||||
SettingsService.setDisableAutoSwitch(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -832,17 +845,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Library section
|
||||
// Display section
|
||||
Text(
|
||||
S.of(context)!.library,
|
||||
S.of(context)!.display,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -851,24 +865,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Show only artists with albums',
|
||||
S.of(context)!.showProviderIcons,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hide artists that have no albums in your library',
|
||||
style: TextStyle(
|
||||
S.of(context)!.showProviderIconsDescription,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _showOnlyArtistsWithAlbums,
|
||||
onChanged: (value) async {
|
||||
setState(() => _showOnlyArtistsWithAlbums = value);
|
||||
await SettingsService.setShowOnlyArtistsWithAlbums(value);
|
||||
// Force sync library to apply the new filter at API level
|
||||
if (mounted) {
|
||||
context.read<MusicAssistantProvider>().forceLibrarySync();
|
||||
}
|
||||
value: _showProviderIcons,
|
||||
onChanged: (value) {
|
||||
setState(() => _showProviderIcons = value);
|
||||
SettingsService.setShowProviderIcons(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -1053,76 +1062,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Audiobook Libraries section
|
||||
if (_discoveredLibraries.isNotEmpty) ...[
|
||||
Text(
|
||||
S.of(context)!.audiobookLibraries,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.chooseAudiobookLibraries,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: _discoveredLibraries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final library = entry.value;
|
||||
final path = library['path'] ?? '';
|
||||
final name = library['name'] ?? S.of(context)!.unknownLibrary;
|
||||
final isEnabled = _libraryEnabled[path] ?? true;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
name,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
value: isEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_libraryEnabled[path] = value;
|
||||
});
|
||||
await SettingsService.toggleAbsLibrary(path, value);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.pullToRefresh),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
),
|
||||
if (index < _discoveredLibraries.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
|
||||
@@ -157,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
// Note: We don't actually stop playback - this button is repurposed for player switching
|
||||
}
|
||||
|
||||
/// Fully stop the foreground service and release resources
|
||||
/// Called after idle timeout to save battery
|
||||
Future<void> stopService() async {
|
||||
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
|
||||
_isRemoteMode = false;
|
||||
_currentMediaItem = null;
|
||||
await _player.stop();
|
||||
// Call the base stop() to properly stop the foreground service
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'basic_auth_strategy.dart';
|
||||
import 'authelia_strategy.dart';
|
||||
import 'ma_auth_strategy.dart';
|
||||
import '../debug_logger.dart';
|
||||
import '../../constants/network.dart';
|
||||
|
||||
/// Central authentication manager
|
||||
/// Manages auth strategy selection, auto-detection, and credential lifecycle
|
||||
@@ -65,16 +66,18 @@ class AuthManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Normalize a server URL with appropriate protocol
|
||||
/// Normalize a server URL with appropriate protocol and port
|
||||
/// For local IP addresses without an explicit port, defaults to MA port 8095
|
||||
String _normalizeUrl(String url) {
|
||||
var baseUrl = url;
|
||||
var baseUrl = url.trim();
|
||||
|
||||
// Check if URL already has a port specified (before adding protocol)
|
||||
// Port can be specified as host:port or after protocol as http://host:port
|
||||
bool hasExplicitPort = _hasExplicitPort(baseUrl);
|
||||
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
// Use http for local IPs, https for domains
|
||||
if (baseUrl.startsWith('192.') ||
|
||||
baseUrl.startsWith('10.') ||
|
||||
baseUrl.startsWith('172.') ||
|
||||
baseUrl == 'localhost' ||
|
||||
baseUrl.startsWith('127.')) {
|
||||
if (_isLocalAddress(baseUrl)) {
|
||||
baseUrl = 'http://$baseUrl';
|
||||
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||
@@ -84,9 +87,80 @@ class AuthManager {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
}
|
||||
|
||||
// For local addresses without explicit port, add default MA port
|
||||
// This handles the common case of MA as HAOS add-on where port 8095 is needed
|
||||
if (!hasExplicitPort) {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (_isLocalAddress(uri.host)) {
|
||||
baseUrl = Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: NetworkConstants.defaultWsPort,
|
||||
path: uri.path.isEmpty ? null : uri.path,
|
||||
).toString();
|
||||
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// Check if a URL string has an explicit port specified
|
||||
bool _hasExplicitPort(String url) {
|
||||
// Remove protocol if present
|
||||
var hostPart = url;
|
||||
if (hostPart.startsWith('http://')) {
|
||||
hostPart = hostPart.substring(7);
|
||||
} else if (hostPart.startsWith('https://')) {
|
||||
hostPart = hostPart.substring(8);
|
||||
}
|
||||
|
||||
// Remove path if present
|
||||
final pathIndex = hostPart.indexOf('/');
|
||||
if (pathIndex != -1) {
|
||||
hostPart = hostPart.substring(0, pathIndex);
|
||||
}
|
||||
|
||||
// Check for port (host:port pattern)
|
||||
// Need to handle IPv6 addresses like [::1]:8095
|
||||
if (hostPart.startsWith('[')) {
|
||||
// IPv6 address
|
||||
final closeBracket = hostPart.indexOf(']');
|
||||
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
|
||||
return hostPart[closeBracket + 1] == ':';
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
// IPv4 or hostname - check for colon followed by digits
|
||||
final colonIndex = hostPart.lastIndexOf(':');
|
||||
if (colonIndex != -1) {
|
||||
final portPart = hostPart.substring(colonIndex + 1);
|
||||
return int.tryParse(portPart) != null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an address is a local/private address (without protocol)
|
||||
bool _isLocalAddress(String address) {
|
||||
// Remove port if present for checking
|
||||
var host = address;
|
||||
final colonIndex = host.lastIndexOf(':');
|
||||
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
|
||||
host = host.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('192.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Check if a hostname is a local/private IP address
|
||||
bool _isLocalIpAddress(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
|
||||
@@ -213,6 +213,20 @@ class CacheService {
|
||||
_logger.log('🗑️ Home artist caches invalidated');
|
||||
}
|
||||
|
||||
/// Clear discover caches (call when provider filter changes)
|
||||
/// This ensures discover rows are refetched with the new provider filter
|
||||
void clearDiscoverCaches() {
|
||||
_cachedDiscoverArtists = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_logger.log('🗑️ Discover caches cleared for provider filter change');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN CACHING
|
||||
// ============================================================================
|
||||
|
||||
@@ -107,15 +107,18 @@ class DatabaseService {
|
||||
// ============================================
|
||||
|
||||
/// Cache an item with JSON serialization
|
||||
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider,
|
||||
}) {
|
||||
return db.cacheItem(
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: jsonEncode(data),
|
||||
sourceProvider: sourceProvider,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +135,29 @@ class DatabaseService {
|
||||
}).where((item) => item.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get cached items with source provider info for client-side filtering
|
||||
/// Returns list of (data, sourceProviders) tuples
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType) async {
|
||||
final items = await db.getCachedItems(itemType);
|
||||
return items.map((item) {
|
||||
try {
|
||||
final data = jsonDecode(item.data) as Map<String, dynamic>;
|
||||
List<String> providers = [];
|
||||
try {
|
||||
if (item.sourceProviders.isNotEmpty && item.sourceProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
jsonDecode(item.sourceProviders) as List,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return (data, providers);
|
||||
} catch (e) {
|
||||
_logger.log('Error decoding cached item: $e');
|
||||
return (<String, dynamic>{}, <String>[]);
|
||||
}
|
||||
}).where((item) => item.$1.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get a single cached item
|
||||
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
|
||||
final item = await db.getCachedItem(itemType, itemId);
|
||||
@@ -167,6 +193,13 @@ class DatabaseService {
|
||||
return db.markItemsDeleted(itemType, [itemId]);
|
||||
}
|
||||
|
||||
/// Batch cache multiple items efficiently
|
||||
/// Uses drift's batch API for much better performance on large datasets.
|
||||
/// Items are automatically chunked into batches of 500.
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) {
|
||||
return db.batchCacheItems(items);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Player Cache Convenience Methods
|
||||
// ============================================
|
||||
|
||||
@@ -7,6 +7,9 @@ enum ErrorType {
|
||||
network,
|
||||
playback,
|
||||
library,
|
||||
remote, // Remote connection (WebRTC) errors
|
||||
dataChannel, // WebRTC data channel errors
|
||||
signaling, // WebRTC signaling server errors
|
||||
unknown,
|
||||
}
|
||||
|
||||
@@ -107,6 +110,41 @@ class ErrorHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// WebRTC/Remote connection errors
|
||||
if (errorStr.contains('webrtc') ||
|
||||
errorStr.contains('remote peer') ||
|
||||
errorStr.contains('remote server disconnected')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.remote,
|
||||
userMessage: 'Remote connection lost. Attempting to reconnect...',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Data channel errors
|
||||
if (errorStr.contains('data channel') ||
|
||||
errorStr.contains('datachannel')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.dataChannel,
|
||||
userMessage: 'Remote data connection failed. Please try reconnecting.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Signaling errors
|
||||
if (errorStr.contains('signaling') ||
|
||||
errorStr.contains('ice') ||
|
||||
errorStr.contains('peer connection failed')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.signaling,
|
||||
userMessage: 'Failed to establish remote connection. Please try again.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
|
||||
@@ -2,14 +2,19 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/lru_cache.dart';
|
||||
|
||||
class MetadataService {
|
||||
static final _logger = DebugLogger();
|
||||
// Cache to avoid repeated API calls for the same artist/album
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
// Cache for artist images
|
||||
static final Map<String, String?> _artistImageCache = {};
|
||||
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
|
||||
// Using NullableLruCache for caches that store null values (failed lookups)
|
||||
|
||||
/// Cache for artist/album descriptions (100 entries)
|
||||
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
|
||||
|
||||
/// Cache for artist images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
/// Fetches artist biography/description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
@@ -34,8 +39,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'artist:$artistName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API
|
||||
@@ -43,7 +48,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +58,7 @@ class MetadataService {
|
||||
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||
if (audioDbDesc != null) {
|
||||
_cache[cacheKey] = audioDbDesc;
|
||||
_descriptionCache[cacheKey] = audioDbDesc;
|
||||
return audioDbDesc;
|
||||
}
|
||||
}
|
||||
@@ -83,8 +88,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'album:$artistName:$albumName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||
@@ -92,7 +97,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -251,11 +256,11 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache for album images
|
||||
static final Map<String, String?> _albumImageCache = {};
|
||||
/// Cache for album images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
// Cache for author images (audiobooks)
|
||||
static final Map<String, String?> _authorImageCache = {};
|
||||
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
|
||||
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
|
||||
|
||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
@@ -740,11 +745,90 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the metadata cache
|
||||
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
|
||||
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
|
||||
static final Map<String, (String?, String?)?> _lyricsCache = {};
|
||||
static const int _lyricsCacheMaxSize = 500;
|
||||
|
||||
/// Fetches lyrics from LRCLIB (free, no API key required)
|
||||
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
|
||||
/// LRCLIB provides both synced (LRC format) and plain lyrics
|
||||
static Future<(String?, String?)?> getTrackLyrics({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
String? albumName,
|
||||
int? durationSeconds,
|
||||
}) async {
|
||||
// Build cache key
|
||||
final cacheKey = 'lyrics:$artistName:$trackName';
|
||||
if (_lyricsCache.containsKey(cacheKey)) {
|
||||
return _lyricsCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try LRCLIB API
|
||||
try {
|
||||
final params = <String, String>{
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
};
|
||||
if (albumName != null && albumName.isNotEmpty) {
|
||||
params['album_name'] = albumName;
|
||||
}
|
||||
if (durationSeconds != null && durationSeconds > 0) {
|
||||
params['duration'] = durationSeconds.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.https('lrclib.net', '/api/get', params);
|
||||
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
|
||||
},
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final plainLyrics = data['plainLyrics'] as String?;
|
||||
final syncedLyrics = data['syncedLyrics'] as String?;
|
||||
|
||||
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
|
||||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
|
||||
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
|
||||
final result = (plainLyrics, syncedLyrics);
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
} else if (response.statusCode == 404) {
|
||||
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
|
||||
} else {
|
||||
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Evict oldest entries from lyrics cache if over max size
|
||||
static void _evictLyricsCacheIfNeeded() {
|
||||
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
|
||||
_lyricsCache.remove(_lyricsCache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all metadata caches
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
_descriptionCache.clear();
|
||||
_artistImageCache.clear();
|
||||
_albumImageCache.clear();
|
||||
_authorImageCache.clear();
|
||||
_lyricsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../constants/network.dart';
|
||||
import '../constants/timings.dart' show Timings, LibraryConstants;
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -23,6 +25,20 @@ enum MAConnectionState {
|
||||
error,
|
||||
}
|
||||
|
||||
/// Wrapper for pending requests with timestamp for age-based cleanup
|
||||
class _PendingRequest {
|
||||
final Completer<Map<String, dynamic>> completer;
|
||||
final DateTime createdAt;
|
||||
|
||||
_PendingRequest({required this.completer, DateTime? createdAt})
|
||||
: createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
bool get isStale {
|
||||
const staleThreshold = Duration(minutes: 2);
|
||||
return DateTime.now().difference(createdAt) > staleThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
class MusicAssistantAPI {
|
||||
final String serverUrl;
|
||||
final AuthManager authManager;
|
||||
@@ -36,7 +52,7 @@ class MusicAssistantAPI {
|
||||
MAConnectionState _currentState = MAConnectionState.disconnected;
|
||||
MAConnectionState get currentConnectionState => _currentState;
|
||||
|
||||
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||
final Map<String, _PendingRequest> _pendingRequests = {};
|
||||
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
|
||||
|
||||
Completer<void>? _connectionCompleter;
|
||||
@@ -59,6 +75,16 @@ class MusicAssistantAPI {
|
||||
int? get schemaVersion => _schemaVersion;
|
||||
Map<String, dynamic>? get serverInfo => _serverInfo;
|
||||
|
||||
// Provider manifests (contains icons)
|
||||
final Map<String, ProviderManifest> _providerManifests = {};
|
||||
Map<String, ProviderManifest> get providerManifests => _providerManifests;
|
||||
|
||||
// Remote mode flag - when true, images go through WebRTC bridge which doesn't support HTTP
|
||||
// In this mode, we use external CDN URLs directly instead of imageproxy
|
||||
bool _isRemoteMode = false;
|
||||
bool get isRemoteMode => _isRemoteMode;
|
||||
set isRemoteMode(bool value) => _isRemoteMode = value;
|
||||
|
||||
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||
|
||||
// Guard to prevent multiple simultaneous connection attempts
|
||||
@@ -79,13 +105,15 @@ class MusicAssistantAPI {
|
||||
return;
|
||||
}
|
||||
|
||||
// If connection is in progress, wait for it instead of starting another
|
||||
// Atomic check-and-set: create new completer first, then check if one exists
|
||||
// This ensures no async operations happen between check and set
|
||||
final newCompleter = Completer<void>();
|
||||
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
|
||||
_logger.log('Connection already in progress, waiting...');
|
||||
return _connectionInProgress!.future;
|
||||
}
|
||||
|
||||
_connectionInProgress = Completer<void>();
|
||||
// Set immediately before any async operations to close the race window
|
||||
_connectionInProgress = newCompleter;
|
||||
|
||||
try {
|
||||
_updateConnectionState(MAConnectionState.connecting);
|
||||
@@ -277,15 +305,15 @@ class MusicAssistantAPI {
|
||||
|
||||
// Handle response to a request
|
||||
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||
final completer = _pendingRequests.remove(messageId);
|
||||
final pending = _pendingRequests.remove(messageId);
|
||||
|
||||
if (data.containsKey('error_code')) {
|
||||
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
|
||||
completer!.completeError(
|
||||
pending!.completer.completeError(
|
||||
Exception('${data['error_code']}: ${data['details']}'),
|
||||
);
|
||||
} else {
|
||||
completer!.complete(data);
|
||||
pending!.completer.complete(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -344,9 +372,20 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale requests before checking limits
|
||||
_cleanupStaleRequests();
|
||||
|
||||
// Prevent memory leaks by limiting pending requests
|
||||
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
|
||||
throw Exception(
|
||||
'Too many pending requests (${_pendingRequests.length}). '
|
||||
'Please wait for existing requests to complete.',
|
||||
);
|
||||
}
|
||||
|
||||
final messageId = _uuid.v4();
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[messageId] = completer;
|
||||
_pendingRequests[messageId] = _PendingRequest(completer: completer);
|
||||
|
||||
final message = {
|
||||
'message_id': messageId,
|
||||
@@ -369,6 +408,27 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale pending requests that have been waiting too long
|
||||
void _cleanupStaleRequests() {
|
||||
final staleIds = <String>[];
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (entry.value.isStale && !entry.value.completer.isCompleted) {
|
||||
staleIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
if (staleIds.isNotEmpty) {
|
||||
_logger.log('Cleanup: Removing ${staleIds.length} stale pending requests');
|
||||
for (final id in staleIds) {
|
||||
final pending = _pendingRequests.remove(id);
|
||||
if (pending != null && !pending.completer.isCompleted) {
|
||||
pending.completer.completeError(
|
||||
TimeoutException('Request expired after 2 minutes'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Library browsing methods
|
||||
Future<List<Artist>> getArtists({
|
||||
int? limit,
|
||||
@@ -376,23 +436,31 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
bool albumArtistsOnly = true,
|
||||
String? orderBy,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final args = {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
};
|
||||
_logger.log('🔍 API getArtists args: $args');
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
args: {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
},
|
||||
args: args,
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
return [];
|
||||
}
|
||||
_logger.log('🔍 API getArtists returned ${items.length} items');
|
||||
return items
|
||||
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -402,7 +470,11 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
|
||||
Future<List<Artist>> getRandomArtists({
|
||||
int limit = 10,
|
||||
bool albumArtistsOnly = true,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
@@ -410,6 +482,8 @@ class MusicAssistantAPI {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -431,6 +505,8 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final args = <String, dynamic>{
|
||||
@@ -439,7 +515,11 @@ class MusicAssistantAPI {
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist_id': artistId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
};
|
||||
_logger.log('🔍 API getAlbums args: $args');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
@@ -448,6 +528,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
_logger.log('🔍 API getAlbums returned ${items.length} items');
|
||||
|
||||
return items
|
||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||
@@ -465,6 +546,8 @@ class MusicAssistantAPI {
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
String? orderBy,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -476,6 +559,9 @@ class MusicAssistantAPI {
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist': artistId,
|
||||
if (albumId != null) 'album': albumId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -496,6 +582,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -504,6 +591,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -555,6 +643,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -563,6 +652,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -719,48 +809,125 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? authorId,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
// Check if library filtering is enabled
|
||||
final allAudiobooks = <Audiobook>[];
|
||||
final seenIds = <String>{};
|
||||
|
||||
// Check if library filtering is enabled (for ABS providers)
|
||||
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
|
||||
|
||||
// If specific libraries are enabled, use browse API for filtering
|
||||
// Extract ABS provider IDs from library paths
|
||||
final absProviderIds = <String>{};
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
|
||||
// Otherwise use the faster MA API (returns all audiobooks)
|
||||
final args = <String, dynamic>{
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (authorId != null) 'author_id': authorId,
|
||||
};
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/audiobooks/library_items',
|
||||
args: args,
|
||||
);
|
||||
|
||||
// Check for error in response
|
||||
if (response.containsKey('error_code')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
final audiobooks = <Audiobook>[];
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
try {
|
||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||
audiobooks.add(book);
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
for (final libPath in enabledLibraries) {
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd != -1) {
|
||||
absProviderIds.add(libPath.substring(0, providerEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobooks;
|
||||
|
||||
_logger.log('📚 getAudiobooks: absProviderIds=$absProviderIds, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// PART 1: Fetch from ABS providers using browse API (for library filtering support)
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
// If specific providers are requested, filter libraries to only matching ABS providers
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (librariesToBrowse.isNotEmpty) {
|
||||
final browseBooks = await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
for (final book in browseBooks) {
|
||||
if (!seenIds.contains(book.itemId)) {
|
||||
seenIds.add(book.itemId);
|
||||
allAudiobooks.add(book);
|
||||
}
|
||||
}
|
||||
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
|
||||
|
||||
// If browse returned nothing, the saved library paths may be stale
|
||||
// (e.g., after provider was removed and re-added with new instance ID)
|
||||
// Clear the stale settings so we fall back to library_items API
|
||||
if (browseBooks.isEmpty) {
|
||||
_logger.log('📚 Browse returned 0 books - clearing stale library settings');
|
||||
await SettingsService.clearEnabledAbsLibraries();
|
||||
await SettingsService.setDiscoveredAbsLibraries([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PART 2: Fetch from non-ABS providers using library_items API
|
||||
// This gets audiobooks from Spotify, Plex, Jellyfin, etc.
|
||||
List<String>? nonAbsProviders;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
// Filter to only non-ABS providers from the requested list
|
||||
nonAbsProviders = providerInstanceIds.where((id) => !absProviderIds.contains(id)).toList();
|
||||
} else if (absProviderIds.isNotEmpty) {
|
||||
// No specific providers requested, but we have ABS - need to also fetch from non-ABS
|
||||
// Pass null to get all, then we'll deduplicate
|
||||
nonAbsProviders = null; // Will fetch all
|
||||
}
|
||||
|
||||
// Only call library_items API if:
|
||||
// 1. No ABS libraries configured (traditional mode), OR
|
||||
// 2. We have specific non-ABS providers to query, OR
|
||||
// 3. ABS browse returned 0 items (stale paths were cleared above)
|
||||
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
|
||||
// as library_items API would return all audiobooks bypassing the library filter
|
||||
final shouldFetchFromApi = enabledLibraries == null ||
|
||||
enabledLibraries.isEmpty ||
|
||||
allAudiobooks.isEmpty || // Fall back if browse got nothing
|
||||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
|
||||
|
||||
if (shouldFetchFromApi) {
|
||||
final args = <String, dynamic>{
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (authorId != null) 'author_id': authorId,
|
||||
if (nonAbsProviders != null && nonAbsProviders.isNotEmpty)
|
||||
'provider': nonAbsProviders,
|
||||
};
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/audiobooks/library_items',
|
||||
args: args,
|
||||
);
|
||||
|
||||
if (!response.containsKey('error_code')) {
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items != null) {
|
||||
int addedCount = 0;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
try {
|
||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||
// Deduplicate by itemId
|
||||
if (!seenIds.contains(book.itemId)) {
|
||||
seenIds.add(book.itemId);
|
||||
allAudiobooks.add(book);
|
||||
addedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
_logger.log('📚 Got $addedCount audiobooks from library_items API');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('📚 Total audiobooks: ${allAudiobooks.length}');
|
||||
return allAudiobooks;
|
||||
} catch (e, stack) {
|
||||
_logger.log('Error getting audiobooks: $e');
|
||||
_logger.log('Stack: $stack');
|
||||
@@ -988,6 +1155,51 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get lyrics for a track
|
||||
/// Returns the lyrics text if available, null otherwise
|
||||
/// Uses the dedicated metadata/get_track_lyrics endpoint
|
||||
Future<String?> getTrackLyrics(Map<String, dynamic> track) async {
|
||||
try {
|
||||
_logger.log('🎤 Getting lyrics for track: ${track['name']}');
|
||||
|
||||
// Use the dedicated lyrics endpoint
|
||||
final response = await _sendCommand(
|
||||
'metadata/get_track_lyrics',
|
||||
args: {
|
||||
'track': track,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.containsKey('error_code')) {
|
||||
_logger.log('🎤 Error getting lyrics: ${response['error_code']}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Result is a tuple: [plain_lyrics, synced_lyrics]
|
||||
// We prefer synced lyrics if available, otherwise plain
|
||||
final result = response['result'];
|
||||
if (result is List && result.length >= 2) {
|
||||
final plainLyrics = result[0] as String?;
|
||||
final syncedLyrics = result[1] as String?;
|
||||
|
||||
if (syncedLyrics != null && syncedLyrics.isNotEmpty) {
|
||||
_logger.log('🎤 Found synced lyrics: ${syncedLyrics.length} chars');
|
||||
return syncedLyrics;
|
||||
}
|
||||
if (plainLyrics != null && plainLyrics.isNotEmpty) {
|
||||
_logger.log('🎤 Found plain lyrics: ${plainLyrics.length} chars');
|
||||
return plainLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('🎤 No lyrics found in result: $result');
|
||||
return null;
|
||||
} catch (e) {
|
||||
_logger.log('🎤 Error getting lyrics: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse Music Assistant content by path
|
||||
/// Returns a list of browse items (folders or media items)
|
||||
Future<List<dynamic>> browse(String? path) async {
|
||||
@@ -1338,13 +1550,18 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Get random albums
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
|
||||
Future<List<Album>> getRandomAlbums({
|
||||
int limit = 10,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1460,6 +1677,8 @@ class MusicAssistantAPI {
|
||||
int? offset,
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -1469,6 +1688,9 @@ class MusicAssistantAPI {
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1490,7 +1712,7 @@ class MusicAssistantAPI {
|
||||
final response = await _sendCommand(
|
||||
'music/playlist',
|
||||
args: {
|
||||
'provider': provider,
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
@@ -3038,6 +3260,12 @@ class MusicAssistantAPI {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In remote mode (WebRTC bridge), HTTP requests don't work through the tunnel
|
||||
// So we need to use external CDN URLs directly when available
|
||||
if (_isRemoteMode) {
|
||||
return _getImageUrlForRemoteMode(images, size);
|
||||
}
|
||||
|
||||
// Try to find a non-remotely accessible image first (prefer local/opensubsonic)
|
||||
Map<String, dynamic>? selectedImage;
|
||||
for (var img in images) {
|
||||
@@ -3100,6 +3328,107 @@ class MusicAssistantAPI {
|
||||
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
||||
}
|
||||
|
||||
/// Get image URL for remote mode - uses external CDN URLs directly
|
||||
/// since HTTP requests don't work through the WebRTC bridge
|
||||
String? _getImageUrlForRemoteMode(List<dynamic> images, int size) {
|
||||
// Known external CDN domains that are publicly accessible
|
||||
const externalDomains = [
|
||||
'i.scdn.co', // Spotify
|
||||
'mosaic.scdn.co', // Spotify playlists
|
||||
'image-cdn-ak.spotifycdn.com', // Spotify
|
||||
'cdns-images.dzcdn.net', // Deezer
|
||||
'resources.tidal.com', // Tidal
|
||||
'e-cdns-images.dzcdn.net', // Deezer
|
||||
'is1-ssl.mzstatic.com', // Apple Music
|
||||
'is2-ssl.mzstatic.com', // Apple Music
|
||||
'is3-ssl.mzstatic.com', // Apple Music
|
||||
'is4-ssl.mzstatic.com', // Apple Music
|
||||
'is5-ssl.mzstatic.com', // Apple Music
|
||||
'lastfm.freetls.fastly.net', // Last.fm
|
||||
'coverartarchive.org', // MusicBrainz
|
||||
'archive.org', // Internet Archive
|
||||
'fanart.tv', // Fanart.tv
|
||||
];
|
||||
|
||||
// First pass: look for images with paths that are external URLs
|
||||
for (var img in images) {
|
||||
final imgMap = img as Map<String, dynamic>;
|
||||
final path = imgMap['path'] as String?;
|
||||
if (path == null) continue;
|
||||
|
||||
// Check if path is already an external URL
|
||||
if (path.startsWith('https://')) {
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
|
||||
// If URL contains imageproxy with a size parameter, replace with requested size
|
||||
if (uri.path.contains('imageproxy') && uri.queryParameters.containsKey('size')) {
|
||||
final newParams = Map<String, String>.from(uri.queryParameters);
|
||||
newParams['size'] = size.toString();
|
||||
final newUri = uri.replace(queryParameters: newParams);
|
||||
_logger.log('🖼️ Remote mode: adjusted imageproxy size to $size from ${uri.host}');
|
||||
return newUri.toString();
|
||||
}
|
||||
|
||||
// Check if it's from a known CDN
|
||||
for (final domain in externalDomains) {
|
||||
if (uri.host.contains(domain)) {
|
||||
_logger.log('🖼️ Remote mode: using external CDN URL from ${uri.host}');
|
||||
// For Spotify, we can request different sizes
|
||||
if (uri.host.contains('scdn.co') || uri.host.contains('spotifycdn.com')) {
|
||||
// Spotify image URLs can have size in path, but we'll use as-is
|
||||
return path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an Audiobookshelf URL (cover API endpoint)
|
||||
// Audiobookshelf supports ?width=X parameter for resizing
|
||||
if (uri.path.contains('/api/') && uri.path.contains('/cover')) {
|
||||
final newParams = Map<String, String>.from(uri.queryParameters);
|
||||
newParams['width'] = size.toString();
|
||||
final newUri = uri.replace(queryParameters: newParams);
|
||||
_logger.log('🖼️ Remote mode: adjusted Audiobookshelf cover size to $size from ${uri.host}');
|
||||
return newUri.toString();
|
||||
}
|
||||
|
||||
// Even if not a known CDN, if it's HTTPS it might work
|
||||
_logger.log('🖼️ Remote mode: trying external URL from ${uri.host}');
|
||||
return path;
|
||||
} catch (e) {
|
||||
// Not a valid URL, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: try any image with a provider that typically has external URLs
|
||||
for (var img in images) {
|
||||
final imgMap = img as Map<String, dynamic>;
|
||||
final provider = imgMap['provider'] as String?;
|
||||
final path = imgMap['path'] as String?;
|
||||
if (path == null) continue;
|
||||
|
||||
// Providers that typically have external URLs in their paths
|
||||
if (provider != null) {
|
||||
if (provider.contains('spotify') ||
|
||||
provider.contains('tidal') ||
|
||||
provider.contains('deezer') ||
|
||||
provider.contains('apple_music') ||
|
||||
provider.contains('musicbrainz')) {
|
||||
if (path.startsWith('https://')) {
|
||||
_logger.log('🖼️ Remote mode: using $provider image URL');
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No external URLs found - images won't load in remote mode for local files
|
||||
_logger.log('🖼️ Remote mode: no external image URL available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== iTunes Artwork Lookup ====================
|
||||
|
||||
/// Search iTunes for a podcast and return high-res artwork URL
|
||||
@@ -3261,22 +3590,26 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('📥 Fetching initial state...');
|
||||
|
||||
// Fetch provider manifests (available providers)
|
||||
// Fetch provider manifests (available providers with icons)
|
||||
try {
|
||||
await _sendCommand('providers/manifests');
|
||||
_logger.log('✓ Provider manifests loaded');
|
||||
final manifestResponse = await _sendCommand('providers/manifests');
|
||||
final manifests = manifestResponse['result'];
|
||||
if (manifests is List) {
|
||||
_providerManifests.clear();
|
||||
for (final manifestData in manifests) {
|
||||
if (manifestData is Map<String, dynamic>) {
|
||||
final manifest = ProviderManifest.fromJson(manifestData);
|
||||
if (manifest.domain.isNotEmpty) {
|
||||
_providerManifests[manifest.domain] = manifest;
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||
}
|
||||
|
||||
// Fetch provider instances (active providers)
|
||||
try {
|
||||
await _sendCommand('providers');
|
||||
_logger.log('✓ Provider instances loaded');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not load provider instances: $e');
|
||||
}
|
||||
|
||||
_logger.log('✅ Initial state fetch complete');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error fetching state: $e');
|
||||
@@ -3284,6 +3617,51 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all music provider instances configured in Music Assistant
|
||||
/// Returns only music providers (spotify, tidal, filesystem, etc.) not player/metadata providers
|
||||
Future<List<ProviderInstance>> getMusicProviders() async {
|
||||
try {
|
||||
final response = await _sendCommand('providers');
|
||||
|
||||
final result = response['result'];
|
||||
if (result == null) {
|
||||
_logger.log('⚠️ providers command returned no result');
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> providers = result is List ? result : [];
|
||||
final musicProviders = <ProviderInstance>[];
|
||||
|
||||
for (final providerData in providers) {
|
||||
if (providerData is Map<String, dynamic>) {
|
||||
final instance = ProviderInstance.fromJson(providerData);
|
||||
// Only include music providers (not player providers, metadata providers, etc.)
|
||||
if (instance.isMusicProvider) {
|
||||
musicProviders.add(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('🎵 Found ${musicProviders.length} music providers: ${musicProviders.map((p) => p.name).join(", ")}');
|
||||
return musicProviders;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error getting music providers: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _providerManifests[domain];
|
||||
}
|
||||
|
||||
/// Get SVG icon for a provider domain
|
||||
/// Returns null if no icon is available
|
||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||
final manifest = _providerManifests[domain];
|
||||
return manifest?.getSvgIcon(isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get current authenticated user's profile
|
||||
/// Returns user info including display_name, username, groups, etc.
|
||||
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
|
||||
@@ -3419,6 +3797,9 @@ class MusicAssistantAPI {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel all pending requests before reconnecting to prevent memory leaks
|
||||
_cancelPendingRequests('Connection lost, reconnecting');
|
||||
|
||||
await Future.delayed(Timings.reconnectDelay);
|
||||
|
||||
// Check again after delay
|
||||
@@ -3447,8 +3828,8 @@ class MusicAssistantAPI {
|
||||
/// Cancel all pending requests with an error message
|
||||
void _cancelPendingRequests(String reason) {
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.completeError(Exception(reason));
|
||||
if (!entry.value.completer.isCompleted) {
|
||||
entry.value.completer.completeError(Exception(reason));
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
|
||||
@@ -100,6 +100,7 @@ class PositionTracker {
|
||||
Duration? duration,
|
||||
double? serverTimestamp,
|
||||
}) {
|
||||
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
|
||||
final bool playerChanged = _playerId != playerId;
|
||||
final bool playStateChanged = _isPlaying != isPlaying;
|
||||
final bool durationChanged = duration != null && _duration != duration;
|
||||
@@ -155,17 +156,34 @@ class PositionTracker {
|
||||
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
|
||||
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
|
||||
// 4. We're not playing (paused state should reflect server position)
|
||||
// 5. Anchor is getting stale (> 20 seconds old) - prevents position freezing at _maxAnchorAge cap
|
||||
//
|
||||
// Key insight: When timestamp is stale, the raw position from server is likely outdated.
|
||||
// Our interpolated position is probably more accurate, so don't let stale data override it.
|
||||
// HOWEVER, if our anchor itself is getting old, we need to refresh it to prevent freezing.
|
||||
final anchorAge = DateTime.now().difference(_anchorTime);
|
||||
final anchorIsGettingStale = anchorAge.inSeconds > 20;
|
||||
// When anchor is stale, always refresh it - the suspicious reset check should
|
||||
// only prevent us from trusting the SERVER's position, not from refreshing
|
||||
// our own interpolated anchor. The newAnchorPos logic below handles using
|
||||
// interpolated position for stale anchors with suspicious server data.
|
||||
final shouldUpdateAnchor = playerChanged
|
||||
|| playStateChanged
|
||||
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|
||||
|| !isPlaying;
|
||||
|| !isPlaying
|
||||
|| anchorIsGettingStale;
|
||||
|
||||
if (shouldUpdateAnchor) {
|
||||
_anchorPosition = anchorPos;
|
||||
// When refreshing due to stale anchor (not player/state change), use interpolated position
|
||||
// to avoid backward jumps from outdated server data
|
||||
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
||||
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
||||
: anchorPos; // Use server position for other cases
|
||||
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
|
||||
_anchorPosition = newAnchorPos;
|
||||
_anchorTime = DateTime.now();
|
||||
} else if (positionDiff > 2) {
|
||||
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
|
||||
}
|
||||
|
||||
_isPlaying = isPlaying;
|
||||
@@ -209,6 +227,7 @@ class PositionTracker {
|
||||
|
||||
/// Clear tracker state (e.g., when disconnecting)
|
||||
void clear() {
|
||||
_logger.log('⏱️ PositionTracker.clear() called');
|
||||
_stopInterpolationTimer();
|
||||
_playerId = null;
|
||||
_isPlaying = false;
|
||||
@@ -218,9 +237,20 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _startInterpolationTimer() {
|
||||
if (_interpolationTimer != null) return;
|
||||
if (_interpolationTimer != null) {
|
||||
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
|
||||
return;
|
||||
}
|
||||
|
||||
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) {
|
||||
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
|
||||
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
|
||||
// Log every 4 ticks (1 second) if no emission happened
|
||||
if (timer.tick % 4 == 0) {
|
||||
final pos = currentPosition;
|
||||
if (pos.inSeconds == _lastEmittedSeconds) {
|
||||
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
|
||||
}
|
||||
}
|
||||
_emitPosition();
|
||||
});
|
||||
|
||||
@@ -229,6 +259,7 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _stopInterpolationTimer() {
|
||||
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||
_interpolationTimer?.cancel();
|
||||
_interpolationTimer = null;
|
||||
}
|
||||
@@ -240,6 +271,7 @@ class PositionTracker {
|
||||
// Only emit if second changed (reduces unnecessary updates)
|
||||
if (seconds != _lastEmittedSeconds) {
|
||||
_lastEmittedSeconds = seconds;
|
||||
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
|
||||
_positionController.add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Error types for RemoteBridge
|
||||
enum RemoteBridgeErrorType {
|
||||
webrtcConnection,
|
||||
signaling,
|
||||
authentication,
|
||||
dataChannel,
|
||||
timeout,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Error information from RemoteBridge
|
||||
class RemoteBridgeError {
|
||||
final RemoteBridgeErrorType type;
|
||||
final String message;
|
||||
final DateTime timestamp;
|
||||
|
||||
RemoteBridgeError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Connection state for RemoteBridge
|
||||
enum RemoteBridgeState {
|
||||
disconnected,
|
||||
connecting,
|
||||
authenticating,
|
||||
connected,
|
||||
reconnecting,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Connection quality metrics
|
||||
class ConnectionMetrics {
|
||||
final int requestsSent;
|
||||
final int responsesReceived;
|
||||
final int reconnectionCount;
|
||||
final Duration? lastLatency;
|
||||
final DateTime? lastActivity;
|
||||
|
||||
ConnectionMetrics({
|
||||
required this.requestsSent,
|
||||
required this.responsesReceived,
|
||||
required this.reconnectionCount,
|
||||
this.lastLatency,
|
||||
this.lastActivity,
|
||||
});
|
||||
|
||||
double get successRate {
|
||||
if (requestsSent == 0) return 1.0;
|
||||
return responsesReceived / requestsSent;
|
||||
}
|
||||
}
|
||||
|
||||
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
|
||||
/// for remote connections. All traffic is tunneled through a local WebSocket server.
|
||||
///
|
||||
/// Architecture:
|
||||
/// ```
|
||||
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
|
||||
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
|
||||
/// ```
|
||||
class RemoteBridge {
|
||||
HttpServer? _server;
|
||||
WebViewWebRTCConnection? _webrtcConnection;
|
||||
int? _port;
|
||||
|
||||
// API client (MusicAssistantAPI)
|
||||
WebSocket? _apiClientSocket;
|
||||
StreamSubscription? _apiClientSubscription;
|
||||
StreamSubscription? _apiWebrtcSubscription;
|
||||
|
||||
// Sendspin client (local player audio)
|
||||
WebSocket? _sendspinClientSocket;
|
||||
StreamSubscription? _sendspinClientSubscription;
|
||||
StreamSubscription? _sendspinTextSubscription;
|
||||
StreamSubscription? _sendspinBinarySubscription;
|
||||
|
||||
Timer? _healthCheckTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Connection parameters for reconnection
|
||||
String? _remoteId;
|
||||
String? _username;
|
||||
String? _password;
|
||||
bool _isReconnecting = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||
static const Duration _rateLimitDelay = Duration(seconds: 65);
|
||||
bool _isRateLimited = false;
|
||||
|
||||
// Cached server hello to replay when WS client connects
|
||||
String? _cachedServerHello;
|
||||
|
||||
// Debug counters
|
||||
int _apiMessagesSent = 0;
|
||||
int _apiMessagesReceived = 0;
|
||||
int _sendspinMessagesSent = 0;
|
||||
int _sendspinMessagesReceived = 0;
|
||||
DateTime? _lastMessageReceived;
|
||||
int _totalReconnections = 0;
|
||||
|
||||
// State tracking
|
||||
RemoteBridgeState _state = RemoteBridgeState.disconnected;
|
||||
final _stateController = StreamController<RemoteBridgeState>.broadcast();
|
||||
final _errorController = StreamController<RemoteBridgeError>.broadcast();
|
||||
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
int? get port => _port;
|
||||
bool get isRunning => _server != null;
|
||||
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
|
||||
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
|
||||
RemoteBridgeState get state => _state;
|
||||
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
|
||||
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
|
||||
|
||||
/// Build the hidden WebView widget that must be added to the widget tree.
|
||||
/// This must be called and the returned widget added to the tree BEFORE calling start().
|
||||
/// The WebView is 1x1 pixel and invisible.
|
||||
Widget buildWebView() {
|
||||
// Create the connection if not already created
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
return _webrtcConnection!.buildWebView();
|
||||
}
|
||||
|
||||
/// Get current connection metrics
|
||||
ConnectionMetrics get metrics => ConnectionMetrics(
|
||||
requestsSent: _apiMessagesSent,
|
||||
responsesReceived: _apiMessagesReceived,
|
||||
reconnectionCount: _totalReconnections,
|
||||
lastActivity: _lastMessageReceived,
|
||||
);
|
||||
|
||||
/// Update state and notify listeners
|
||||
void _setState(RemoteBridgeState newState) {
|
||||
if (_state != newState) {
|
||||
_logger.log('RemoteBridge: State changed: $_state -> $newState');
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an error to listeners
|
||||
void _emitError(RemoteBridgeErrorType type, String message) {
|
||||
final error = RemoteBridgeError(type: type, message: message);
|
||||
_logger.log('RemoteBridge: Error [$type]: $message');
|
||||
_errorController.add(error);
|
||||
}
|
||||
|
||||
/// Start the bridge and connect to the remote MA server.
|
||||
/// Returns the local port number to connect to, or null if failed.
|
||||
Future<int?> start(String remoteId, String username, String password) async {
|
||||
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
|
||||
_setState(RemoteBridgeState.connecting);
|
||||
|
||||
// Store connection parameters for reconnection
|
||||
_remoteId = remoteId;
|
||||
_username = username;
|
||||
_password = password;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
try {
|
||||
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
|
||||
// The WebView must be in the widget tree first - call buildWebView() before start()
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
|
||||
_webrtcConnection!.onStateChanged = (state) {
|
||||
_logger.log('RemoteBridge: WebRTC state: $state');
|
||||
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||
state == WebViewWebRTCConnectionState.failed) {
|
||||
_handleWebRTCDisconnection();
|
||||
}
|
||||
};
|
||||
|
||||
_webrtcConnection!.onError = (error) {
|
||||
_logger.log('RemoteBridge: WebRTC error: $error');
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
|
||||
};
|
||||
|
||||
final connected = await _webrtcConnection!.connect(remoteId);
|
||||
if (!connected) {
|
||||
_logger.log('RemoteBridge: WebRTC connection failed');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
|
||||
_setState(RemoteBridgeState.authenticating);
|
||||
|
||||
// Step 2: Authenticate with MA
|
||||
final authenticated = await _authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
_logger.log('RemoteBridge: Authentication failed');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Authentication successful');
|
||||
_setState(RemoteBridgeState.connected);
|
||||
|
||||
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
|
||||
if (_webrtcConnection!.serverInfo != null) {
|
||||
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||
_logger.log('RemoteBridge: Cached server hello');
|
||||
}
|
||||
|
||||
// Step 4: Start local WebSocket server
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_port = _server!.port;
|
||||
_logger.log('RemoteBridge: Local WS server started on port $_port');
|
||||
|
||||
// Handle incoming connections
|
||||
_server!.listen(_handleHttpRequest);
|
||||
|
||||
// Step 5: Subscribe to WebRTC API messages to forward to API client
|
||||
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||
_apiMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_apiClientSocket != null) {
|
||||
_apiClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
|
||||
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||
_sendspinMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
|
||||
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||
_sendspinMessagesReceived++;
|
||||
if (_sendspinClientSocket != null) {
|
||||
// Forward binary audio data to Sendspin client
|
||||
_sendspinClientSocket!.add(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 8: Start health check timer
|
||||
_startHealthCheck();
|
||||
|
||||
return _port;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Start failed: $e');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.unknown, e.toString());
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with the MA server over WebRTC.
|
||||
Future<bool> _authenticate(String username, String password) async {
|
||||
try {
|
||||
// Step 1: Login to get access token
|
||||
_logger.log('RemoteBridge: Sending auth/login...');
|
||||
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_name': 'Ensemble Mobile App',
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: Login result: $loginResult');
|
||||
|
||||
// Check for error response
|
||||
if (loginResult.containsKey('error_code')) {
|
||||
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
String? accessToken;
|
||||
if (loginResult['result'] != null) {
|
||||
final result = loginResult['result'];
|
||||
if (result is Map<String, dynamic>) {
|
||||
if (result['success'] == true) {
|
||||
accessToken = result['access_token'] as String?;
|
||||
final user = result['user'] as Map<String, dynamic>?;
|
||||
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accessToken == null) {
|
||||
_logger.log('RemoteBridge: No access token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Authenticate the session with the token
|
||||
_logger.log('RemoteBridge: Authenticating session with token...');
|
||||
final authResult = await _webrtcConnection!.sendRequest('auth', {
|
||||
'token': accessToken,
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: Auth result: $authResult');
|
||||
|
||||
if (authResult.containsKey('error_code')) {
|
||||
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Session authenticated successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Auth error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming HTTP requests and upgrade to WebSocket.
|
||||
void _handleHttpRequest(HttpRequest request) async {
|
||||
final path = request.uri.path;
|
||||
_logger.log('RemoteBridge: Incoming HTTP request: $path');
|
||||
|
||||
if (WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
try {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
|
||||
// Route based on path: /sendspin for audio, anything else for API
|
||||
if (path == '/sendspin') {
|
||||
_handleSendspinConnection(socket);
|
||||
} else {
|
||||
_handleApiConnection(socket);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
|
||||
request.response.statusCode = HttpStatus.internalServerError;
|
||||
await request.response.close();
|
||||
}
|
||||
} else {
|
||||
// Not a WebSocket request
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a new API WebSocket client connection.
|
||||
void _handleApiConnection(WebSocket socket) {
|
||||
_logger.log('RemoteBridge: API client connected');
|
||||
|
||||
// Only allow one API client at a time
|
||||
if (_apiClientSocket != null) {
|
||||
_logger.log('RemoteBridge: Closing existing API client connection');
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSocket?.close();
|
||||
}
|
||||
|
||||
_apiClientSocket = socket;
|
||||
|
||||
// Step 1: Send cached server hello immediately
|
||||
if (_cachedServerHello != null) {
|
||||
_logger.log('RemoteBridge: Sending cached server hello to API client');
|
||||
_apiClientSocket!.add(_cachedServerHello!);
|
||||
}
|
||||
|
||||
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
|
||||
_apiClientSubscription = _apiClientSocket!.listen(
|
||||
(message) {
|
||||
if (message is String && _webrtcConnection != null) {
|
||||
_apiMessagesSent++;
|
||||
_webrtcConnection!.sendRaw(message);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('RemoteBridge: API client disconnected');
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription = null;
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('RemoteBridge: API client error: $error');
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
|
||||
void _handleSendspinConnection(WebSocket socket) {
|
||||
_logger.log('RemoteBridge: Sendspin client connected');
|
||||
|
||||
// Only allow one Sendspin client at a time
|
||||
if (_sendspinClientSocket != null) {
|
||||
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSocket?.close();
|
||||
}
|
||||
|
||||
_sendspinClientSocket = socket;
|
||||
|
||||
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
|
||||
_sendspinClientSubscription = _sendspinClientSocket!.listen(
|
||||
(message) {
|
||||
if (_webrtcConnection != null) {
|
||||
_sendspinMessagesSent++;
|
||||
if (message is String) {
|
||||
// Text message (JSON control)
|
||||
_webrtcConnection!.sendSendspinText(message);
|
||||
} else if (message is List<int>) {
|
||||
// Binary message (would be audio data from client, unlikely but handle it)
|
||||
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('RemoteBridge: Sendspin client disconnected');
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription = null;
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('RemoteBridge: Sendspin client error: $error');
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle WebRTC disconnection - attempt reconnection.
|
||||
void _handleWebRTCDisconnection() {
|
||||
_logger.log('RemoteBridge: WebRTC disconnected');
|
||||
|
||||
// Don't close WS clients immediately - they will reconnect when WebRTC is back
|
||||
// Instead, attempt to reconnect WebRTC
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Schedule a WebRTC reconnection attempt.
|
||||
void _scheduleReconnect() {
|
||||
if (_isReconnecting) {
|
||||
_logger.log('RemoteBridge: Already reconnecting, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_remoteId == null || _username == null || _password == null) {
|
||||
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
|
||||
_closeAllClients();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
|
||||
_closeAllClients();
|
||||
return;
|
||||
}
|
||||
|
||||
_setState(RemoteBridgeState.reconnecting);
|
||||
_reconnectAttempts++;
|
||||
_totalReconnections++;
|
||||
|
||||
// Use longer delay if rate limited, otherwise exponential backoff
|
||||
final Duration delay;
|
||||
if (_isRateLimited) {
|
||||
delay = _rateLimitDelay;
|
||||
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
|
||||
} else {
|
||||
delay = _reconnectDelay * _reconnectAttempts;
|
||||
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, _reconnectWebRTC);
|
||||
}
|
||||
|
||||
/// Attempt to reconnect WebRTC.
|
||||
Future<void> _reconnectWebRTC() async {
|
||||
if (_isReconnecting) return;
|
||||
_isReconnecting = true;
|
||||
|
||||
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
|
||||
|
||||
try {
|
||||
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
|
||||
_apiWebrtcSubscription?.cancel();
|
||||
_sendspinTextSubscription?.cancel();
|
||||
_sendspinBinarySubscription?.cancel();
|
||||
await _webrtcConnection?.disconnect();
|
||||
|
||||
// Reuse existing WebView connection (WebView is already in widget tree)
|
||||
// If somehow null, create a new one (shouldn't happen in normal flow)
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
|
||||
_webrtcConnection!.onStateChanged = (state) {
|
||||
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
|
||||
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||
state == WebViewWebRTCConnectionState.failed) {
|
||||
_isReconnecting = false;
|
||||
_handleWebRTCDisconnection();
|
||||
} else if (state == WebViewWebRTCConnectionState.connected) {
|
||||
_reconnectAttempts = 0; // Reset on successful connection
|
||||
}
|
||||
};
|
||||
|
||||
_webrtcConnection!.onError = (error) {
|
||||
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
|
||||
// Detect rate limiting from signaling server
|
||||
if (error.toLowerCase().contains('rate limit')) {
|
||||
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
|
||||
_isRateLimited = true;
|
||||
}
|
||||
};
|
||||
|
||||
final connected = await _webrtcConnection!.connect(_remoteId!);
|
||||
if (!connected) {
|
||||
_logger.log('RemoteBridge: WebRTC reconnection failed');
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
|
||||
|
||||
// Re-authenticate
|
||||
final authenticated = await _authenticate(_username!, _password!);
|
||||
if (!authenticated) {
|
||||
_logger.log('RemoteBridge: Re-authentication failed');
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Re-authentication successful');
|
||||
|
||||
// Re-cache server hello
|
||||
if (_webrtcConnection!.serverInfo != null) {
|
||||
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||
_logger.log('RemoteBridge: Re-cached server hello');
|
||||
}
|
||||
|
||||
// Re-subscribe to WebRTC streams
|
||||
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||
_apiMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_apiClientSocket != null) {
|
||||
_apiClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||
_sendspinMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||
_sendspinMessagesReceived++;
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(data);
|
||||
}
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
|
||||
_setState(RemoteBridgeState.connected);
|
||||
_isReconnecting = false;
|
||||
_reconnectAttempts = 0;
|
||||
_isRateLimited = false;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Reconnection error: $e');
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all WS clients (called when giving up on reconnection).
|
||||
void _closeAllClients() {
|
||||
_logger.log('RemoteBridge: Closing all WS clients');
|
||||
|
||||
_apiClientSocket?.close();
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSubscription = null;
|
||||
|
||||
_sendspinClientSocket?.close();
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSubscription = null;
|
||||
}
|
||||
|
||||
/// Start periodic health check to monitor connection state
|
||||
void _startHealthCheck() {
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
|
||||
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
|
||||
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
|
||||
final timeSinceLastMsg = _lastMessageReceived != null
|
||||
? DateTime.now().difference(_lastMessageReceived!).inSeconds
|
||||
: -1;
|
||||
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
|
||||
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
|
||||
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
|
||||
'lastRecv:${timeSinceLastMsg}s ago');
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the bridge and disconnect from the remote server.
|
||||
Future<void> stop() async {
|
||||
_logger.log('RemoteBridge: Stopping bridge');
|
||||
_setState(RemoteBridgeState.disconnected);
|
||||
|
||||
// Cancel reconnection
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_isReconnecting = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = null;
|
||||
|
||||
// Cancel API subscriptions
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSubscription = null;
|
||||
_apiWebrtcSubscription?.cancel();
|
||||
_apiWebrtcSubscription = null;
|
||||
|
||||
// Cancel Sendspin subscriptions
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSubscription = null;
|
||||
_sendspinTextSubscription?.cancel();
|
||||
_sendspinTextSubscription = null;
|
||||
_sendspinBinarySubscription?.cancel();
|
||||
_sendspinBinarySubscription = null;
|
||||
|
||||
// Close client sockets
|
||||
await _apiClientSocket?.close();
|
||||
_apiClientSocket = null;
|
||||
await _sendspinClientSocket?.close();
|
||||
_sendspinClientSocket = null;
|
||||
|
||||
await _server?.close(force: true);
|
||||
_server = null;
|
||||
_port = null;
|
||||
|
||||
await _webrtcConnection?.disconnect();
|
||||
_webrtcConnection = null;
|
||||
|
||||
_cachedServerHello = null;
|
||||
|
||||
// Clear connection parameters
|
||||
_remoteId = null;
|
||||
_username = null;
|
||||
_password = null;
|
||||
|
||||
_logger.log('RemoteBridge: Bridge stopped');
|
||||
}
|
||||
|
||||
/// Dispose the bridge and close all stream controllers.
|
||||
void dispose() {
|
||||
stop();
|
||||
_stateController.close();
|
||||
_errorController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
import 'package:ensemble/services/remote/webrtc_connection.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Manages remote connections to MA servers via WebRTC
|
||||
class RemoteConnectionManager {
|
||||
final WebRTCConnection _connection = WebRTCConnection();
|
||||
|
||||
StreamSubscription? _messageSubscription;
|
||||
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
bool _isAuthenticated = false;
|
||||
String? _serverVersion;
|
||||
|
||||
// Callbacks
|
||||
Function(bool connected)? onConnectionChanged;
|
||||
Function(String error)? onError;
|
||||
|
||||
bool get isConnected => _connection.isConnected && _isAuthenticated;
|
||||
Stream<Map<String, dynamic>> get events => _eventController.stream;
|
||||
String? get serverVersion => _serverVersion;
|
||||
|
||||
RemoteConnectionManager() {
|
||||
_connection.onStateChanged = _onConnectionStateChanged;
|
||||
_connection.onError = (error) => onError?.call(error);
|
||||
|
||||
// Forward events from the data channel
|
||||
_messageSubscription = _connection.messages.listen((message) {
|
||||
// Check if this is an event (has 'event' key)
|
||||
if (message.containsKey('event')) {
|
||||
_eventController.add(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect to a remote MA server using Remote Access ID
|
||||
Future<bool> connect(String remoteId, String username, String password) async {
|
||||
DebugLogger().log('RemoteConnection: Connecting to remote ID: $remoteId');
|
||||
|
||||
// Connect WebRTC
|
||||
final connected = await _connection.connect(remoteId);
|
||||
if (!connected) {
|
||||
DebugLogger().log('RemoteConnection: WebRTC connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugLogger().log('RemoteConnection: WebRTC connected, authenticating...');
|
||||
|
||||
// Authenticate with MA
|
||||
try {
|
||||
final authenticated = await _authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
DebugLogger().log('RemoteConnection: Authentication failed');
|
||||
await _connection.disconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isAuthenticated = true;
|
||||
DebugLogger().log('RemoteConnection: Fully connected and authenticated');
|
||||
onConnectionChanged?.call(true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('RemoteConnection: Auth error: $e');
|
||||
await _connection.disconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with the MA server
|
||||
Future<bool> _authenticate(String username, String password) async {
|
||||
try {
|
||||
// Step 1: Login to get access token
|
||||
DebugLogger().log('RemoteConnection: Sending auth/login...');
|
||||
final loginResult = await _connection.sendRequest('auth/login', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_name': 'Ensemble Mobile App',
|
||||
});
|
||||
|
||||
DebugLogger().log('RemoteConnection: Login result: $loginResult');
|
||||
|
||||
// Check for error response
|
||||
if (loginResult.containsKey('error_code')) {
|
||||
DebugLogger().log('RemoteConnection: Login error: ${loginResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
String? accessToken;
|
||||
if (loginResult['result'] != null) {
|
||||
final result = loginResult['result'];
|
||||
|
||||
if (result is Map<String, dynamic>) {
|
||||
if (result['success'] == true) {
|
||||
accessToken = result['access_token'] as String?;
|
||||
final user = result['user'] as Map<String, dynamic>?;
|
||||
|
||||
DebugLogger().log('RemoteConnection: Login successful!');
|
||||
DebugLogger().log('RemoteConnection: User: ${user?['username']} (${user?['role']})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accessToken == null) {
|
||||
DebugLogger().log('RemoteConnection: No access token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Authenticate the session with the token
|
||||
DebugLogger().log('RemoteConnection: Authenticating session with token...');
|
||||
final authResult = await _connection.sendRequest('auth', {
|
||||
'token': accessToken,
|
||||
});
|
||||
|
||||
DebugLogger().log('RemoteConnection: Auth result: $authResult');
|
||||
|
||||
if (authResult.containsKey('error_code')) {
|
||||
DebugLogger().log('RemoteConnection: Session auth failed: ${authResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the token for later use
|
||||
_accessToken = accessToken;
|
||||
DebugLogger().log('RemoteConnection: Session authenticated successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('RemoteConnection: Login error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String? _accessToken;
|
||||
|
||||
/// Send a command to the MA server
|
||||
Future<Map<String, dynamic>> sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic>? params,
|
||||
]) async {
|
||||
if (!_connection.isConnected) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
return await _connection.sendRequest(command, params);
|
||||
}
|
||||
|
||||
/// Subscribe to events from the server
|
||||
Future<void> subscribe(List<String> eventTypes) async {
|
||||
await sendCommand('subscribe_events', {
|
||||
'event_types': eventTypes,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the remote server
|
||||
Future<void> disconnect() async {
|
||||
_isAuthenticated = false;
|
||||
await _connection.disconnect();
|
||||
onConnectionChanged?.call(false);
|
||||
}
|
||||
|
||||
void _onConnectionStateChanged(WebRTCConnectionState state) {
|
||||
DebugLogger().log('RemoteConnection: State changed to $state');
|
||||
|
||||
if (state == WebRTCConnectionState.disconnected ||
|
||||
state == WebRTCConnectionState.failed) {
|
||||
_isAuthenticated = false;
|
||||
onConnectionChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageSubscription?.cancel();
|
||||
_eventController.close();
|
||||
_connection.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Message types for the MA signaling protocol
|
||||
class SignalingMessageType {
|
||||
static const String ping = 'ping';
|
||||
static const String pong = 'pong';
|
||||
static const String connectRequest = 'connect-request';
|
||||
static const String connected = 'connected';
|
||||
static const String offer = 'offer';
|
||||
static const String answer = 'answer';
|
||||
static const String iceCandidate = 'ice-candidate';
|
||||
static const String peerDisconnected = 'peer-disconnected';
|
||||
static const String error = 'error';
|
||||
}
|
||||
|
||||
/// ICE server configuration from signaling
|
||||
class IceServer {
|
||||
final List<String> urls;
|
||||
final String? username;
|
||||
final String? credential;
|
||||
|
||||
IceServer({
|
||||
required this.urls,
|
||||
this.username,
|
||||
this.credential,
|
||||
});
|
||||
|
||||
factory IceServer.fromJson(Map<String, dynamic> json) {
|
||||
// Handle urls as either String or List<String>
|
||||
final urlsRaw = json['urls'];
|
||||
final List<String> urls;
|
||||
if (urlsRaw is String) {
|
||||
urls = [urlsRaw];
|
||||
} else if (urlsRaw is List) {
|
||||
urls = urlsRaw.cast<String>();
|
||||
} else {
|
||||
urls = [];
|
||||
}
|
||||
|
||||
return IceServer(
|
||||
urls: urls,
|
||||
username: json['username'] as String?,
|
||||
credential: json['credential'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toWebRtcConfig() {
|
||||
final config = <String, dynamic>{'urls': urls};
|
||||
if (username != null) config['username'] = username;
|
||||
if (credential != null) config['credential'] = credential;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/// Client for connecting to the MA signaling server
|
||||
class SignalingClient {
|
||||
static const String _signalingUrl = 'wss://signaling.music-assistant.io/ws';
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _pingTimer;
|
||||
|
||||
String? _sessionId;
|
||||
String? _remoteId;
|
||||
List<IceServer> _iceServers = [];
|
||||
bool _isConnected = false;
|
||||
|
||||
// Callbacks
|
||||
Function(String sessionId, List<IceServer> iceServers)? onConnected;
|
||||
Function(Map<String, dynamic> answer)? onAnswer;
|
||||
Function(Map<String, dynamic> candidate)? onIceCandidate;
|
||||
Function(String error)? onError;
|
||||
Function()? onPeerDisconnected;
|
||||
Function()? onDisconnected;
|
||||
|
||||
String? get sessionId => _sessionId;
|
||||
List<IceServer> get iceServers => _iceServers;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Connect to the signaling server and request connection to a remote MA server
|
||||
Future<bool> connect(String remoteId) async {
|
||||
DebugLogger().log('Signaling: Connecting to $_signalingUrl');
|
||||
|
||||
try {
|
||||
// Normalize remote ID (remove dashes, uppercase)
|
||||
final normalizedId = remoteId.replaceAll('-', '').toUpperCase();
|
||||
_remoteId = normalizedId;
|
||||
DebugLogger().log('Signaling: Remote ID: $normalizedId');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_signalingUrl));
|
||||
await _channel!.ready;
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: (error) {
|
||||
DebugLogger().log('Signaling: WebSocket error: $error');
|
||||
onError?.call(error.toString());
|
||||
_cleanup();
|
||||
},
|
||||
onDone: () {
|
||||
DebugLogger().log('Signaling: WebSocket closed');
|
||||
onDisconnected?.call();
|
||||
_cleanup();
|
||||
},
|
||||
);
|
||||
|
||||
// Start ping timer to keep connection alive
|
||||
_startPingTimer();
|
||||
|
||||
// Send connect request
|
||||
_send({
|
||||
'type': SignalingMessageType.connectRequest,
|
||||
'remoteId': normalizedId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('Signaling: Failed to connect: $e');
|
||||
onError?.call(e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC offer to the remote server
|
||||
void sendOffer(Map<String, dynamic> offer) {
|
||||
if (_sessionId == null || _remoteId == null) {
|
||||
DebugLogger().log('Signaling: Cannot send offer - no session');
|
||||
return;
|
||||
}
|
||||
|
||||
_send({
|
||||
'type': SignalingMessageType.offer,
|
||||
'remoteId': _remoteId,
|
||||
'sessionId': _sessionId,
|
||||
'data': offer,
|
||||
});
|
||||
}
|
||||
|
||||
/// Send ICE candidate to the remote server
|
||||
void sendIceCandidate(Map<String, dynamic> candidate) {
|
||||
if (_sessionId == null || _remoteId == null) {
|
||||
DebugLogger().log('Signaling: Cannot send ICE candidate - no session');
|
||||
return;
|
||||
}
|
||||
|
||||
_send({
|
||||
'type': SignalingMessageType.iceCandidate,
|
||||
'remoteId': _remoteId,
|
||||
'sessionId': _sessionId,
|
||||
'data': candidate,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the signaling server
|
||||
void disconnect() {
|
||||
DebugLogger().log('Signaling: Disconnecting');
|
||||
_cleanup();
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
final type = message['type'] as String?;
|
||||
|
||||
DebugLogger().log('Signaling: Received message type: $type');
|
||||
|
||||
switch (type) {
|
||||
case SignalingMessageType.ping:
|
||||
// Server is pinging us - respond with pong to keep connection alive
|
||||
_send({'type': SignalingMessageType.pong});
|
||||
break;
|
||||
|
||||
case SignalingMessageType.pong:
|
||||
// Ignore pong responses to our pings
|
||||
break;
|
||||
|
||||
case SignalingMessageType.connected:
|
||||
_handleConnected(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.answer:
|
||||
_handleAnswer(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.iceCandidate:
|
||||
_handleIceCandidate(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.peerDisconnected:
|
||||
DebugLogger().log('Signaling: Peer disconnected');
|
||||
onPeerDisconnected?.call();
|
||||
break;
|
||||
|
||||
case SignalingMessageType.error:
|
||||
final error = message['error'] as String? ?? 'Unknown error';
|
||||
DebugLogger().log('Signaling: Error from server: $error');
|
||||
onError?.call(error);
|
||||
break;
|
||||
|
||||
default:
|
||||
DebugLogger().log('Signaling: Unknown message type: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger().log('Signaling: Failed to handle message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnected(Map<String, dynamic> message) {
|
||||
_sessionId = message['sessionId'] as String?;
|
||||
_isConnected = true;
|
||||
|
||||
// Parse ICE servers
|
||||
final iceServersJson = message['iceServers'] as List<dynamic>?;
|
||||
if (iceServersJson != null) {
|
||||
_iceServers = iceServersJson
|
||||
.map((s) => IceServer.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
DebugLogger().log('Signaling: Connected with session $_sessionId');
|
||||
DebugLogger().log('Signaling: Received ${_iceServers.length} ICE servers');
|
||||
|
||||
onConnected?.call(_sessionId!, _iceServers);
|
||||
}
|
||||
|
||||
void _handleAnswer(Map<String, dynamic> message) {
|
||||
final answer = message['data'] as Map<String, dynamic>?;
|
||||
if (answer != null) {
|
||||
DebugLogger().log('Signaling: Received answer');
|
||||
onAnswer?.call(answer);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIceCandidate(Map<String, dynamic> message) {
|
||||
final candidate = message['data'] as Map<String, dynamic>?;
|
||||
if (candidate != null) {
|
||||
DebugLogger().log('Signaling: Received ICE candidate');
|
||||
onIceCandidate?.call(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Map<String, dynamic> message) {
|
||||
if (_channel == null) return;
|
||||
|
||||
final json = jsonEncode(message);
|
||||
DebugLogger().log('Signaling: Sending ${message['type']}');
|
||||
_channel!.sink.add(json);
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
// Send ping every 15 seconds to keep connection alive
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
_send({'type': SignalingMessageType.ping});
|
||||
});
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_sessionId = null;
|
||||
_remoteId = null;
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:ensemble/services/remote/signaling_client.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// WebRTC connection state
|
||||
enum WebRTCConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Manages a WebRTC peer connection for remote MA access
|
||||
class WebRTCConnection {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _dataChannel;
|
||||
RTCDataChannel? _sendspinDataChannel;
|
||||
final SignalingClient _signalingClient = SignalingClient();
|
||||
final _uuid = const Uuid();
|
||||
|
||||
WebRTCConnectionState _state = WebRTCConnectionState.disconnected;
|
||||
Completer<bool>? _connectionCompleter;
|
||||
bool _serverHelloReceived = false;
|
||||
Completer<void>? _serverHelloCompleter;
|
||||
|
||||
// Message handling for MA API
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||
|
||||
// Sendspin message handling (text and binary)
|
||||
final _sendspinTextController = StreamController<String>.broadcast();
|
||||
final _sendspinBinaryController = StreamController<Uint8List>.broadcast();
|
||||
|
||||
// Server info
|
||||
Map<String, dynamic>? serverInfo;
|
||||
|
||||
// Callbacks
|
||||
Function(WebRTCConnectionState state)? onStateChanged;
|
||||
Function(String error)? onError;
|
||||
|
||||
WebRTCConnectionState get state => _state;
|
||||
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||
Stream<String> get rawMessages => _rawMessageController.stream;
|
||||
bool get isConnected => _state == WebRTCConnectionState.connected;
|
||||
|
||||
// Sendspin channel streams
|
||||
Stream<String> get sendspinTextMessages => _sendspinTextController.stream;
|
||||
Stream<Uint8List> get sendspinBinaryMessages => _sendspinBinaryController.stream;
|
||||
bool get isSendspinConnected =>
|
||||
_sendspinDataChannel?.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||
|
||||
/// Connect to a remote MA server using the Remote Access ID
|
||||
Future<bool> connect(String remoteId) async {
|
||||
if (_state == WebRTCConnectionState.connecting) {
|
||||
DebugLogger().log('WebRTC: Already connecting');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setState(WebRTCConnectionState.connecting);
|
||||
_connectionCompleter = Completer<bool>();
|
||||
|
||||
try {
|
||||
// Set up signaling callbacks
|
||||
_signalingClient.onConnected = _onSignalingConnected;
|
||||
_signalingClient.onAnswer = _onAnswer;
|
||||
_signalingClient.onIceCandidate = _onRemoteIceCandidate;
|
||||
_signalingClient.onError = _onSignalingError;
|
||||
_signalingClient.onPeerDisconnected = _onPeerDisconnected;
|
||||
_signalingClient.onDisconnected = _onSignalingDisconnected;
|
||||
|
||||
// Connect to signaling server
|
||||
final connected = await _signalingClient.connect(remoteId);
|
||||
if (!connected) {
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for connection to complete (with timeout)
|
||||
final result = await _connectionCompleter!.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
DebugLogger().log('WebRTC: Connection timeout');
|
||||
_onSignalingError('Connection timeout');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Connection error: $e');
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
onError?.call(e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and wait for response
|
||||
Future<Map<String, dynamic>> sendRequest(
|
||||
String command, [
|
||||
Map<String, dynamic>? params,
|
||||
]) async {
|
||||
if (_dataChannel == null || _dataChannel!.state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
throw Exception('Data channel not open');
|
||||
}
|
||||
|
||||
final messageId = _uuid.v4();
|
||||
final request = {
|
||||
'message_id': messageId,
|
||||
'command': command,
|
||||
if (params != null) 'args': params,
|
||||
};
|
||||
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[messageId] = completer;
|
||||
|
||||
final jsonStr = jsonEncode(request);
|
||||
DebugLogger().log('WebRTC: Sending request: $jsonStr');
|
||||
_dataChannel!.send(RTCDataChannelMessage(jsonStr));
|
||||
|
||||
// Timeout after 30 seconds
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
_pendingRequests.remove(messageId);
|
||||
throw TimeoutException('Request timed out: $command');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a raw message without waiting for response (for bridge passthrough)
|
||||
void sendRaw(String message) {
|
||||
if (_dataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send raw - data channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _dataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send raw - data channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLogger().log('WebRTC: Sending raw: ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
_dataChannel!.send(RTCDataChannelMessage(message));
|
||||
}
|
||||
|
||||
/// Check if the data channel is healthy
|
||||
bool get isDataChannelHealthy {
|
||||
if (_dataChannel == null) return false;
|
||||
return _dataChannel!.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||
}
|
||||
|
||||
/// Get data channel state for debugging
|
||||
String get dataChannelStateDebug {
|
||||
if (_dataChannel == null) return 'null';
|
||||
return _dataChannel!.state.toString();
|
||||
}
|
||||
|
||||
/// Disconnect from the remote server
|
||||
Future<void> disconnect() async {
|
||||
DebugLogger().log('WebRTC: Disconnecting');
|
||||
|
||||
_signalingClient.disconnect();
|
||||
await _dataChannel?.close();
|
||||
await _sendspinDataChannel?.close();
|
||||
await _peerConnection?.close();
|
||||
|
||||
_dataChannel = null;
|
||||
_sendspinDataChannel = null;
|
||||
_peerConnection = null;
|
||||
|
||||
// Complete all pending requests with an error before clearing
|
||||
_completePendingRequestsWithError('WebRTC connection disconnected');
|
||||
|
||||
_serverHelloReceived = false;
|
||||
_serverHelloCompleter = null;
|
||||
serverInfo = null;
|
||||
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Complete all pending requests with an error message
|
||||
void _completePendingRequestsWithError(String reason) {
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.completeError(Exception(reason));
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
}
|
||||
|
||||
void _setState(WebRTCConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
DebugLogger().log('WebRTC: State changed: $_state -> $newState');
|
||||
_state = newState;
|
||||
onStateChanged?.call(newState);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSignalingConnected(String sessionId, List<IceServer> iceServers) async {
|
||||
DebugLogger().log('WebRTC: Signaling connected, creating peer connection');
|
||||
|
||||
try {
|
||||
// Configure Android audio to NOT manage focus since we only use data channels
|
||||
// This prevents flutter_webrtc from interfering with system audio/speakers
|
||||
await AndroidNativeAudioManagement.setAndroidAudioConfiguration(
|
||||
AndroidAudioConfiguration(
|
||||
manageAudioFocus: false,
|
||||
),
|
||||
);
|
||||
DebugLogger().log('WebRTC: Android audio configured - manageAudioFocus: false');
|
||||
|
||||
// Create peer connection with ICE servers
|
||||
final config = <String, dynamic>{
|
||||
'iceServers': iceServers.map((s) => s.toWebRtcConfig()).toList(),
|
||||
'sdpSemantics': 'unified-plan',
|
||||
};
|
||||
|
||||
DebugLogger().log('WebRTC: ICE servers: ${config['iceServers']}');
|
||||
|
||||
_peerConnection = await createPeerConnection(config);
|
||||
|
||||
// Set up peer connection callbacks
|
||||
_peerConnection!.onIceCandidate = _onLocalIceCandidate;
|
||||
_peerConnection!.onIceConnectionState = _onIceConnectionState;
|
||||
_peerConnection!.onConnectionState = _onConnectionState;
|
||||
_peerConnection!.onDataChannel = _onDataChannel;
|
||||
|
||||
// Create data channel for MA API
|
||||
final dataChannelInit = RTCDataChannelInit()
|
||||
..ordered = true
|
||||
..protocol = 'ma-api';
|
||||
|
||||
_dataChannel = await _peerConnection!.createDataChannel('ma-api', dataChannelInit);
|
||||
_setupDataChannel(_dataChannel!);
|
||||
|
||||
// TEST: Sendspin channel DISABLED to test if multi-channel bug causes disconnects
|
||||
// If stability improves, the flutter_webrtc multi-channel bug is confirmed
|
||||
DebugLogger().log('WebRTC: ⚠️ TEST BUILD - Sendspin channel DISABLED (single channel mode)');
|
||||
// final sendspinChannelInit = RTCDataChannelInit()
|
||||
// ..ordered = true
|
||||
// ..protocol = 'sendspin';
|
||||
// _sendspinDataChannel = await _peerConnection!.createDataChannel('sendspin', sendspinChannelInit);
|
||||
// _setupSendspinDataChannel(_sendspinDataChannel!);
|
||||
|
||||
// Create and send offer
|
||||
final offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
DebugLogger().log('WebRTC: Sending offer');
|
||||
_signalingClient.sendOffer({
|
||||
'type': offer.type,
|
||||
'sdp': offer.sdp,
|
||||
});
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to create peer connection: $e');
|
||||
_onSignalingError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAnswer(Map<String, dynamic> answer) async {
|
||||
DebugLogger().log('WebRTC: Received answer');
|
||||
|
||||
try {
|
||||
final description = RTCSessionDescription(
|
||||
answer['sdp'] as String?,
|
||||
answer['type'] as String?,
|
||||
);
|
||||
await _peerConnection?.setRemoteDescription(description);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to set remote description: $e');
|
||||
_onSignalingError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onLocalIceCandidate(RTCIceCandidate candidate) {
|
||||
DebugLogger().log('WebRTC: Local ICE candidate: ${candidate.candidate?.substring(0, 50)}...');
|
||||
|
||||
_signalingClient.sendIceCandidate({
|
||||
'candidate': candidate.candidate,
|
||||
'sdpMid': candidate.sdpMid,
|
||||
'sdpMLineIndex': candidate.sdpMLineIndex,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onRemoteIceCandidate(Map<String, dynamic> candidateData) async {
|
||||
DebugLogger().log('WebRTC: Remote ICE candidate received');
|
||||
|
||||
try {
|
||||
final candidate = RTCIceCandidate(
|
||||
candidateData['candidate'] as String?,
|
||||
candidateData['sdpMid'] as String?,
|
||||
candidateData['sdpMLineIndex'] as int?,
|
||||
);
|
||||
await _peerConnection?.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to add ICE candidate: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onIceConnectionState(RTCIceConnectionState state) {
|
||||
DebugLogger().log('WebRTC: ICE connection state: $state');
|
||||
|
||||
switch (state) {
|
||||
case RTCIceConnectionState.RTCIceConnectionStateFailed:
|
||||
_onSignalingError('ICE connection failed');
|
||||
break;
|
||||
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
|
||||
DebugLogger().log('WebRTC: ICE disconnected, may recover');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionState(RTCPeerConnectionState state) {
|
||||
DebugLogger().log('WebRTC: Peer connection state: $state');
|
||||
|
||||
switch (state) {
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||
// Connection established, but wait for data channel
|
||||
break;
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||
_onSignalingError('Peer connection failed');
|
||||
break;
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataChannel(RTCDataChannel channel) {
|
||||
DebugLogger().log('WebRTC: Remote data channel received: ${channel.label}');
|
||||
if (channel.label == 'ma-api') {
|
||||
_dataChannel = channel;
|
||||
_setupDataChannel(channel);
|
||||
} else if (channel.label == 'sendspin') {
|
||||
_sendspinDataChannel = channel;
|
||||
_setupSendspinDataChannel(channel);
|
||||
}
|
||||
}
|
||||
|
||||
void _setupDataChannel(RTCDataChannel channel) {
|
||||
channel.onDataChannelState = (state) {
|
||||
DebugLogger().log('WebRTC: Data channel state: $state');
|
||||
|
||||
if (state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Data channel open - waiting for server hello...');
|
||||
// Don't complete yet - wait for server hello
|
||||
_serverHelloCompleter = Completer<void>();
|
||||
_serverHelloCompleter!.future.then((_) {
|
||||
DebugLogger().log('WebRTC: Server hello received - connection complete!');
|
||||
_setState(WebRTCConnectionState.connected);
|
||||
_safeCompleteConnection(true);
|
||||
});
|
||||
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
|
||||
DebugLogger().log('WebRTC: Data channel closed, completing pending requests with error');
|
||||
_completePendingRequestsWithError('Data channel closed');
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
}
|
||||
};
|
||||
|
||||
channel.onMessage = _onDataChannelMessage;
|
||||
}
|
||||
|
||||
void _onDataChannelMessage(RTCDataChannelMessage message) {
|
||||
try {
|
||||
DebugLogger().log('WebRTC: Raw message: ${message.text.substring(0, message.text.length > 200 ? 200 : message.text.length)}...');
|
||||
final data = jsonDecode(message.text) as Map<String, dynamic>;
|
||||
final messageId = data['message_id']?.toString();
|
||||
|
||||
DebugLogger().log('WebRTC: Parsed message keys: ${data.keys.toList()}, message_id: $messageId');
|
||||
|
||||
// Check for server hello message (has server_id but no message_id)
|
||||
if (data.containsKey('server_id') && messageId == null && !_serverHelloReceived) {
|
||||
DebugLogger().log('WebRTC: Server hello received - server_version: ${data['server_version']}');
|
||||
serverInfo = data;
|
||||
_serverHelloReceived = true;
|
||||
_serverHelloCompleter?.complete();
|
||||
// Don't forward server hello to rawMessages - bridge caches it separately
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a response to a pending request (for internal use like auth)
|
||||
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||
DebugLogger().log('WebRTC: Matched pending request $messageId');
|
||||
_pendingRequests.remove(messageId)?.complete(data);
|
||||
// Don't forward to rawMessages - this was an internal request
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward all other messages to rawMessages stream (for bridge passthrough)
|
||||
_rawMessageController.add(message.text);
|
||||
|
||||
// Also broadcast parsed event/notification for internal listeners
|
||||
DebugLogger().log('WebRTC: No match for message_id=$messageId, pending: ${_pendingRequests.keys.toList()}');
|
||||
_messageController.add(data);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to parse message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup Sendspin data channel for audio streaming
|
||||
void _setupSendspinDataChannel(RTCDataChannel channel) {
|
||||
channel.onDataChannelState = (state) {
|
||||
DebugLogger().log('WebRTC: Sendspin data channel state: $state');
|
||||
};
|
||||
|
||||
channel.onMessage = _onSendspinMessage;
|
||||
}
|
||||
|
||||
/// Handle incoming Sendspin messages (both text and binary)
|
||||
void _onSendspinMessage(RTCDataChannelMessage message) {
|
||||
if (message.isBinary) {
|
||||
// Binary audio data
|
||||
final bytes = message.binary;
|
||||
if (bytes != null && !_sendspinBinaryController.isClosed) {
|
||||
_sendspinBinaryController.add(bytes);
|
||||
}
|
||||
} else {
|
||||
// Text control message (JSON)
|
||||
final text = message.text;
|
||||
DebugLogger().log('WebRTC: Sendspin text message: ${text.substring(0, text.length > 100 ? 100 : text.length)}...');
|
||||
if (!_sendspinTextController.isClosed) {
|
||||
_sendspinTextController.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a text message to Sendspin channel (JSON control messages)
|
||||
void sendSendspinText(String message) {
|
||||
if (_sendspinDataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin text - channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _sendspinDataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin text - channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
_sendspinDataChannel!.send(RTCDataChannelMessage(message));
|
||||
}
|
||||
|
||||
/// Send binary data to Sendspin channel (audio data from local player)
|
||||
void sendSendspinBinary(Uint8List data) {
|
||||
if (_sendspinDataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _sendspinDataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
_sendspinDataChannel!.send(RTCDataChannelMessage.fromBinary(data));
|
||||
}
|
||||
|
||||
void _onSignalingError(String error) {
|
||||
DebugLogger().log('WebRTC: Signaling error: $error');
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
onError?.call(error);
|
||||
_safeCompleteConnection(false);
|
||||
}
|
||||
|
||||
void _onPeerDisconnected() {
|
||||
DebugLogger().log('WebRTC: Peer disconnected, completing pending requests with error');
|
||||
_completePendingRequestsWithError('Remote peer disconnected');
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
onError?.call('Remote server disconnected');
|
||||
}
|
||||
|
||||
void _onSignalingDisconnected() {
|
||||
DebugLogger().log('WebRTC: Signaling disconnected');
|
||||
// Only set failed if we were still connecting
|
||||
if (_state == WebRTCConnectionState.connecting) {
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
_safeCompleteConnection(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely complete the connection completer (only once)
|
||||
void _safeCompleteConnection(bool success) {
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.complete(success);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageController.close();
|
||||
_rawMessageController.close();
|
||||
_sendspinTextController.close();
|
||||
_sendspinBinaryController.close();
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ class SendspinService {
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 10;
|
||||
static const Duration _baseReconnectDelay = Duration(seconds: 3);
|
||||
static const Duration _maxReconnectDelay = Duration(seconds: 60);
|
||||
|
||||
// Connection state
|
||||
SendspinConnectionState _state = SendspinConnectionState.disconnected;
|
||||
@@ -154,7 +158,8 @@ class SendspinService {
|
||||
}
|
||||
|
||||
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
|
||||
Future<bool> connectWithUrl(String wsUrl) async {
|
||||
/// If useProxyAuth is true, will authenticate with MA token (needed for proxy connections)
|
||||
Future<bool> connectWithUrl(String wsUrl, {bool useProxyAuth = false}) async {
|
||||
if (_isDisposed) return false;
|
||||
if (_state == SendspinConnectionState.connected) return true;
|
||||
|
||||
@@ -173,7 +178,7 @@ class SendspinService {
|
||||
|
||||
_logger.log('Sendspin: Connecting with URL: $wsUrl');
|
||||
|
||||
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
|
||||
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5), useProxyAuth: useProxyAuth);
|
||||
|
||||
if (!connected) {
|
||||
_updateState(SendspinConnectionState.error);
|
||||
@@ -278,6 +283,7 @@ class SendspinService {
|
||||
|
||||
_logger.log('Sendspin: Connected and registered successfully');
|
||||
_updateState(SendspinConnectionState.connected);
|
||||
_resetReconnectAttempts();
|
||||
_startHeartbeat();
|
||||
|
||||
return true;
|
||||
@@ -558,6 +564,7 @@ class SendspinService {
|
||||
'state': playerState,
|
||||
'volume': _volume,
|
||||
'muted': _isMuted,
|
||||
'position': _position,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -626,6 +633,7 @@ class SendspinService {
|
||||
'state': 'synchronized',
|
||||
'volume': _volume,
|
||||
'muted': _isMuted,
|
||||
'position': _position,
|
||||
},
|
||||
},
|
||||
}, allowDuringHandshake: true);
|
||||
@@ -637,12 +645,27 @@ class SendspinService {
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
/// Schedule reconnection attempt
|
||||
/// Schedule reconnection attempt with exponential backoff
|
||||
void _scheduleReconnect() {
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
_logger.log('Sendspin: Max reconnection attempts reached ($_maxReconnectAttempts), giving up');
|
||||
_updateState(SendspinConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectAttempts++;
|
||||
|
||||
// Calculate exponential backoff delay: 3s, 6s, 12s, 24s, 48s, then cap at 60s
|
||||
final delaySeconds = (_baseReconnectDelay.inSeconds * (1 << (_reconnectAttempts - 1)))
|
||||
.clamp(0, _maxReconnectDelay.inSeconds);
|
||||
final delay = Duration(seconds: delaySeconds);
|
||||
|
||||
_logger.log('Sendspin: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
if (!_isDisposed && _state != SendspinConnectionState.connected) {
|
||||
_logger.log('Sendspin: Attempting reconnection...');
|
||||
if (_connectedUrl != null) {
|
||||
@@ -652,11 +675,19 @@ class SendspinService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset reconnection attempts counter (call on successful connection)
|
||||
void _resetReconnectAttempts() {
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/// Update connection state
|
||||
void _updateState(SendspinConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
// Don't add events if controller is closed (disposed)
|
||||
if (!_isDisposed) {
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class SettingsService {
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
static const String _keyAuthServerUrl = 'auth_server_url';
|
||||
static const String _keyWebSocketPort = 'websocket_port';
|
||||
static const String _keyRemoteAccessId = 'remote_access_id'; // MA Remote Access ID for WebRTC
|
||||
static const String _keyAuthToken = 'auth_token';
|
||||
static const String _keyMaAuthToken = 'ma_auth_token'; // Music Assistant native auth token
|
||||
static const String _keyAuthCredentials = 'auth_credentials'; // NEW: Serialized auth strategy credentials
|
||||
@@ -92,6 +93,7 @@ class SettingsService {
|
||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
||||
static const String _keySmartSortPlayers = 'smart_sort_players';
|
||||
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
@@ -161,8 +163,13 @@ class SettingsService {
|
||||
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
||||
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
|
||||
|
||||
// Music Provider Filter Settings
|
||||
static const String _keyEnabledMusicProviders = 'enabled_music_providers'; // JSON list of provider instance IDs
|
||||
static const String _keyDiscoveredMusicProviders = 'discovered_music_providers'; // JSON list of {instanceId, domain, name}
|
||||
|
||||
// Hint System Settings
|
||||
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
||||
static const String _keyShowProviderIcons = 'show_provider_icons'; // Show provider icons on album art
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||
|
||||
@@ -187,6 +194,26 @@ class SettingsService {
|
||||
await prefs.remove(_keyServerUrl);
|
||||
}
|
||||
|
||||
// Remote Access ID for WebRTC-based remote connections
|
||||
static Future<String?> getRemoteAccessId() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyRemoteAccessId);
|
||||
}
|
||||
|
||||
static Future<void> setRemoteAccessId(String? id) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (id == null || id.isEmpty) {
|
||||
await prefs.remove(_keyRemoteAccessId);
|
||||
} else {
|
||||
await prefs.setString(_keyRemoteAccessId, id);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clearRemoteAccessId() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyRemoteAccessId);
|
||||
}
|
||||
|
||||
// Get authentication server URL (returns null if not set, meaning use server URL)
|
||||
static Future<String?> getAuthServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
@@ -356,6 +383,10 @@ class SettingsService {
|
||||
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
||||
|
||||
// Disable Auto-Switch - prevents automatic switching to playing players
|
||||
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
|
||||
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
|
||||
|
||||
// Helper to create player name with possessive apostrophe
|
||||
// Automatically detects Phone vs Tablet based on screen size
|
||||
static String _makePlayerName(String ownerName) {
|
||||
@@ -789,6 +820,81 @@ class SettingsService {
|
||||
await setEnabledAbsLibraries(enabled);
|
||||
}
|
||||
|
||||
// Music Provider Filter Settings
|
||||
|
||||
/// Get list of enabled music provider instance IDs (null = all enabled)
|
||||
/// Returns null if no filter is set (all providers enabled by default)
|
||||
static Future<List<String>?> getEnabledMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyEnabledMusicProviders);
|
||||
if (json == null) return null; // null means all providers are enabled
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set list of enabled music provider instance IDs
|
||||
/// Pass null or call clearEnabledMusicProviders() to enable all providers
|
||||
static Future<void> setEnabledMusicProviders(List<String> providerIds) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyEnabledMusicProviders, jsonEncode(providerIds));
|
||||
}
|
||||
|
||||
/// Clear enabled providers (reverts to all enabled)
|
||||
static Future<void> clearEnabledMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyEnabledMusicProviders);
|
||||
}
|
||||
|
||||
/// Get discovered music providers [{instanceId, domain, name}]
|
||||
static Future<List<Map<String, String>>?> getDiscoveredMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveredMusicProviders);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save discovered music providers
|
||||
static Future<void> setDiscoveredMusicProviders(List<Map<String, String>> providers) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyDiscoveredMusicProviders, jsonEncode(providers));
|
||||
}
|
||||
|
||||
/// Check if a specific music provider is enabled
|
||||
static Future<bool> isMusicProviderEnabled(String instanceId) async {
|
||||
final enabled = await getEnabledMusicProviders();
|
||||
if (enabled == null) return true; // All enabled by default
|
||||
return enabled.contains(instanceId);
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider
|
||||
/// allProviderIds should contain all available provider instance IDs
|
||||
static Future<void> toggleMusicProvider(String instanceId, bool enable, List<String> allProviderIds) async {
|
||||
var enabled = await getEnabledMusicProviders();
|
||||
// If null (all enabled), initialize with all provider IDs
|
||||
enabled ??= List.from(allProviderIds);
|
||||
|
||||
if (enable && !enabled.contains(instanceId)) {
|
||||
enabled.add(instanceId);
|
||||
} else if (!enable && enabled.contains(instanceId)) {
|
||||
// Prevent disabling the last provider
|
||||
if (enabled.length <= 1) {
|
||||
return; // Don't allow disabling the last provider
|
||||
}
|
||||
enabled.remove(instanceId);
|
||||
}
|
||||
|
||||
await setEnabledMusicProviders(enabled);
|
||||
}
|
||||
|
||||
// Hint System Settings
|
||||
|
||||
/// Get whether hints are enabled (default: true)
|
||||
@@ -803,6 +909,24 @@ class SettingsService {
|
||||
await prefs.setBool(_keyShowHints, show);
|
||||
}
|
||||
|
||||
/// Get whether provider icons are shown on album art (default: true)
|
||||
static Future<bool> getShowProviderIcons() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyShowProviderIcons) ?? true;
|
||||
}
|
||||
|
||||
/// Synchronous getter for provider icons setting (uses cached prefs)
|
||||
/// Returns true if prefs not yet initialized
|
||||
static bool getShowProviderIconsSync() {
|
||||
return _prefsCache?.getBool(_keyShowProviderIcons) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether provider icons are shown on album art
|
||||
static Future<void> setShowProviderIcons(bool show) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyShowProviderIcons, show);
|
||||
}
|
||||
|
||||
/// Check if user has ever used the player reveal gesture
|
||||
static Future<bool> getHasUsedPlayerReveal() async {
|
||||
final prefs = await _getPrefs();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../database/database.dart' show BatchCacheItem;
|
||||
import '../models/media_item.dart';
|
||||
import 'database_service.dart';
|
||||
import 'debug_logger.dart';
|
||||
@@ -17,6 +18,7 @@ enum SyncStatus {
|
||||
|
||||
/// Service for background library synchronization
|
||||
/// Loads from database cache first, then syncs from MA API in background
|
||||
/// Supports per-provider sync for accurate client-side filtering
|
||||
class SyncService with ChangeNotifier {
|
||||
static SyncService? _instance;
|
||||
static final _logger = DebugLogger();
|
||||
@@ -42,6 +44,13 @@ class SyncService with ChangeNotifier {
|
||||
List<Audiobook> _cachedAudiobooks = [];
|
||||
List<Playlist> _cachedPlaylists = [];
|
||||
|
||||
// Source provider tracking for client-side filtering
|
||||
// Maps itemId -> list of provider instance IDs that provided the item
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
|
||||
// Getters
|
||||
SyncStatus get status => _status;
|
||||
String? get lastError => _lastError;
|
||||
@@ -54,8 +63,15 @@ class SyncService with ChangeNotifier {
|
||||
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
|
||||
// Source provider getters for client-side filtering
|
||||
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
|
||||
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
|
||||
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
|
||||
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
|
||||
|
||||
/// Load library data from database cache (instant)
|
||||
/// Call this on app startup for immediate data
|
||||
/// Also loads source provider info for client-side filtering
|
||||
Future<void> loadFromCache() async {
|
||||
if (!_db.isInitialized) {
|
||||
_logger.log('⚠️ Database not initialized, skipping cache load');
|
||||
@@ -65,52 +81,73 @@ class SyncService with ChangeNotifier {
|
||||
try {
|
||||
_logger.log('📦 Loading library from database cache...');
|
||||
|
||||
// Load albums from cache
|
||||
final albumData = await _db.getCachedItems('album');
|
||||
_cachedAlbums = albumData.map((data) {
|
||||
// Load albums from cache with source providers
|
||||
final albumDataWithProviders = await _db.getCachedItemsWithProviders('album');
|
||||
_cachedAlbums = [];
|
||||
_albumSourceProviders = {};
|
||||
for (final (data, providers) in albumDataWithProviders) {
|
||||
try {
|
||||
return Album.fromJson(data);
|
||||
final album = Album.fromJson(data);
|
||||
_cachedAlbums.add(album);
|
||||
if (providers.isNotEmpty) {
|
||||
_albumSourceProviders[album.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached album: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Album>().toList();
|
||||
}
|
||||
|
||||
// Load artists from cache
|
||||
final artistData = await _db.getCachedItems('artist');
|
||||
_cachedArtists = artistData.map((data) {
|
||||
// Load artists from cache with source providers
|
||||
final artistDataWithProviders = await _db.getCachedItemsWithProviders('artist');
|
||||
_cachedArtists = [];
|
||||
_artistSourceProviders = {};
|
||||
for (final (data, providers) in artistDataWithProviders) {
|
||||
try {
|
||||
return Artist.fromJson(data);
|
||||
final artist = Artist.fromJson(data);
|
||||
_cachedArtists.add(artist);
|
||||
if (providers.isNotEmpty) {
|
||||
_artistSourceProviders[artist.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached artist: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Artist>().toList();
|
||||
}
|
||||
|
||||
// Load audiobooks from cache
|
||||
final audiobookData = await _db.getCachedItems('audiobook');
|
||||
_cachedAudiobooks = audiobookData.map((data) {
|
||||
// Load audiobooks from cache with source providers
|
||||
final audiobookDataWithProviders = await _db.getCachedItemsWithProviders('audiobook');
|
||||
_cachedAudiobooks = [];
|
||||
_audiobookSourceProviders = {};
|
||||
for (final (data, providers) in audiobookDataWithProviders) {
|
||||
try {
|
||||
return Audiobook.fromJson(data);
|
||||
final audiobook = Audiobook.fromJson(data);
|
||||
_cachedAudiobooks.add(audiobook);
|
||||
if (providers.isNotEmpty) {
|
||||
_audiobookSourceProviders[audiobook.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached audiobook: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Audiobook>().toList();
|
||||
}
|
||||
|
||||
// Load playlists from cache
|
||||
final playlistData = await _db.getCachedItems('playlist');
|
||||
_cachedPlaylists = playlistData.map((data) {
|
||||
// Load playlists from cache with source providers
|
||||
final playlistDataWithProviders = await _db.getCachedItemsWithProviders('playlist');
|
||||
_cachedPlaylists = [];
|
||||
_playlistSourceProviders = {};
|
||||
for (final (data, providers) in playlistDataWithProviders) {
|
||||
try {
|
||||
return Playlist.fromJson(data);
|
||||
final playlist = Playlist.fromJson(data);
|
||||
_cachedPlaylists.add(playlist);
|
||||
if (providers.isNotEmpty) {
|
||||
_playlistSourceProviders[playlist.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached playlist: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Playlist>().toList();
|
||||
}
|
||||
|
||||
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
|
||||
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to load from cache: $e');
|
||||
@@ -119,7 +156,12 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
/// Sync library data from MA API in background
|
||||
/// Updates database cache and notifies listeners when complete
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
|
||||
/// [providerInstanceIds] - list of provider IDs to sync (fetches each provider separately for accurate source tracking)
|
||||
Future<void> syncFromApi(
|
||||
MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
if (_isSyncing) {
|
||||
_logger.log('🔄 Sync already in progress, skipping');
|
||||
return;
|
||||
@@ -155,33 +197,193 @@ class SyncService with ChangeNotifier {
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
// Fetch fresh data from MA API (in parallel for speed)
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
// Only clear cache for full syncs (no specific providers)
|
||||
// Partial syncs preserve items from disabled providers for instant toggle-on
|
||||
final isPartialSync = providerInstanceIds != null && providerInstanceIds.isNotEmpty;
|
||||
if (!isPartialSync) {
|
||||
await _db.clearCacheForType('album');
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
}
|
||||
|
||||
final albums = results[0] as List<Album>;
|
||||
final artists = results[1] as List<Artist>;
|
||||
final audiobooks = results[2] as List<Audiobook>;
|
||||
final playlists = results[3] as List<Playlist>;
|
||||
// Build NEW source tracking maps - don't modify existing until sync is complete
|
||||
// This prevents UI flicker from partial tracking during sync
|
||||
final syncingProviders = providerInstanceIds?.toSet() ?? <String>{};
|
||||
final newAlbumSourceProviders = <String, List<String>>{};
|
||||
final newArtistSourceProviders = <String, List<String>>{};
|
||||
final newAudiobookSourceProviders = <String, List<String>>{};
|
||||
final newPlaylistSourceProviders = <String, List<String>>{};
|
||||
|
||||
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
||||
// For partial syncs, copy tracking from non-syncing providers
|
||||
if (isPartialSync) {
|
||||
for (final entry in _albumSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newAlbumSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _artistSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newArtistSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _audiobookSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newAudiobookSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _playlistSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newPlaylistSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear old cache before saving fresh data (removes stale items)
|
||||
await _db.clearCacheForType('album');
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
// Collect all items (deduped by itemId, but tracking all source providers)
|
||||
final albumMap = <String, Album>{};
|
||||
final artistMap = <String, Artist>{};
|
||||
final audiobookMap = <String, Audiobook>{};
|
||||
final playlistMap = <String, Playlist>{};
|
||||
|
||||
// Save to database cache
|
||||
await _saveAlbumsToCache(albums);
|
||||
await _saveArtistsToCache(artists);
|
||||
await _saveAudiobooksToCache(audiobooks);
|
||||
await _savePlaylistsToCache(playlists);
|
||||
// If specific providers are requested, sync each separately for accurate source tracking
|
||||
// Use smaller batches in remote mode to avoid WebRTC message size issues
|
||||
final limit = api.isRemoteMode ? 100 : 1000;
|
||||
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers (limit: $limit, remote: ${api.isRemoteMode})');
|
||||
|
||||
for (final providerId in providerInstanceIds) {
|
||||
_logger.log(' 📡 Syncing provider: $providerId');
|
||||
|
||||
List<Album> albums;
|
||||
List<Artist> artists;
|
||||
List<Audiobook> audiobooks;
|
||||
List<Playlist> playlists;
|
||||
|
||||
// In remote mode, do sequential requests to avoid overwhelming WebRTC
|
||||
if (api.isRemoteMode) {
|
||||
albums = await api.getAlbums(limit: limit, providerInstanceIds: [providerId]);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
audiobooks = await api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
playlists = await api.getPlaylists(limit: limit, providerInstanceIds: [providerId]);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
} else {
|
||||
// Local mode: parallel requests for speed
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: limit, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
|
||||
api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]),
|
||||
api.getPlaylists(limit: limit, providerInstanceIds: [providerId]),
|
||||
]);
|
||||
|
||||
albums = results[0] as List<Album>;
|
||||
artists = results[1] as List<Artist>;
|
||||
audiobooks = results[2] as List<Audiobook>;
|
||||
playlists = results[3] as List<Playlist>;
|
||||
}
|
||||
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Add to maps and track source provider in NEW tracking maps
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
newAlbumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!newAlbumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
newAlbumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final artist in artists) {
|
||||
artistMap[artist.itemId] = artist;
|
||||
newArtistSourceProviders.putIfAbsent(artist.itemId, () => []);
|
||||
if (!newArtistSourceProviders[artist.itemId]!.contains(providerId)) {
|
||||
newArtistSourceProviders[artist.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final audiobook in audiobooks) {
|
||||
audiobookMap[audiobook.itemId] = audiobook;
|
||||
newAudiobookSourceProviders.putIfAbsent(audiobook.itemId, () => []);
|
||||
if (!newAudiobookSourceProviders[audiobook.itemId]!.contains(providerId)) {
|
||||
newAudiobookSourceProviders[audiobook.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final playlist in playlists) {
|
||||
playlistMap[playlist.itemId] = playlist;
|
||||
newPlaylistSourceProviders.putIfAbsent(playlist.itemId, () => []);
|
||||
if (!newPlaylistSourceProviders[playlist.itemId]!.contains(providerId)) {
|
||||
newPlaylistSourceProviders[playlist.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No provider filter - fetch all at once (faster, but no source tracking)
|
||||
_logger.log('📡 Fetching from all providers (no source tracking, limit: $limit)');
|
||||
|
||||
// In remote mode, do sequential requests to avoid overwhelming WebRTC
|
||||
if (api.isRemoteMode) {
|
||||
_logger.log('🌐 Remote mode: using sequential requests with delays');
|
||||
|
||||
final albums = await api.getAlbums(limit: limit);
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
}
|
||||
_logger.log(' 📥 Got ${albums.length} albums');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums);
|
||||
for (final artist in artists) {
|
||||
artistMap[artist.itemId] = artist;
|
||||
}
|
||||
_logger.log(' 📥 Got ${artists.length} artists');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final audiobooks = await api.getAudiobooks(limit: limit);
|
||||
for (final audiobook in audiobooks) {
|
||||
audiobookMap[audiobook.itemId] = audiobook;
|
||||
}
|
||||
_logger.log(' 📥 Got ${audiobooks.length} audiobooks');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final playlists = await api.getPlaylists(limit: limit);
|
||||
for (final playlist in playlists) {
|
||||
playlistMap[playlist.itemId] = playlist;
|
||||
}
|
||||
_logger.log(' 📥 Got ${playlists.length} playlists');
|
||||
} else {
|
||||
// Local mode: parallel requests for speed
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: limit),
|
||||
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAudiobooks(limit: limit),
|
||||
api.getPlaylists(limit: limit),
|
||||
]);
|
||||
|
||||
for (final album in results[0] as List<Album>) {
|
||||
albumMap[album.itemId] = album;
|
||||
}
|
||||
for (final artist in results[1] as List<Artist>) {
|
||||
artistMap[artist.itemId] = artist;
|
||||
}
|
||||
for (final audiobook in results[2] as List<Audiobook>) {
|
||||
audiobookMap[audiobook.itemId] = audiobook;
|
||||
}
|
||||
for (final playlist in results[3] as List<Playlist>) {
|
||||
playlistMap[playlist.itemId] = playlist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final albums = albumMap.values.toList();
|
||||
final artists = artistMap.values.toList();
|
||||
final audiobooks = audiobookMap.values.toList();
|
||||
final playlists = playlistMap.values.toList();
|
||||
|
||||
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
|
||||
|
||||
// Save to database cache with source provider info (using NEW tracking maps)
|
||||
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
|
||||
await _saveArtistsToCache(artists, newArtistSourceProviders);
|
||||
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
|
||||
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
|
||||
|
||||
// Update sync metadata
|
||||
await _db.updateSyncMetadata('albums', albums.length);
|
||||
@@ -190,14 +392,49 @@ class SyncService with ChangeNotifier {
|
||||
await _db.updateSyncMetadata('playlists', playlists.length);
|
||||
|
||||
// Update in-memory cache
|
||||
_cachedAlbums = albums;
|
||||
_cachedArtists = artists;
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
if (isPartialSync) {
|
||||
// Partial sync: merge with existing cache to preserve items from disabled providers
|
||||
final albumIds = albums.map((a) => a.itemId).toSet();
|
||||
final artistIds = artists.map((a) => a.itemId).toSet();
|
||||
final audiobookIds = audiobooks.map((a) => a.itemId).toSet();
|
||||
final playlistIds = playlists.map((p) => p.itemId).toSet();
|
||||
|
||||
// Keep items not in current sync, add/update items from sync
|
||||
_cachedAlbums = [
|
||||
..._cachedAlbums.where((a) => !albumIds.contains(a.itemId)),
|
||||
...albums,
|
||||
];
|
||||
_cachedArtists = [
|
||||
..._cachedArtists.where((a) => !artistIds.contains(a.itemId)),
|
||||
...artists,
|
||||
];
|
||||
_cachedAudiobooks = [
|
||||
..._cachedAudiobooks.where((a) => !audiobookIds.contains(a.itemId)),
|
||||
...audiobooks,
|
||||
];
|
||||
_cachedPlaylists = [
|
||||
..._cachedPlaylists.where((p) => !playlistIds.contains(p.itemId)),
|
||||
...playlists,
|
||||
];
|
||||
} else {
|
||||
// Full sync: replace entire cache
|
||||
_cachedAlbums = albums;
|
||||
_cachedArtists = artists;
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
}
|
||||
// Atomically swap in the new source tracking maps
|
||||
// This ensures filtering always sees complete data, never partial
|
||||
_albumSourceProviders = newAlbumSourceProviders;
|
||||
_artistSourceProviders = newArtistSourceProviders;
|
||||
_audiobookSourceProviders = newAudiobookSourceProviders;
|
||||
_playlistSourceProviders = newPlaylistSourceProviders;
|
||||
|
||||
_lastSyncTime = DateTime.now();
|
||||
_status = SyncStatus.completed;
|
||||
|
||||
_logger.log('✅ Library sync complete');
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Library sync failed: $e');
|
||||
_status = SyncStatus.error;
|
||||
@@ -208,73 +445,90 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save albums to database cache
|
||||
Future<void> _saveAlbumsToCache(List<Album> albums) async {
|
||||
/// Save albums to database cache with source provider tracking (batched)
|
||||
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final album in albums) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[album.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: jsonEncode(album.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${albums.length} albums to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache albums: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${albums.length} albums to cache');
|
||||
}
|
||||
|
||||
/// Save artists to database cache
|
||||
Future<void> _saveArtistsToCache(List<Artist> artists) async {
|
||||
/// Save artists to database cache with source provider tracking (batched)
|
||||
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final artist in artists) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[artist.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: jsonEncode(artist.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${artists.length} artists to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache artists: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${artists.length} artists to cache');
|
||||
}
|
||||
|
||||
/// Save audiobooks to database cache
|
||||
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
|
||||
/// Save audiobooks to database cache with source provider tracking (batched)
|
||||
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final audiobook in audiobooks) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[audiobook.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: jsonEncode(audiobook.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
|
||||
}
|
||||
|
||||
/// Save playlists to database cache
|
||||
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
|
||||
/// Save playlists to database cache with source provider tracking (batched)
|
||||
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final playlist in playlists) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
|
||||
}
|
||||
final providers = sourceProviders[playlist.itemId] ?? <String>[];
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: jsonEncode(playlist.toJson()),
|
||||
sourceProviders: providers,
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache playlists: $e');
|
||||
}
|
||||
_logger.log('💾 Saved ${playlists.length} playlists to cache');
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
Future<void> forceSync(MusicAssistantAPI api) async {
|
||||
await syncFromApi(api, force: true);
|
||||
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
|
||||
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
|
||||
await syncFromApi(api, force: true, providerInstanceIds: providerInstanceIds);
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
@@ -286,12 +540,79 @@ class SyncService with ChangeNotifier {
|
||||
_cachedArtists = [];
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_albumSourceProviders = {};
|
||||
_artistSourceProviders = {};
|
||||
_audiobookSourceProviders = {};
|
||||
_playlistSourceProviders = {};
|
||||
_lastSyncTime = null;
|
||||
_status = SyncStatus.idle;
|
||||
notifyListeners();
|
||||
_logger.log('🗑️ Library cache cleared');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Client-side filtering methods
|
||||
// ============================================
|
||||
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
/// Empty enabledProviderIds = all providers enabled, show everything
|
||||
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter artists by source provider (instant, no network)
|
||||
List<Artist> getArtistsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedArtists;
|
||||
}
|
||||
return _cachedArtists.where((artist) {
|
||||
final sources = _artistSourceProviders[artist.itemId];
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter audiobooks by source provider (instant, no network)
|
||||
List<Audiobook> getAudiobooksFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedAudiobooks;
|
||||
}
|
||||
return _cachedAudiobooks.where((audiobook) {
|
||||
final sources = _audiobookSourceProviders[audiobook.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter playlists by source provider (instant, no network)
|
||||
List<Playlist> getPlaylistsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedPlaylists;
|
||||
}
|
||||
return _cachedPlaylists.where((playlist) {
|
||||
final sources = _playlistSourceProviders[playlist.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Check if we have source provider tracking data
|
||||
bool get hasSourceTracking =>
|
||||
_albumSourceProviders.isNotEmpty ||
|
||||
_artistSourceProviders.isNotEmpty ||
|
||||
_audiobookSourceProviders.isNotEmpty ||
|
||||
_playlistSourceProviders.isNotEmpty;
|
||||
|
||||
/// Get albums (from cache or empty if not loaded)
|
||||
List<Album> getAlbums() => _cachedAlbums;
|
||||
|
||||
@@ -307,4 +628,25 @@ class SyncService with ChangeNotifier {
|
||||
/// Check if we have data available (from cache or sync)
|
||||
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
|
||||
/// Update cached albums with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedAlbums(List<Album> albums) {
|
||||
_cachedAlbums = albums;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update cached artists with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedArtists(List<Artist> artists) {
|
||||
_cachedArtists = artists;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update cached playlists with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedPlaylists(List<Playlist> playlists) {
|
||||
_cachedPlaylists = playlists;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class AppTheme {
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
@@ -91,7 +91,7 @@ class AppTheme {
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
|
||||
@@ -129,13 +129,31 @@ class Durations {
|
||||
class ButtonSizes {
|
||||
ButtonSizes._();
|
||||
|
||||
// Touch target sizes (Material Design minimum: 48x48)
|
||||
// Touch target sizes
|
||||
// WCAG 2.1 Level AA requires minimum 44x44, Level AAA requires 48x48
|
||||
// Material Design recommends 48x48 minimum
|
||||
// WARNING: sm and md are below WCAG minimum - use only for non-interactive elements
|
||||
|
||||
/// 32x32 - NOT WCAG compliant, use only for decorative/non-interactive elements
|
||||
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
|
||||
static const double sm = 32.0;
|
||||
|
||||
/// 36x36 - NOT WCAG compliant, use only for dense layouts where space is critical
|
||||
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
|
||||
static const double md = 36.0;
|
||||
|
||||
/// 44x44 - Meets WCAG Level AA minimum
|
||||
static const double lg = 44.0;
|
||||
|
||||
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
|
||||
static const double xl = 48.0;
|
||||
|
||||
/// 56x56 - Comfortable touch target for primary actions
|
||||
static const double xxl = 56.0;
|
||||
|
||||
/// Minimum WCAG-compliant touch target size (48x48)
|
||||
static const double minAccessible = 48.0;
|
||||
|
||||
// Icon sizes within buttons
|
||||
static const double iconSm = 16.0;
|
||||
static const double iconMd = 18.0;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Extension methods for formatting Duration values.
|
||||
///
|
||||
/// Provides consistent duration formatting across the codebase,
|
||||
/// replacing duplicate _formatDuration() implementations.
|
||||
extension DurationFormatter on Duration {
|
||||
/// Format duration as "H:MM:SS" or "M:SS" depending on length.
|
||||
///
|
||||
/// Examples:
|
||||
/// - 45 seconds -> "0:45"
|
||||
/// - 3 minutes, 45 seconds -> "3:45"
|
||||
/// - 1 hour, 3 minutes, 45 seconds -> "1:03:45"
|
||||
String toFormattedString() {
|
||||
final hours = inHours;
|
||||
final minutes = inMinutes % 60;
|
||||
final seconds = inSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Format duration as "M:SS" (no hours, minutes not padded).
|
||||
///
|
||||
/// Use this for shorter content like songs where hours are unlikely.
|
||||
String toMinutesSeconds() {
|
||||
final totalSeconds = inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an integer number of seconds as "H:MM:SS" or "M:SS".
|
||||
///
|
||||
/// This is a standalone function for cases where you have raw seconds
|
||||
/// rather than a Duration object.
|
||||
String formatDurationSeconds(int seconds) {
|
||||
return Duration(seconds: seconds).toFormattedString();
|
||||
}
|
||||
|
||||
/// Format a nullable Duration, returning empty string if null.
|
||||
String formatDurationOrEmpty(Duration? duration) {
|
||||
if (duration == null) return '';
|
||||
return duration.toMinutesSeconds();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:collection';
|
||||
|
||||
/// A simple Least Recently Used (LRU) cache implementation.
|
||||
///
|
||||
/// When the cache reaches its maximum size, the least recently used entries
|
||||
/// are automatically evicted to make room for new entries.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final cache = LruCache<String, String>(maxSize: 100);
|
||||
/// cache.put('key', 'value');
|
||||
/// final value = cache.get('key');
|
||||
/// ```
|
||||
class LruCache<K, V> {
|
||||
/// Maximum number of entries to store
|
||||
final int maxSize;
|
||||
|
||||
/// Internal storage using LinkedHashMap for O(1) LRU operations
|
||||
final LinkedHashMap<K, V> _cache = LinkedHashMap<K, V>();
|
||||
|
||||
/// Creates an LRU cache with the specified maximum size.
|
||||
///
|
||||
/// [maxSize] must be greater than 0.
|
||||
LruCache({required this.maxSize}) : assert(maxSize > 0);
|
||||
|
||||
/// Returns the number of entries currently in the cache.
|
||||
int get length => _cache.length;
|
||||
|
||||
/// Returns true if the cache is empty.
|
||||
bool get isEmpty => _cache.isEmpty;
|
||||
|
||||
/// Returns true if the cache is not empty.
|
||||
bool get isNotEmpty => _cache.isNotEmpty;
|
||||
|
||||
/// Returns true if the cache contains an entry for [key].
|
||||
bool containsKey(K key) => _cache.containsKey(key);
|
||||
|
||||
/// Returns the value for [key], or null if not found.
|
||||
///
|
||||
/// This operation marks the entry as recently used.
|
||||
V? get(K key) {
|
||||
if (!_cache.containsKey(key)) {
|
||||
return null;
|
||||
}
|
||||
// Move to end (most recently used) by removing and re-adding
|
||||
final value = _cache.remove(key);
|
||||
_cache[key] = value as V;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Returns the value for [key] without marking it as recently used.
|
||||
///
|
||||
/// Use this when you need to check a value without affecting LRU order.
|
||||
V? peek(K key) => _cache[key];
|
||||
|
||||
/// Stores [value] for [key] in the cache.
|
||||
///
|
||||
/// If [key] already exists, the value is updated and marked as recently used.
|
||||
/// If the cache is full, the least recently used entry is evicted.
|
||||
void put(K key, V value) {
|
||||
// If key exists, remove it first (will be re-added at end)
|
||||
if (_cache.containsKey(key)) {
|
||||
_cache.remove(key);
|
||||
} else if (_cache.length >= maxSize) {
|
||||
// Cache is full, remove least recently used (first entry)
|
||||
_cache.remove(_cache.keys.first);
|
||||
}
|
||||
_cache[key] = value;
|
||||
}
|
||||
|
||||
/// Operator access for getting values (same as [get]).
|
||||
V? operator [](K key) => get(key);
|
||||
|
||||
/// Operator access for setting values (same as [put]).
|
||||
void operator []=(K key, V value) => put(key, value);
|
||||
|
||||
/// Removes the entry for [key] if it exists.
|
||||
///
|
||||
/// Returns the removed value, or null if [key] was not in the cache.
|
||||
V? remove(K key) => _cache.remove(key);
|
||||
|
||||
/// Removes all entries from the cache.
|
||||
void clear() => _cache.clear();
|
||||
|
||||
/// Returns all keys currently in the cache.
|
||||
Iterable<K> get keys => _cache.keys;
|
||||
|
||||
/// Returns all values currently in the cache.
|
||||
Iterable<V> get values => _cache.values;
|
||||
|
||||
/// Returns all entries currently in the cache.
|
||||
Iterable<MapEntry<K, V>> get entries => _cache.entries;
|
||||
}
|
||||
|
||||
/// An LRU cache that can store null values and distinguish them from missing entries.
|
||||
///
|
||||
/// Regular LruCache returns null for both missing keys and keys with null values.
|
||||
/// This class uses a wrapper to distinguish between the two cases.
|
||||
class NullableLruCache<K, V> {
|
||||
final LruCache<K, _CacheEntry<V>> _cache;
|
||||
|
||||
/// Creates a nullable LRU cache with the specified maximum size.
|
||||
NullableLruCache({required int maxSize}) : _cache = LruCache(maxSize: maxSize);
|
||||
|
||||
/// Returns the number of entries currently in the cache.
|
||||
int get length => _cache.length;
|
||||
|
||||
/// Returns true if the cache contains an entry for [key].
|
||||
bool containsKey(K key) => _cache.containsKey(key);
|
||||
|
||||
/// Returns the value for [key], or null if not found.
|
||||
///
|
||||
/// Note: This will also return null if the cached value is null.
|
||||
/// Use [containsKey] to distinguish between missing and null values.
|
||||
V? get(K key) => _cache.get(key)?.value;
|
||||
|
||||
/// Stores [value] for [key] in the cache.
|
||||
///
|
||||
/// The value can be null.
|
||||
void put(K key, V? value) => _cache.put(key, _CacheEntry(value));
|
||||
|
||||
/// Operator access for getting values.
|
||||
V? operator [](K key) => get(key);
|
||||
|
||||
/// Operator access for setting values.
|
||||
void operator []=(K key, V? value) => put(key, value);
|
||||
|
||||
/// Removes the entry for [key] if it exists.
|
||||
V? remove(K key) => _cache.remove(key)?.value;
|
||||
|
||||
/// Removes all entries from the cache.
|
||||
void clear() => _cache.clear();
|
||||
}
|
||||
|
||||
/// Wrapper to store nullable values in the cache
|
||||
class _CacheEntry<V> {
|
||||
final V? value;
|
||||
_CacheEntry(this.value);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class AlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
@@ -40,7 +41,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -123,8 +125,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -151,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.album.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title with year
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Artist name
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -42,7 +42,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -116,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -186,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Artist name - fixed height container so image size is consistent
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
SizedBox(
|
||||
height: 36, // Fixed height for 2 lines of text
|
||||
child: Hero(
|
||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../screens/audiobook_detail_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class AudiobookRow extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -285,6 +286,11 @@ class _AudiobookCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: audiobook.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -103,39 +103,48 @@ class EmptyState extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
// Wrap in scrollable to allow PageView horizontal swipe detection
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(actionLabel!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(actionLabel!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -162,51 +171,60 @@ class _CustomEmptyState extends EmptyState {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final s = S.of(context)!;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
// Wrap in scrollable to allow PageView horizontal swipe detection
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
Spacing.vGap8,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRefresh != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(s.refresh),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
Spacing.vGap8,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRefresh != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(s.refresh),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../services/debug_logger.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
@@ -72,9 +73,9 @@ class DebugConsole extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(40, 20),
|
||||
),
|
||||
child: const Text(
|
||||
'Clear',
|
||||
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||
child: Text(
|
||||
S.of(context)!.clear,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -86,17 +87,17 @@ class DebugConsole extends StatelessWidget {
|
||||
if (logs.isNotEmpty) {
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logs copied to clipboard'),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.logsCopied),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No logs to copy'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.noLogsToCopy),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
|
||||
|
||||
/// Collapse the player if it's expanded
|
||||
static void collapsePlayer() {
|
||||
globalPlayerKey.currentState?.collapse();
|
||||
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||
static void collapsePlayer({bool withHaptic = true}) {
|
||||
globalPlayerKey.currentState?.collapse(withHaptic: withHaptic);
|
||||
}
|
||||
|
||||
/// Force collapse player to mini state, instantly closing queue panel if open.
|
||||
@@ -164,6 +165,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
/// Check if the player reveal is currently visible
|
||||
static bool get isPlayerRevealVisible =>
|
||||
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
||||
|
||||
/// Show player selector for a "Play On" action.
|
||||
/// Instead of the default hints, shows a contextual hint like "Select player to play album".
|
||||
/// When a player is tapped, calls onPlayerSelected instead of switching to that player.
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(dynamic player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
_overlayStateKey.currentState?._showPlayerSelectorForAction(
|
||||
contextHint: contextHint,
|
||||
onPlayerSelected: onPlayerSelected,
|
||||
hintIcon: hintIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
@@ -208,6 +224,11 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
// Key for the reveal overlay
|
||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||
|
||||
// State for "Play On" action - stored here so it can be passed to PlayerRevealOverlay
|
||||
void Function(dynamic player)? _pendingPlayerAction;
|
||||
String? _pendingContextHint;
|
||||
IconData? _pendingHintIcon;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -394,6 +415,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
void _hidePlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
_bounceOffsetNotifier.value = 0;
|
||||
_clearPendingAction(); // Clear pending action when overlay is dismissed
|
||||
setState(() {
|
||||
_isRevealVisible = false;
|
||||
_isOnboardingReveal = false;
|
||||
@@ -413,6 +435,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
_singleBounceController.forward();
|
||||
}
|
||||
|
||||
/// Clear pending action state (called when overlay is dismissed)
|
||||
void _clearPendingAction() {
|
||||
_pendingPlayerAction = null;
|
||||
_pendingContextHint = null;
|
||||
_pendingHintIcon = null;
|
||||
}
|
||||
|
||||
/// Show player selector for a "Play On" action.
|
||||
/// Stores the pending action and shows the reveal overlay with context hint.
|
||||
void _showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(dynamic player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Store pending action state
|
||||
_pendingPlayerAction = onPlayerSelected;
|
||||
_pendingContextHint = contextHint;
|
||||
_pendingHintIcon = hintIcon;
|
||||
|
||||
// Show the reveal overlay
|
||||
_showPlayerReveal();
|
||||
}
|
||||
|
||||
/// End hint mode (called when user taps skip button)
|
||||
void _endHintMode() {
|
||||
if (!_isHintModeActive) return;
|
||||
@@ -485,10 +530,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
child: widget.child,
|
||||
),
|
||||
// Global persistent bottom navigation bar - positioned at bottom
|
||||
// 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 (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
|
||||
// Hide when: not connected OR showing welcome screen
|
||||
// Always show when connected so users can access Settings/Library even without a player
|
||||
if (isConnected && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -536,62 +580,76 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
// Fade out and slide down nav bar as player expands
|
||||
// Use IgnorePointer when faded to prevent accidental taps
|
||||
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
|
||||
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: navOpacity < 0.1,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, navSlideDown),
|
||||
child: Opacity(
|
||||
opacity: navOpacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -627,11 +685,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
duration: const Duration(seconds: 3),
|
||||
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||
builder: (context, opacity, child) {
|
||||
// Use theme-appropriate background color for seamless transition
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final backdropColor = isDark
|
||||
? const Color(0xFF1a1a1a) // Dark theme background
|
||||
: colorScheme.surface; // Light theme surface
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||
child: Container(
|
||||
// Use same dark color as connection screen for seamless transition
|
||||
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
||||
color: backdropColor.withOpacity(opacity),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -727,6 +789,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
showOnboardingHints: _isOnboardingReveal,
|
||||
// Pass pending action parameters for "Play On" functionality
|
||||
onPlayerSelected: _pendingPlayerAction,
|
||||
contextHint: _pendingContextHint,
|
||||
contextHintIcon: _pendingHintIcon,
|
||||
),
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
@@ -740,6 +806,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
// Reset expansion state to prevent stale values from causing
|
||||
// invisible/non-interactive nav bar when connection restores.
|
||||
// This fixes a bug where expanded player state persisted after
|
||||
// disconnect, making nav bar opacity=0 and ignoring taps.
|
||||
if (playerExpansionNotifier.value.progress != 0.0) {
|
||||
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||
/// through alphabetically sorted lists.
|
||||
/// Display mode for the scrollbar popup indicator
|
||||
enum ScrollbarDisplayMode {
|
||||
/// Show first letter of item name (A, B, C, #)
|
||||
letter,
|
||||
/// Show year (2024, 2023, etc.)
|
||||
year,
|
||||
/// Show count number
|
||||
count,
|
||||
/// Hide the popup (just show scrollbar thumb)
|
||||
none,
|
||||
}
|
||||
|
||||
/// A scrollbar that shows a contextual popup when dragging, for fast navigation
|
||||
/// through sorted lists.
|
||||
///
|
||||
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
|
||||
/// section you're currently in via a popup indicator.
|
||||
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows contextual info
|
||||
/// about the current position via a popup indicator. The popup content adapts
|
||||
/// based on the display mode (letters for alphabetical, years for date sorts,
|
||||
/// counts for play count sorts).
|
||||
class LetterScrollbar extends StatefulWidget {
|
||||
/// The scrollable child widget (ListView, GridView, etc.)
|
||||
final Widget child;
|
||||
@@ -16,6 +30,14 @@ class LetterScrollbar extends StatefulWidget {
|
||||
/// Each item's first character is used to determine the letter shown in the popup.
|
||||
final List<String> items;
|
||||
|
||||
/// Display mode for the popup indicator
|
||||
final ScrollbarDisplayMode displayMode;
|
||||
|
||||
/// Custom display labels for each item (optional).
|
||||
/// When provided, these are shown in the popup instead of extracting letters.
|
||||
/// Length must match [items] if provided.
|
||||
final List<String>? displayLabels;
|
||||
|
||||
/// Callback when user taps/drags to a specific index.
|
||||
final void Function(int index)? onScrollToIndex;
|
||||
|
||||
@@ -45,6 +67,8 @@ class LetterScrollbar extends StatefulWidget {
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.items,
|
||||
this.displayMode = ScrollbarDisplayMode.letter,
|
||||
this.displayLabels,
|
||||
this.onScrollToIndex,
|
||||
this.onDragStateChanged,
|
||||
this.itemExtent,
|
||||
@@ -85,7 +109,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||
setState(() {
|
||||
_scrollFraction = newFraction;
|
||||
_currentLetter = _getLetterAtFraction(newFraction);
|
||||
_currentLetter = _getLabelAtFraction(newFraction);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -102,15 +126,32 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
return '#';
|
||||
}
|
||||
|
||||
/// Get the letter at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLetterAtFraction(double fraction) {
|
||||
/// Get the display label at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLabelAtFraction(double fraction) {
|
||||
if (widget.items.isEmpty) return '';
|
||||
|
||||
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
|
||||
|
||||
// Use custom display labels if provided
|
||||
if (widget.displayLabels != null && index < widget.displayLabels!.length) {
|
||||
return widget.displayLabels![index];
|
||||
}
|
||||
|
||||
// Otherwise extract from items based on display mode
|
||||
if (index < widget.items.length) {
|
||||
final item = widget.items[index];
|
||||
if (item.isNotEmpty) {
|
||||
return _normalizeToLetter(item[0]);
|
||||
switch (widget.displayMode) {
|
||||
case ScrollbarDisplayMode.letter:
|
||||
return _normalizeToLetter(item[0]);
|
||||
case ScrollbarDisplayMode.year:
|
||||
case ScrollbarDisplayMode.count:
|
||||
// For year/count modes, the item itself should be the display value
|
||||
return item;
|
||||
case ScrollbarDisplayMode.none:
|
||||
// No popup shown, return empty
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -154,7 +195,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final fraction = clampedPosition / effectiveHeight;
|
||||
|
||||
// Get letter for display
|
||||
final letter = _getLetterAtFraction(fraction);
|
||||
final letter = _getLabelAtFraction(fraction);
|
||||
|
||||
// Scroll proportionally
|
||||
final maxScroll = widget.controller.position.maxScrollExtent;
|
||||
@@ -170,6 +211,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
widget.controller.jumpTo(targetScroll);
|
||||
}
|
||||
|
||||
/// Build the popup bubble with appropriate size and styling based on display mode
|
||||
Widget _buildPopupBubble(ColorScheme colorScheme) {
|
||||
// Determine size and font based on content length and display mode
|
||||
final double width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
|
||||
switch (widget.displayMode) {
|
||||
case ScrollbarDisplayMode.letter:
|
||||
case ScrollbarDisplayMode.none: // Fallback (none won't actually reach here)
|
||||
width = 56;
|
||||
height = 56;
|
||||
fontSize = 28;
|
||||
break;
|
||||
case ScrollbarDisplayMode.year:
|
||||
width = 72;
|
||||
height = 48;
|
||||
fontSize = 22;
|
||||
break;
|
||||
case ScrollbarDisplayMode.count:
|
||||
// Dynamic width based on count length
|
||||
final labelLength = _currentLetter.length;
|
||||
width = labelLength <= 2 ? 56 : (labelLength <= 4 ? 72 : 88);
|
||||
height = 48;
|
||||
fontSize = labelLength <= 3 ? 22 : 18;
|
||||
break;
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_currentLetter,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -236,32 +325,15 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
),
|
||||
),
|
||||
|
||||
// The letter popup bubble
|
||||
if (_isDragging && _currentLetter.isNotEmpty)
|
||||
// The popup bubble (letter, year, or count) - hidden when displayMode is none
|
||||
if (_isDragging && _currentLetter.isNotEmpty && widget.displayMode != ScrollbarDisplayMode.none)
|
||||
Positioned(
|
||||
right: 48,
|
||||
top: (_dragPosition - 28).clamp(
|
||||
0.0,
|
||||
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_currentLetter,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _buildPopupBubble(colorScheme),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/media_item.dart' show Audiobook, Chapter;
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../utils/duration_formatter.dart';
|
||||
import '../common/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -26,18 +27,6 @@ class ChaptersPanel extends StatelessWidget {
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final duration = Duration(seconds: seconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
final secs = duration.inSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${minutes}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chapters = audiobook?.chapters ?? [];
|
||||
@@ -147,7 +136,7 @@ class ChaptersPanel extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
_formatDuration(chapter.positionMs ~/ 1000),
|
||||
formatDurationSeconds(chapter.positionMs ~/ 1000),
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
|
||||
@@ -19,12 +19,25 @@ class PlayerRevealOverlay extends StatefulWidget {
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||
|
||||
/// Callback when player is selected for a pending action (e.g., "Play On").
|
||||
/// If null, selecting a player just switches to that player.
|
||||
final void Function(dynamic player)? onPlayerSelected;
|
||||
|
||||
/// Context hint to display instead of default hints (e.g., "Select player to play album").
|
||||
final String? contextHint;
|
||||
|
||||
/// Icon for context hint (default: play_circle_outline).
|
||||
final IconData? contextHintIcon;
|
||||
|
||||
const PlayerRevealOverlay({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.miniPlayerBottom,
|
||||
required this.miniPlayerHeight,
|
||||
this.showOnboardingHints = false,
|
||||
this.onPlayerSelected,
|
||||
this.contextHint,
|
||||
this.contextHintIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -383,16 +396,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: isOnboarding
|
||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
child: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: isOnboarding
|
||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -436,7 +454,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
if (widget.onPlayerSelected != null) {
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -489,7 +511,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
if (widget.onPlayerSelected != null) {
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
|
||||
@@ -3,9 +3,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../utils/duration_formatter.dart';
|
||||
import '../common/empty_state.dart';
|
||||
import '../global_player_overlay.dart';
|
||||
|
||||
@@ -26,8 +28,8 @@ class QueuePanel extends StatefulWidget {
|
||||
final VoidCallback onRefresh;
|
||||
final ValueChanged<bool>? onDraggingChanged;
|
||||
final VoidCallback? onSwipeStart;
|
||||
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
|
||||
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
|
||||
final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
|
||||
final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
|
||||
|
||||
const QueuePanel({
|
||||
super.key,
|
||||
@@ -54,6 +56,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
List<QueueItem> _items = [];
|
||||
final GlobalKey _stackKey = GlobalKey();
|
||||
|
||||
// Scroll controller to track list position (for swipe-to-close only at top)
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isAtTop = true;
|
||||
bool _wasAtTopOnSwipeStart = true; // Capture at gesture start
|
||||
|
||||
// Transfer dropdown animation
|
||||
late AnimationController _dropdownController;
|
||||
bool _showingTransferDropdown = false;
|
||||
@@ -99,6 +106,17 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
// Track scroll position for swipe-to-close (only allowed at top)
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final atTop = _scrollController.offset <= 0;
|
||||
if (atTop != _isAtTop) {
|
||||
setState(() {
|
||||
_isAtTop = atTop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -134,18 +152,13 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_dropdownController.dispose();
|
||||
_pendingReorderTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) return '';
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleDelete(QueueItem item, int index) async {
|
||||
// Store item for potential rollback
|
||||
@@ -162,9 +175,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
debugPrint('QueuePanel: Delete successful for ${item.track.name}');
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error deleting queue item: $e');
|
||||
// Rollback: re-insert the item or refresh from server
|
||||
// Refresh is safer as queue may have changed
|
||||
widget.onRefresh();
|
||||
@@ -185,7 +196,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandClear(playerId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error clearing queue: $e');
|
||||
// Refresh to restore if failed
|
||||
widget.onRefresh();
|
||||
}
|
||||
@@ -257,7 +267,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Transfer to...',
|
||||
S.of(context)!.transferTo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -271,7 +281,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Text(
|
||||
'No other players available',
|
||||
S.of(context)!.noOtherPlayersAvailable,
|
||||
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
|
||||
),
|
||||
)
|
||||
@@ -346,7 +356,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
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);
|
||||
@@ -357,7 +366,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
// 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');
|
||||
// Error transferring queue - handled silently
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +439,29 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
double _calculateAverageVelocityVertical() {
|
||||
if (_velocitySamples.length < 2) return 0.0;
|
||||
|
||||
// Use weighted average of recent samples (more recent = higher weight)
|
||||
double totalVelocity = 0.0;
|
||||
double totalWeight = 0.0;
|
||||
|
||||
for (int i = 1; i < _velocitySamples.length; i++) {
|
||||
final prev = _velocitySamples[i - 1];
|
||||
final curr = _velocitySamples[i];
|
||||
final dt = curr.timeMs - prev.timeMs;
|
||||
if (dt > 0 && dt < 100) { // Only use samples within 100ms
|
||||
final dy = curr.position.dy - prev.position.dy;
|
||||
final velocity = (dy / dt) * 1000; // px/s
|
||||
final weight = i.toDouble(); // Later samples get higher weight
|
||||
totalVelocity += velocity * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
|
||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
|
||||
if (_dragIndex != null) return;
|
||||
|
||||
@@ -523,11 +555,9 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
final playerId = widget.queue?.playerId;
|
||||
// API uses relative shift: positive = down, negative = up
|
||||
final posShift = newIndex - originalIndex;
|
||||
debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})');
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
debugPrint('QueuePanel: Move API call completed successfully');
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
@@ -537,7 +567,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Move API error: $e');
|
||||
// Clear pending state immediately on error
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -548,7 +577,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
widget.onRefresh();
|
||||
}
|
||||
} else {
|
||||
debugPrint('QueuePanel: playerId is null, cannot move');
|
||||
// Clear pending state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -595,15 +623,71 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error playing index: $e');
|
||||
// Error playing index - clear optimistic state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
|
||||
}
|
||||
|
||||
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
|
||||
if (isCurrentItem) return;
|
||||
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId == null) return;
|
||||
|
||||
// Get current index (where the currently playing track is)
|
||||
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
|
||||
|
||||
// Target position is right after the current track
|
||||
final targetIndex = currentIndex + 1;
|
||||
|
||||
// If already at target position, nothing to do
|
||||
if (index == targetIndex) {
|
||||
// Haptic to confirm it's already in position
|
||||
HapticFeedback.lightImpact();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear optimistic state after delay - server state will take over
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = null;
|
||||
// Calculate the position shift for the API
|
||||
final posShift = targetIndex - index;
|
||||
|
||||
// Cancel any existing timer to prevent stacking
|
||||
_pendingReorderTimer?.cancel();
|
||||
|
||||
// Optimistic update - move item in local list
|
||||
setState(() {
|
||||
final movedItem = _items.removeAt(index);
|
||||
_items.insert(targetIndex > index ? targetIndex - 1 : targetIndex, movedItem);
|
||||
_pendingReorder = true;
|
||||
});
|
||||
|
||||
// Haptic feedback
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Call API
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Clear pending state immediately on error
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
// Refresh queue from server to get correct state
|
||||
widget.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,17 +714,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
_swipeLocked = false;
|
||||
_velocitySamples.clear();
|
||||
_addVelocitySample(event.position, _swipeLastTime!);
|
||||
// Capture scroll position at gesture start - only allow close if already at top
|
||||
_wasAtTopOnSwipeStart = _isAtTop;
|
||||
} else if (startedInEdgeZone) {
|
||||
// Clear swipe state for edge touches
|
||||
_swipeStart = null;
|
||||
}
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
// Ignore swipes from edge zone (let Android back gesture handle it)
|
||||
// Ignore swipes while dragging a queue item
|
||||
if (_swipeStart == null || _dragIndex != null) return;
|
||||
|
||||
final dx = event.position.dx - _swipeStart!.dx;
|
||||
final dy = (event.position.dy - _swipeStart!.dy).abs();
|
||||
final dx = (event.position.dx - _swipeStart!.dx).abs();
|
||||
final dy = event.position.dy - _swipeStart!.dy;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Track all move events for velocity calculation
|
||||
@@ -650,31 +736,31 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
// Once direction is locked, maintain it (prevents accidental cancellation)
|
||||
if (_swipeLocked && _isSwiping) {
|
||||
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
|
||||
widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
|
||||
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
|
||||
// Check if this is a vertical swipe (dy > dx * 1.2)
|
||||
final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
|
||||
|
||||
if (isHorizontal && dx > 0) {
|
||||
// Horizontal swipe right - close gesture
|
||||
if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
|
||||
// Vertical swipe down - close gesture (only when was at top when gesture started)
|
||||
if (!_isSwiping) {
|
||||
_isSwiping = true;
|
||||
_swipeLocked = true; // Lock direction once swipe starts
|
||||
widget.onSwipeStart?.call();
|
||||
}
|
||||
widget.onSwipeUpdate?.call(dx);
|
||||
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
|
||||
// Direction established as vertical - don't start swipe
|
||||
widget.onSwipeUpdate?.call(dy);
|
||||
} else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
|
||||
// Direction established as horizontal - don't start swipe
|
||||
_swipeLocked = true; // Lock as non-swipe
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_isSwiping && _swipeStart != null) {
|
||||
final velocity = _calculateAverageVelocity();
|
||||
final totalDx = event.position.dx - _swipeStart!.dx;
|
||||
widget.onSwipeEnd?.call(velocity, totalDx);
|
||||
final velocity = _calculateAverageVelocityVertical();
|
||||
final totalDy = event.position.dy - _swipeStart!.dy;
|
||||
widget.onSwipeEnd?.call(velocity, totalDy);
|
||||
}
|
||||
_resetSwipeState();
|
||||
},
|
||||
@@ -697,11 +783,11 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
icon: Icon(Icons.keyboard_arrow_down_rounded, color: widget.textColor, size: IconSizes.lg),
|
||||
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
@@ -720,14 +806,14 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
),
|
||||
onPressed: () => _handleTransferQueue(context),
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Transfer queue to another player',
|
||||
tooltip: S.of(context)!.transferQueueToPlayer,
|
||||
),
|
||||
// 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',
|
||||
tooltip: S.of(context)!.clearQueue,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -834,6 +920,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
return ListView.builder(
|
||||
key: const PageStorageKey('queue_list'),
|
||||
controller: _scrollController,
|
||||
padding: Spacing.paddingH8,
|
||||
// Disable scrolling while dragging to prevent gesture conflict
|
||||
physics: _dragIndex != null
|
||||
@@ -857,8 +944,19 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
||||
return Dismissible(
|
||||
key: ValueKey(item.queueItemId),
|
||||
direction: DismissDirection.endToStart,
|
||||
direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
|
||||
// Swipe right (startToEnd) = move to play next
|
||||
background: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.primaryColor,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: const Icon(Icons.skip_next_rounded, color: Colors.white, size: 28),
|
||||
),
|
||||
// Swipe left (endToStart) = delete
|
||||
secondaryBackground: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -870,8 +968,15 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
confirmDismiss: (direction) async {
|
||||
// Block dismissal if swipe started from screen edge (Android back gesture)
|
||||
if (lastTouchWasInEdgeZone) return false;
|
||||
// Don't allow dismissing the currently playing item
|
||||
return !isCurrentItem;
|
||||
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
// Swipe right = move to play next position
|
||||
_handleMoveToPlayNext(item, index, isCurrentItem);
|
||||
return false; // Don't dismiss - item stays in queue (just moved)
|
||||
} else {
|
||||
// Swipe left = delete
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onDismissed: (direction) => _handleDelete(item, index),
|
||||
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
|
||||
@@ -913,7 +1018,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
|
||||
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
|
||||
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
final duration = _formatDuration(item.track.duration);
|
||||
final duration = formatDurationOrEmpty(item.track.duration);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
|
||||
@@ -245,14 +245,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
// Show snackbar for visual feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
content: Text(S.of(context)!.syncingPlayer(player.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Long-press to sync/unsync player
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class PlaylistCard extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
@@ -48,8 +49,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -76,51 +77,56 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.playlist.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.playlist.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class PodcastCard extends StatefulWidget {
|
||||
final MediaItem podcast;
|
||||
@@ -54,8 +55,8 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -82,51 +83,56 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Podcast title
|
||||
Hero(
|
||||
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Podcast author (if available)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Displays a provider icon (SVG) from Music Assistant.
|
||||
///
|
||||
/// Shows a small icon in a rounded container, typically used as an overlay
|
||||
/// on album art to indicate which provider the item comes from.
|
||||
class ProviderIcon extends StatelessWidget {
|
||||
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Size of the icon container
|
||||
final double size;
|
||||
|
||||
/// Whether to use dark mode icon variant
|
||||
final bool? isDark;
|
||||
|
||||
const ProviderIcon({
|
||||
super.key,
|
||||
required this.domain,
|
||||
this.size = 20,
|
||||
this.isDark,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final useDark = isDark ?? brightness == Brightness.dark;
|
||||
|
||||
final svgString = maProvider.getProviderIconSvg(domain, isDark: useDark);
|
||||
|
||||
if (svgString == null || svgString.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SvgPicture.string(
|
||||
svgString,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A positioned provider icon overlay for use in Stack widgets.
|
||||
///
|
||||
/// Typically positioned in a corner of album art.
|
||||
class ProviderIconOverlay extends StatelessWidget {
|
||||
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Size of the icon container
|
||||
final double size;
|
||||
|
||||
/// Position from the edges
|
||||
final double margin;
|
||||
|
||||
const ProviderIconOverlay({
|
||||
super.key,
|
||||
required this.domain,
|
||||
this.size = 20,
|
||||
this.margin = 4,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if provider icons are enabled in settings
|
||||
if (!SettingsService.getShowProviderIconsSync()) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
child: ProviderIcon(
|
||||
domain: domain,
|
||||
size: size,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -225,11 +225,8 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sliderWidth = constraints.maxWidth;
|
||||
// Calculate thumb position to match Flutter Slider's internal positioning
|
||||
// The Slider uses overlayRadius as padding on each side for the track
|
||||
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
|
||||
final thumbPosition = overlayRadius +
|
||||
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
|
||||
// Calculate thumb position for the custom slider track
|
||||
final thumbPosition = sliderWidth * currentVolume.clamp(0.0, 1.0);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -311,22 +308,56 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
},
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
|
||||
),
|
||||
child: AbsorbPointer(
|
||||
// AbsorbPointer prevents Slider from handling gestures
|
||||
// Our GestureDetector handles everything for live updates
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: (_) {},
|
||||
// Custom slider track (no Overlay needed - Flutter 3.38 Slider uses OverlayPortal)
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Inactive track (full width, thinner)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8.5,
|
||||
child: Container(
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Active track
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 7,
|
||||
child: Container(
|
||||
width: sliderWidth * currentVolume.clamp(0.0, 1.0),
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _inPrecisionMode ? accentColor : Colors.white,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Thumb
|
||||
Positioned(
|
||||
left: (sliderWidth * currentVolume.clamp(0.0, 1.0)) - 6,
|
||||
top: 4,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: _inPrecisionMode ? accentColor : Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,23 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "72.0.0"
|
||||
_macros:
|
||||
dependency: transitive
|
||||
description: dart
|
||||
source: sdk
|
||||
version: "0.3.2"
|
||||
version: "91.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.7.0"
|
||||
version: "8.4.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -90,10 +85,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
||||
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "3.0.1"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -114,26 +109,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
||||
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "3.0.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.13"
|
||||
version: "2.6.1"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.2"
|
||||
version: "9.2.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -178,10 +173,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -210,10 +205,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -226,10 +221,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -258,10 +253,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.7"
|
||||
version: "3.1.3"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_webrtc
|
||||
sha256: "4ed7b9fa9924e5a81eb39271e2c2356739dd1039d60a13b86ba6c5f448625086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -282,18 +285,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621
|
||||
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.23.1"
|
||||
version: "2.30.1"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9
|
||||
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.23.1"
|
||||
version: "2.30.1"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -314,10 +317,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -355,6 +358,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_inappwebview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -440,6 +507,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -458,6 +533,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: "792aa1e5838a719f29ae52c0773dbb5dd781fc33b1bf87c321b274e55ab51ad1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.2"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -518,10 +601,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -574,26 +657,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.5"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -610,22 +693,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
macros:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: macros
|
||||
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2-main.4"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.16+1"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -646,10 +721,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -658,6 +733,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.11"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -710,10 +793,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -934,15 +1025,15 @@ packages:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
||||
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
version: "4.2.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1011,26 +1102,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41"
|
||||
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.40.0"
|
||||
version: "0.43.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1067,10 +1158,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.7.7"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1127,14 +1218,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.18"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.16"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1167,6 +1282,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
webrtc_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webrtc_interface
|
||||
sha256: ad0e5786b2acd3be72a3219ef1dde9e1cac071cf4604c685f11b61d63cdd6eb3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1208,5 +1331,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.8-beta+46
|
||||
version: 2.9.1-beta+49
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -11,12 +11,13 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: ^0.19.0
|
||||
intl: ^0.20.0
|
||||
|
||||
# UI
|
||||
material_design_icons_flutter: ^7.0.7296
|
||||
dynamic_color: 1.6.8
|
||||
palette_generator: ^0.3.3+3
|
||||
flutter_svg: ^2.0.10+1
|
||||
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
@@ -25,6 +26,8 @@ dependencies:
|
||||
web_socket_channel: ^2.4.0
|
||||
http: ^1.1.0
|
||||
cached_network_image: ^3.3.1
|
||||
flutter_webrtc: ^0.14.1
|
||||
flutter_inappwebview: ^6.0.0
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
@@ -39,6 +42,7 @@ dependencies:
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^10.1.4
|
||||
mobile_scanner: ^6.0.2
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
@@ -63,6 +67,7 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/webrtc_engine.js
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||