Compare commits

..
27 Commits
Author SHA1 Message Date
Chris 44f35d1a26 WIP on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
Chris 6918569a82 index on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
ChrisandClaude Opus 4.5 8cf35cfe78 Add robust reconnection for remote mode
Bridge improvements:
- Increase max reconnect attempts to 10 with exponential backoff (2s-60s)
- Auto-recover from failed state when app tries to connect
- Health check triggers retry every 60s when in failed state
- Track last reconnect attempt time for smarter retries

App improvements:
- Retry 5 times with exponential backoff on resume (remote mode)
- Add 30s periodic reconnect timer when disconnected in remote mode
- Timer auto-stops on successful connection
- Multiple retry layers ensure eventual recovery

This fixes the issue where network drops would leave the app
permanently disconnected even after network recovery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:40:53 +00:00
ChrisandClaude Opus 4.5 42bc1ed16c Fix remote connection stability issues
- Add 30s server hello timeout to prevent indefinite hangs
- Queue ICE candidates until remote description is set
- Fix stuck "connecting" state blocking future connections
- Properly notify clients when WebRTC fails with error codes
- Close WS clients immediately on WebRTC failure (not cache stale hello)
- Reject new connections during reconnection or failed state
- Add _resetConnection() for clean retry scenarios

WebSocket close codes:
- 4000: WebRTC not healthy
- 4001: Connection lost, reconnecting
- 4002: Reconnection in progress
- 4003: Connection failed
- 4004: Bridge terminated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:54:38 +00:00
ChrisandClaude Opus 4.5 d908cf93a2 Remove HA add-on note - too busy
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:03:43 +00:00
ChrisandClaude Opus 4.5 e21e1eb4fd Update remote ID hint format and add HA add-on note
- Change placeholder to realistic format: ABCD1234-EFGH5-6IJKL-MNOP7890
- Add note: "For Home Assistant add-on users only"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:43:07 +00:00
ChrisandClaude Opus 4.5 9aa81ef09f Make QR button same height as remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:33:55 +00:00
ChrisandClaude Opus 4.5 8d2adb6fc5 Style QR button to match remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:28:13 +00:00
ChrisandClaude Opus 4.5 99ee131712 Move QR scanner to separate button beside remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:15:27 +00:00
ChrisandClaude Opus 4.5 f51ea9cb22 Add QR code scanner for Remote Access ID
- Add QR scanner screen with camera preview and scan overlay
- Wire up QR button to remote ID field on login screen
- Add mobile_scanner dependency
- Add localization strings for scanner UI
- Handle various QR code formats:
  - Raw remote ID
  - ma://remote/<id> URL scheme
  - music-assistant.io URLs with cloudInstanceId param

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:04:42 +00:00
ChrisandClaude Opus 4.5 9773f28cf7 Fix ICE server URL parsing for Nabu Casa users
The signaling server returns URLs as an array for HA Cloud subscribers
but as a string for others. Handle both formats in IceServer.fromJson.

Fixes #31

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:51:32 +00:00
ChrisandClaude Opus 4.5 2856ca1e8b Implement remote connection audit fixes
- WebRTCConnection: Complete pending requests with error on disconnect/peer disconnect
- ErrorHandler: Add WebRTC-specific error types (remote, dataChannel, signaling)
- RemoteBridge: Add RemoteBridgeErrorType, RemoteBridgeState, ConnectionMetrics
  - State and error streams for UI integration
  - Track total reconnection count
- MusicAssistantAPI: Age-based cleanup for pending requests
  - New _PendingRequest class with timestamp
  - Auto-cleanup stale requests older than 2 minutes
- SendspinService: Exponential backoff for reconnection (3s, 6s, 12s... up to 60s)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:42:43 +00:00
ChrisandClaude Opus 4.5 c832502c49 Add default MA port 8095 for local IP addresses
When users enter a local IP address without specifying a port,
auth detection now defaults to port 8095 (MA's default port).
This fixes connection issues for HAOS add-on users who previously
had to manually specify :8095.

Behavior:
- 192.168.4.178 → http://192.168.4.178:8095
- 192.168.4.178:8095 → http://192.168.4.178:8095 (unchanged)
- 192.168.4.178:80 → http://192.168.4.178:80 (explicit port preserved)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:29:48 +00:00
ChrisandClaude Opus 4.5 675779c108 Add remote auto-reconnect on app restart
- Save remote access ID when connecting via remote mode
- Clear remote access ID when connecting via local/direct mode
- On startup, detect if saved URL is a localhost bridge URL
- Re-establish bridge automatically using saved credentials
- Fixes app unable to connect after restart in remote mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:33:00 +00:00
ChrisandClaude Opus 4.5 54a9e6e2b4 Add WebRTC auto-reconnection and increase ping frequency
- Increase signaling ping frequency from 30s to 15s
- Add automatic WebRTC reconnection when connection drops
- Exponential backoff for reconnect attempts (up to 5 tries)
- Preserve WS clients during reconnection for seamless recovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:24:53 +00:00
ChrisandClaude Opus 4.5 870acabad1 Add Sendspin audio tunneling through WebRTC bridge for remote local player
- Add second WebRTC data channel for Sendspin audio streaming
- Extend RemoteBridge to handle /sendspin path separately from API
- Route Sendspin text and binary messages through dedicated channel
- Update provider to connect Sendspin via bridge in remote mode
- Fix authentication flow (save credentials before connecting)
- Add sequential sync requests for remote mode stability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:08:17 +00:00
ChrisandClaude Opus 4.5 d3a5542c68 Remove init.gradle from CI - conflicts with Gradle settings mode
GitHub Actions doesn't need Maven mirrors workaround.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:09:10 +00:00
ChrisandClaude Opus 4.5 b93918219f Update CI to Flutter 3.38.0 for intl compatibility
Flutter 3.24 pins intl to 0.19.0 via flutter_localizations,
but project requires intl ^0.20.0. Flutter 3.38 resolves this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:04:07 +00:00
ChrisandClaude Opus 4.5 a23f9ff05f Add remote access login UI and fix album art for remote mode
- Add Server URL / Remote Access ID toggle to login screen
- Add RemoteBridge connection flow for HA add-on users
- Add isRemoteMode flag to API to detect WebRTC tunnel connections
- Fix album art in remote mode by using external CDN URLs directly
  (Spotify, Tidal, Deezer, Apple Music, Last.fm, MusicBrainz)
- Add debug copy logs button to home screen
- Add localization strings for remote access UI

Note: Local/OpenSubsonic content won't have album art in remote mode
since HTTP image requests can't go through the WebRTC tunnel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 14:58:13 +00:00
ChrisandClaude Opus 4.5 1921a20884 Add remote bridge infrastructure and fix Flutter 3.38 compatibility issues
Remote Bridge:
- Add WebRTC-based remote connection infrastructure
- Add signaling client for MA remote access protocol
- Add RemoteBridge local WebSocket server for transparent tunneling
- Add RemoteConnectionManager for high-level remote API

Flutter 3.38 Fixes:
- Replace Material 3 Slider with custom implementation (fixes Overlay error)
- Custom progress bar and volume slider using Stack/Container
- Add seek debouncing to prevent command timeout on rapid seeks
- Fix position reporting in Sendspin client/state payload

Other Fixes:
- Fix tab swiping scrolling multiple tabs (use default PageScrollPhysics)
- Fix copy logs to use filtered entries instead of all logs
- Revert share logs to simple implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 13:40:51 +00:00
ChrisandClaude Opus 4.5 516faf6f6a chore: Update Gradle, Kotlin, and AGP for Flutter 3.38 compatibility
- Gradle: 8.0 → 8.9
- Android Gradle Plugin: 8.1.0 → 8.7.0
- Kotlin: 1.9.10 → 2.1.0
- intl: 0.19.0 → 0.20.0
- Fix CardTheme → CardThemeData (Flutter API change)

Required for Flutter 3.38.7 compatibility. The older versions cause
build failures with the current Flutter SDK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:21:32 +00:00
ChrisandClaude Opus 4.5 d7b7bbcf0d Fix Sendspin proxy auth for local network connections
Two issues fixed:
1. Version check was looking for MA 2.8.0+ but proxy was added in 2.7.1
2. Local proxy fallback wasn't sending auth token, causing connection rejection

Now local IP connections properly authenticate with the MA token when
falling back to the /sendspin proxy endpoint.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:11:02 +00:00
ChrisandClaude Opus 4.5 3503978185 Fix nav bar hidden when no player selected
Show the bottom navigation bar when connected even without a player
selected. This allows users to access Settings and Library to
troubleshoot connection issues or browse content.

Fixes #52

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:59:32 +00:00
ChrisandClaude Opus 4.5 020ed250f4 Fix library sorting, scrollbar display, and provider management
- Fix audiobook year sorting (ascending/descending support)
- Fix server-handled sort orders (play_count, last_played, etc.) to preserve server order
- Fix letter scrollbar showing irrelevant letters for non-alphabetical sorts
- Add ScrollbarDisplayMode enum for contextual scrollbar popups
- Fix playlists sorting by requesting sorted data from server
- Fix multiple accounts of same service not showing in provider toggles
- Fix stale provider IDs persisting in settings
- Use context.watch instead of Selector for albums/artists tabs
- Use Consumer for menu provider toggles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:10:27 +00:00
ChrisandClaude Opus 4.5 e5be64a6cd fix: Auto-stop foreground service after 30 min idle (fixes #51)
Add idle service timeout to stop the Android foreground service when no
playback occurs for 30 minutes. This saves battery by allowing the
service to be fully stopped rather than persisting indefinitely.

- Add stopService() method to MassivAudioHandler
- Add 30-minute idle timer in MusicAssistantProvider
- Timer starts when playback stops, cancels when playback resumes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:22:48 +00:00
Chris b85fadc551 Update swipe gestures and add provider filters 2026-01-20 20:12:10 +00:00
ChrisandClaude Opus 4.5 3b8339f94f docs: Add screenshots 11 and 12 to README
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:31:09 +00:00
40 changed files with 4772 additions and 827 deletions
+4 -17
View File
@@ -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
+16 -11
View File
@@ -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
+37
View File
@@ -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
+1 -1
View File
@@ -59,7 +59,7 @@ android {
defaultConfig {
applicationId "com.musicassistant.music_assistant"
minSdk 21
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
+2 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
Binary file not shown.
+50
View File
@@ -1653,5 +1653,55 @@
"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"
}
}
+78 -14
View File
@@ -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,
@@ -2120,6 +2122,66 @@ abstract class S {
/// 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> {
@@ -2131,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.');
}
+121 -44
View File
@@ -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,13 +812,15 @@ 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';
@@ -800,7 +829,8 @@ class SDe extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,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';
@@ -901,19 +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';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@@ -945,7 +981,8 @@ 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';
@@ -954,7 +991,8 @@ class SDe extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@@ -972,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) {
@@ -994,19 +1034,22 @@ 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) {
@@ -1112,4 +1155,38 @@ class SDe extends S {
@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';
}
+105 -36
View File
@@ -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,7 +811,8 @@ 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';
@@ -800,7 +821,8 @@ class SEn extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,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';
@@ -901,19 +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';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Unavailable';
@@ -945,7 +973,8 @@ 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';
@@ -954,7 +983,8 @@ class SEn extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Pull to select players';
@@ -972,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) {
@@ -994,19 +1026,22 @@ 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) {
@@ -1112,4 +1147,38 @@ class SEn extends S {
@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';
}
+137 -52
View File
@@ -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,13 +820,15 @@ 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';
@@ -800,7 +837,8 @@ class SEs extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,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';
@@ -901,19 +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';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'No disponible';
@@ -945,7 +989,8 @@ 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';
@@ -954,7 +999,8 @@ class SEs extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@@ -972,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) {
@@ -994,19 +1042,22 @@ 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) {
@@ -1112,4 +1163,38 @@ class SEs extends S {
@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';
}
+155 -61
View File
@@ -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,13 +826,15 @@ 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';
@@ -800,7 +843,8 @@ class SFr extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,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';
@@ -852,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...';
@@ -889,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';
@@ -901,19 +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';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Indisponible';
@@ -945,7 +997,8 @@ 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';
@@ -954,7 +1007,8 @@ class SFr extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@@ -966,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) {
@@ -994,19 +1051,22 @@ 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) {
@@ -1112,4 +1172,38 @@ class SFr extends S {
@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';
}
+25 -1
View File
@@ -330,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(),
),
+21 -6
View File
@@ -31,14 +31,20 @@ class ProviderMapping {
});
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>?,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
inLibrary: inLibrary,
);
}
@@ -440,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) {
@@ -472,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'];
+387 -43
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
@@ -24,6 +25,7 @@ import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../services/remote/remote_bridge.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../main.dart' show audioHandler;
@@ -79,6 +81,10 @@ class MusicAssistantProvider with ChangeNotifier {
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
// Idle service timeout - stops foreground service after 30 min of no playback
Timer? _idleServiceTimer;
static const _idleServiceTimeout = Duration(minutes: 30);
// Local player state
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -101,6 +107,20 @@ class MusicAssistantProvider with ChangeNotifier {
SendspinService? _sendspinService;
bool _sendspinConnected = false;
// Remote mode flag - persisted across reconnections
bool _isRemoteMode = false;
// Remote cloud URL for Sendspin connection (e.g., xxxxxxxx.ui.nabu.casa)
String? _remoteCloudUrl;
// Remote bridge for WebRTC tunnel (kept alive for reconnection)
// Created early so WebView can be added to widget tree before start()
final RemoteBridge _remoteBridge = RemoteBridge();
StreamSubscription<RemoteBridgeState>? _remoteBridgeStateSubscription;
// Remote mode auto-reconnect timer (retries every 30s when disconnected)
Timer? _remoteReconnectTimer;
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
@@ -141,6 +161,10 @@ class MusicAssistantProvider with ChangeNotifier {
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
/// Get the hidden WebView widget for remote bridge WebRTC.
/// This MUST be added to the widget tree for remote connections to work.
Widget get remoteBridgeWebView => _remoteBridge.buildWebView();
// Library getters with provider filtering applied
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
@@ -371,22 +395,40 @@ class MusicAssistantProvider with ChangeNotifier {
// Build list of providers that support this category, with their item counts
// Only include providers that have at least 1 item (hides providers not synced to library)
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
// but we dedupe if names match exactly (same account re-added with new instance ID)
final result = <(ProviderInstance, int)>[];
final addedInstanceIds = <String>{};
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
if (count > 0) {
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
final existingCount = addedNames[provider.name];
if (existingCount == null) {
// First provider with this name
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
} else if (count > existingCount) {
// Same name but more items - likely same account re-added, keep better one
result.removeWhere((r) => r.$1.name == provider.name);
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
addedNames[provider.name] = count;
}
}
}
}
// Also include providers that have items but aren't in _availableMusicProviders
// (e.g., search-only providers like iTunes)
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
// For these synthetic providers, dedupe by domain since we don't have account-specific names
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
final addedSyntheticDomains = <String>{};
for (final entry in counts.entries) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID
@@ -395,6 +437,11 @@ class MusicAssistantProvider with ChangeNotifier {
final domain = instanceId.contains('--')
? instanceId.split('--').first
: instanceId;
// Skip if this domain is already represented by a real provider or synthetic
if (addedRealDomains.contains(domain)) continue;
if (addedSyntheticDomains.contains(domain)) continue;
// Only add if this domain supports the category
final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) {
@@ -407,6 +454,7 @@ class MusicAssistantProvider with ChangeNotifier {
),
entry.value,
));
addedSyntheticDomains.add(domain);
}
}
}
@@ -792,11 +840,102 @@ class MusicAssistantProvider with ChangeNotifier {
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
// Check if this is a localhost bridge URL that needs remote reconnection
final remoteAccessId = await SettingsService.getRemoteAccessId();
final isLocalhostUrl = _serverUrl!.contains('localhost') || _serverUrl!.contains('127.0.0.1');
if (isLocalhostUrl && remoteAccessId != null && remoteAccessId.isNotEmpty) {
// This is a remote connection - need to re-establish bridge
_logger.log('🌐 Detected remote mode, re-establishing bridge for: $remoteAccessId');
await _reconnectViaRemoteBridge(remoteAccessId);
} else {
// Direct/local connection
await connectToServer(_serverUrl!);
}
await _initializeLocalPlayback();
}
}
/// Re-establish remote bridge connection on app restart
Future<void> _reconnectViaRemoteBridge(String remoteAccessId) async {
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username == null || password == null) {
_logger.log('❌ Remote reconnect failed: no credentials saved');
// Clear remote access ID since we can't reconnect
await SettingsService.clearRemoteAccessId();
return;
}
_logger.log('🔄 Starting remote bridge for reconnection...');
try {
// Use existing bridge instance (WebView is already in widget tree)
// Subscribe to bridge state changes for auto-reconnect
_remoteBridgeStateSubscription?.cancel();
_remoteBridgeStateSubscription = _remoteBridge.stateStream.listen((state) {
_logger.log('🔄 RemoteBridge state: $state (API state: $_connectionState)');
if (state == RemoteBridgeState.connected &&
(_connectionState == MAConnectionState.disconnected ||
_connectionState == MAConnectionState.error)) {
// Bridge reconnected but API is disconnected - reconnect API immediately
_logger.log('🔄 Bridge reconnected, triggering immediate API reconnect');
_reconnectApiToBridge();
}
});
final port = await _remoteBridge.start(remoteAccessId, username, password);
if (port == null) {
_logger.log('❌ Remote bridge failed to start');
// Clear remote access ID since bridge failed
await SettingsService.clearRemoteAccessId();
return;
}
_logger.log('✅ Remote bridge started on port $port');
// Update server URL to the new bridge port
final bridgeUrl = 'ws://localhost:$port';
await SettingsService.setServerUrl(bridgeUrl);
// Connect through the bridge
await connectToServer(bridgeUrl, isRemoteMode: true, remoteCloudUrl: remoteAccessId);
} catch (e) {
_logger.log('❌ Remote reconnect error: $e');
await SettingsService.clearRemoteAccessId();
}
}
/// Start the remote bridge for initial connection (called from LoginScreen).
/// Returns the local port number to connect to, or null if failed.
Future<int?> startRemoteBridge(String remoteId, String username, String password) async {
_logger.log('🔄 Starting remote bridge for: $remoteId');
try {
// Subscribe to bridge state changes for auto-reconnect
_remoteBridgeStateSubscription?.cancel();
_remoteBridgeStateSubscription = _remoteBridge.stateStream.listen((state) {
_logger.log('🔄 RemoteBridge state: $state (API state: $_connectionState)');
if (state == RemoteBridgeState.connected &&
(_connectionState == MAConnectionState.disconnected ||
_connectionState == MAConnectionState.error)) {
_logger.log('🔄 Bridge reconnected, triggering immediate API reconnect');
_reconnectApiToBridge();
}
});
final port = await _remoteBridge.start(remoteId, username, password);
return port;
} catch (e) {
_logger.log('❌ Remote bridge start error: $e');
return null;
}
}
/// Load cached players from database for instant UI display
Future<void> _loadPlayersFromDatabase() async {
try {
@@ -1001,16 +1140,19 @@ class MusicAssistantProvider with ChangeNotifier {
// CONNECTION
// ============================================================================
Future<void> connectToServer(String serverUrl) async {
Future<void> connectToServer(String serverUrl, {bool isRemoteMode = false, String? remoteCloudUrl}) async {
try {
_error = null;
_serverUrl = serverUrl;
_isRemoteMode = isRemoteMode; // Store for reconnection
_remoteCloudUrl = remoteCloudUrl; // Store cloud URL for Sendspin in remote mode
await SettingsService.setServerUrl(serverUrl);
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
_api!.isRemoteMode = isRemoteMode;
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
@@ -1035,9 +1177,11 @@ class MusicAssistantProvider with ChangeNotifier {
}
// No auth required, initialize immediately
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
await _initializeAfterConnection();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
@@ -1045,11 +1189,14 @@ class MusicAssistantProvider with ChangeNotifier {
// Keep showing cached data for instant UI display on reconnect
// Player list and state will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
// Start reconnect timer in remote mode
_startRemoteReconnectTimer();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
_startRemoteReconnectTimer(); // Start retry timer in remote mode
notifyListeners();
},
);
@@ -1252,11 +1399,23 @@ class MusicAssistantProvider with ChangeNotifier {
return false;
}
await SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
// Update local state directly for immediate UI response
// If currently "all enabled" (empty list) and disabling one, need to switch to explicit mode
if (_enabledProviderIds.isEmpty && !enabled) {
// Initialize with all providers except the one being disabled
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
} else if (enabled) {
// Add to list if not present
if (!_enabledProviderIds.contains(instanceId)) {
_enabledProviderIds = [..._enabledProviderIds, instanceId];
}
} else {
// Remove from list
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
}
// Reload the enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
// Persist to settings (async, but don't wait for UI update)
SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
@@ -1835,13 +1994,28 @@ class MusicAssistantProvider with ChangeNotifier {
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl (remote: $_isRemoteMode)');
// In remote mode, retry multiple times since bridge may be recovering
final maxRetries = _isRemoteMode ? 5 : 1;
for (var attempt = 1; attempt <= maxRetries; attempt++) {
try {
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('🔄 Reconnection successful on attempt $attempt');
return;
} catch (e) {
_logger.log('🔄 Reconnection attempt $attempt/$maxRetries failed: $e');
if (attempt < maxRetries) {
// Exponential backoff: 2s, 4s, 8s, 16s
final delay = Duration(seconds: 2 << (attempt - 1));
_logger.log('🔄 Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay);
}
}
}
_logger.log('🔄 All reconnection attempts failed');
// Start periodic reconnect timer for remote mode
_startRemoteReconnectTimer();
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
@@ -1852,7 +2026,7 @@ class MusicAssistantProvider with ChangeNotifier {
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
@@ -1860,6 +2034,59 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Start periodic reconnect timer for remote mode (every 30s)
void _startRemoteReconnectTimer() {
if (!_isRemoteMode) return;
if (_remoteReconnectTimer != null) return; // Already running
_logger.log('🔄 Starting remote reconnect timer (30s interval)');
_remoteReconnectTimer = Timer.periodic(const Duration(seconds: 30), (_) async {
if (_connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated) {
_logger.log('🔄 Remote reconnect timer: Already connected, stopping timer');
_stopRemoteReconnectTimer();
return;
}
_logger.log('🔄 Remote reconnect timer: Attempting reconnection...');
try {
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('🔄 Remote reconnect timer: Reconnection successful');
_stopRemoteReconnectTimer();
} catch (e) {
_logger.log('🔄 Remote reconnect timer: Reconnection failed: $e');
// Timer will fire again in 30s
}
});
}
/// Stop the remote reconnect timer
void _stopRemoteReconnectTimer() {
if (_remoteReconnectTimer != null) {
_logger.log('🔄 Stopping remote reconnect timer');
_remoteReconnectTimer!.cancel();
_remoteReconnectTimer = null;
}
}
/// Reconnect the API to the existing remote bridge
/// Called when RemoteBridge reconnects and API is disconnected
Future<void> _reconnectApiToBridge() async {
if (_serverUrl == null) {
_logger.log('⚠️ No server URL for API reconnect');
return;
}
try {
_logger.log('🔄 Reconnecting API to bridge at $_serverUrl');
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('✅ API reconnected to bridge successfully');
} catch (e) {
_logger.log('❌ API reconnect to bridge failed: $e');
// The 30s timer will try again
}
}
// ============================================================================
// LOCAL PLAYER
// ============================================================================
@@ -1962,24 +2189,21 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20;
}
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
/// or manually configure reverse proxy routing to port 8927
/// Check if server has built-in Sendspin proxy (added in MA 2.7.1)
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.8.0
// Compare with 2.7.1
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 8) return true;
if (minor < 8) return false;
// minor == 8, any 2.8.x version has the proxy
return true;
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7, need patch >= 1
return patch >= 1;
}
/// Get the server version string for logging
@@ -2092,23 +2316,77 @@ class MusicAssistantProvider with ChangeNotifier {
/// This is the replacement for builtin_player when that API is not available.
///
/// Connection strategy depends on MA version and network:
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.1+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
/// - Remote mode: Uses WebRTC bridge tunnel (ws://localhost:PORT/sendspin)
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy, remote: $_isRemoteMode');
// Initialize Sendspin service
// In remote mode, connect via the WebRTC bridge tunnel
if (_isRemoteMode) {
// The bridge provides Sendspin tunnel at ws://localhost:PORT/sendspin
// Extract port from the server URL (which is ws://localhost:PORT for remote)
final serverUri = Uri.parse(_serverUrl!);
if (serverUri.host != 'localhost' && serverUri.host != '127.0.0.1') {
_logger.log('⚠️ Sendspin: Remote mode but server URL is not localhost bridge');
return false;
}
final bridgeSendspinUrl = 'ws://${serverUri.host}:${serverUri.port}/sendspin';
_logger.log('Sendspin: Remote mode - connecting via WebRTC bridge: $bridgeSendspinUrl');
// Initialize Sendspin service with the bridge's /sendspin endpoint
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); // Base URL for service reference
// Initialize PCM audio player
_pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer();
final pcmInitialized = await _pcmAudioPlayer!.initialize();
if (pcmInitialized) {
_logger.log('✅ PCM audio player initialized for Sendspin');
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
}
// Wire up callbacks
_sendspinService!.onPlay = _handleSendspinPlay;
_sendspinService!.onPause = _handleSendspinPause;
_sendspinService!.onStop = _handleSendspinStop;
_sendspinService!.onSeek = _handleSendspinSeek;
_sendspinService!.onVolume = _handleSendspinVolume;
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Connect directly to the bridge's /sendspin endpoint
// No proxy auth needed - the bridge handles authentication via WebRTC
final connected = await _sendspinService!.connectWithUrl(bridgeSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via WebRTC bridge tunnel');
return true;
}
_logger.log('⚠️ Sendspin: WebRTC bridge tunnel connection failed');
return false;
}
// Local mode - use server URL directly
String sendspinServerUrl = _serverUrl!;
// Initialize Sendspin service with the appropriate server URL
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
_sendspinService = SendspinService(sendspinServerUrl);
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
@@ -2139,13 +2417,13 @@ class MusicAssistantProvider with ChangeNotifier {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Parse server URL to determine connection strategy
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
? _serverUrl!
: 'https://$_serverUrl');
// Parse server URL to determine connection strategy (local mode only - remote handled above)
final serverUri = Uri.parse(sendspinServerUrl.startsWith('http')
? sendspinServerUrl
: 'https://$sendspinServerUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
(!sendspinServerUrl.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
@@ -2159,10 +2437,10 @@ class MusicAssistantProvider with ChangeNotifier {
}
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path in case user has a local reverse proxy
// For local IPs, also try the proxy path (MA 2.7.1+ has built-in /sendspin proxy)
_logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl, useProxyAuth: true);
if (proxyConnected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
@@ -2173,7 +2451,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
if (hasProxy) {
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
_logger.log('Sendspin: MA 2.7.1+ detected, using built-in /sendspin proxy');
} else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
@@ -2214,7 +2492,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else {
// MA 2.8.0+ but still failing
// MA 2.7.1+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
@@ -2363,6 +2641,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Initialize notification with position 0
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: true,
@@ -2403,6 +2682,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Show paused state with preserved position
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: false,
@@ -2708,7 +2988,14 @@ class MusicAssistantProvider with ChangeNotifier {
Map<String, dynamic>? metadata;
if (imageUrl != null) {
var finalImageUrl = imageUrl;
if (_serverUrl != null) {
// In remote mode, don't use imageproxy (can't reach MA server through WebRTC tunnel)
// Use the original URL directly if it's an external HTTPS URL
if (_api?.isRemoteMode == true) {
// Keep external URLs as-is, they should work directly
if (!imageUrl.startsWith('https://')) {
finalImageUrl = imageUrl; // May not load, but better than broken imageproxy
}
} else if (_serverUrl != null && !_serverUrl!.startsWith('ws://') && !_serverUrl!.startsWith('wss://')) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
@@ -2948,7 +3235,9 @@ class MusicAssistantProvider with ChangeNotifier {
}
artist ??= 'Unknown Artist';
if (imageUrl != null && _serverUrl != null) {
// In remote mode, don't use imageproxy (can't reach MA server through WebRTC tunnel)
if (imageUrl != null && _api?.isRemoteMode != true && _serverUrl != null &&
!_serverUrl!.startsWith('ws://') && !_serverUrl!.startsWith('wss://')) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
@@ -3973,6 +4262,7 @@ class MusicAssistantProvider with ChangeNotifier {
} else {
// Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
} else {
// For remote players, immediately show notification if we have cached track info
@@ -3994,6 +4284,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Use position tracker for consistent position
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
@@ -4009,6 +4300,7 @@ class MusicAssistantProvider with ChangeNotifier {
artist: 'Loading...',
);
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: player.state == 'playing',
@@ -4019,6 +4311,7 @@ class MusicAssistantProvider with ChangeNotifier {
// Player is idle - clear the notification to prevent stale metadata
// from previous player showing (fixes issue #42)
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
}
}
@@ -4172,6 +4465,7 @@ class MusicAssistantProvider with ChangeNotifier {
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
);
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: isPlaying,
@@ -4356,6 +4650,26 @@ class MusicAssistantProvider with ChangeNotifier {
_updatePlayerState();
}
/// Start the idle service timer - stops foreground service after 30 min of no playback
/// Called when playback stops (player state becomes idle)
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_idleServiceTimer = Timer(_idleServiceTimeout, () {
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
audioHandler.stopService();
});
}
/// Cancel the idle service timer - called when playback starts
void _cancelIdleServiceTimer() {
if (_idleServiceTimer?.isActive == true) {
_logger.log('⏱️ Cancelling idle service timer (playback resumed)');
_idleServiceTimer?.cancel();
_idleServiceTimer = null;
}
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
@@ -4444,6 +4758,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
if (stateChanged) {
notifyListeners();
}
@@ -4630,6 +4945,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
// Use position tracker for consistent position (single source of truth)
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
@@ -4664,6 +4980,7 @@ class MusicAssistantProvider with ChangeNotifier {
);
} else {
final position = _positionTracker.currentPosition;
_cancelIdleServiceTimer();
audioHandler.setRemotePlaybackState(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
@@ -4673,6 +4990,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
} else {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
}
}
@@ -4768,7 +5086,8 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Fetch tracks from API (not cached - too many items)
if (_api != null) {
// Skip in remote mode to keep connection responsive
if (_api != null && !_api!.isRemoteMode) {
try {
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
@@ -4778,6 +5097,8 @@ class MusicAssistantProvider with ChangeNotifier {
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
} else if (_api?.isRemoteMode == true) {
_logger.log('🌐 Remote mode: skipping tracks fetch');
}
_isLoading = false;
@@ -4799,6 +5120,13 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _syncLibraryInBackground() async {
if (_api == null) return;
// In remote mode, delay sync to let player/queue operations complete first
if (_api!.isRemoteMode) {
_logger.log('🌐 Remote mode: delaying library sync by 3 seconds');
await Future.delayed(const Duration(seconds: 3));
if (_api == null || !isConnected) return; // Check still connected
}
final syncService = SyncService.instance;
// Listen for sync completion to update our lists
@@ -4813,7 +5141,14 @@ class MusicAssistantProvider with ChangeNotifier {
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
// In remote mode, force sync but use gentler approach (handled in SyncService)
if (_api!.isRemoteMode) {
_logger.log('🌐 Remote mode: starting gentle library sync');
await syncService.forceSync(_api!, providerInstanceIds: providerIdsForApiCalls);
} else {
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
}
}
/// Load radio stations from the library
@@ -4900,6 +5235,9 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
notifyListeners();
} catch (e) {
@@ -4932,6 +5270,9 @@ class MusicAssistantProvider with ChangeNotifier {
orderBy: orderBy,
);
// Update SyncService cache with sorted data so UI sees the new order
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
notifyListeners();
} catch (e) {
@@ -5877,6 +6218,9 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel();
_idleServiceTimer?.cancel();
_remoteReconnectTimer?.cancel();
_remoteBridgeStateSubscription?.cancel();
_connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
+6 -2
View File
@@ -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);
+371 -5
View File
@@ -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,
+16
View File
@@ -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,
+436 -325
View File
@@ -431,15 +431,28 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
/// Sort audiobooks based on current sort order
void _sortAudiobooks() {
final sorted = List<Audiobook>.from(_audiobooks);
if (_audiobooksSortOrder == 'year') {
sorted.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
} else {
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
switch (_audiobooksSortOrder) {
case 'year':
sorted.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sorted.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'alpha_desc':
sorted.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()));
break;
default: // 'alpha' or any other
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
_sortedAudiobooks = sorted;
_audiobookNames = sorted.map((a) => a.name).toList();
@@ -480,9 +493,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
default:
// Default: name ascending
case 'name':
// Explicit name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
break;
// Server-handled sort orders - preserve server order, don't re-sort
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
// Server already sorted, keep the order
break;
default:
// Unknown sort order - preserve server order
break;
}
_albumsCacheKey = cacheKey;
@@ -499,11 +527,27 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
final sortedArtists = List<Artist>.from(artists);
if (_artistsSortOrder == 'name_desc') {
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
} else {
// Default: name ascending
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
switch (_artistsSortOrder) {
case 'name':
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
break;
case 'name_desc':
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
// Server-handled sort orders - preserve server order, don't re-sort
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
// Server already sorted, keep the order
break;
default:
// Unknown sort order - preserve server order
break;
}
_artistsCacheKey = cacheKey;
@@ -829,6 +873,41 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return false;
}
/// Map sort order to scrollbar display mode
ScrollbarDisplayMode _getScrollbarDisplayMode(String sortOrder) {
switch (sortOrder) {
// Alphabetical sorts -> show letter
case 'name':
case 'name_desc':
case 'sort_name':
case 'sort_name_desc':
case 'artist_name':
case 'artist_name_desc':
case 'alpha':
case 'alpha_desc':
case 'books': // Series sorted by book count - show letter as fallback
return ScrollbarDisplayMode.letter;
// Year sorts -> show year
case 'year':
case 'year_desc':
return ScrollbarDisplayMode.year;
// Sorts without displayable values -> hide popup
case 'timestamp_added':
case 'timestamp_added_desc':
case 'timestamp_modified':
case 'timestamp_modified_desc':
case 'last_played':
case 'last_played_desc':
case 'play_count':
case 'play_count_desc':
case 'duration':
case 'duration_desc':
return ScrollbarDisplayMode.none;
default:
return ScrollbarDisplayMode.letter;
}
}
void _onLetterScrollbarDragChanged(bool isDragging) {
setState(() {
_isLetterScrollbarDragging = isDragging;
@@ -880,19 +959,32 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
// Use SyncService's client-side filtering for instant updates with source tracking
// Fetch playlists with sort order from server
final serverPlaylists = await maProvider.getPlaylists(
favoriteOnly: favoriteOnly,
orderBy: _playlistsSortOrder,
);
// Update SyncService cache with sorted data
if (serverPlaylists.isNotEmpty) {
syncService.updateCachedPlaylists(serverPlaylists);
}
// Apply provider filtering client-side
List<Playlist> playlists;
if (enabledProviders.isNotEmpty) {
// Client-side filtering using source tracking (instant, differentiates same-type providers)
playlists = syncService.getPlaylistsFilteredByProviders(enabledProviders);
// Apply favorite filter client-side
if (favoriteOnly == true) {
playlists = playlists.where((p) => p.favorite == true).toList();
}
if (enabledProviders.isNotEmpty && serverPlaylists.isNotEmpty) {
playlists = serverPlaylists.where((p) {
final sources = syncService.playlistSourceProviders[p.itemId];
if (sources == null || sources.isEmpty) return true; // Show if no tracking
return sources.any((s) => enabledProviders.contains(s));
}).toList();
} else if (serverPlaylists.isNotEmpty) {
playlists = serverPlaylists;
} else {
// No provider filter active - use cached playlists
playlists = syncService.cachedPlaylists;
// Apply favorite filter client-side
// Fallback to cached data if server returned nothing
playlists = enabledProviders.isNotEmpty
? syncService.getPlaylistsFilteredByProviders(enabledProviders)
: syncService.cachedPlaylists;
if (favoriteOnly == true) {
playlists = playlists.where((p) => p.favorite == true).toList();
}
@@ -901,7 +993,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (mounted) {
setState(() {
_playlists = playlists;
// Server returns pre-sorted data, but keep client sort for fallback
_sortPlaylists();
_isLoadingPlaylists = false;
});
@@ -1411,8 +1502,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _tabCount,
// Faster settling so vertical scroll works sooner after swipe
physics: const _FastPageScrollPhysics(),
// Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(),
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
),
),
@@ -2294,6 +2385,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _authorsScrollController,
items: sortedAuthorNames,
displayMode: _getScrollbarDisplayMode(_authorsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _authorsViewMode == 'list'
@@ -2606,23 +2698,36 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: syncService.cachedAudiobooks;
: List<Audiobook>.from(syncService.cachedAudiobooks);
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
// Apply sort order
if (_audiobooksSortOrder == 'year') {
audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
} else {
audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Apply sort order (client-side for audiobooks)
switch (_audiobooksSortOrder) {
case 'year':
audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
audiobooks.sort((a, b) {
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'alpha_desc':
audiobooks.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()));
break;
default: // 'alpha' or any other
audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
if (audiobooks.isEmpty) {
@@ -2644,6 +2749,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
final audiobookNames = audiobooks.map((a) => a.name).toList();
// Generate year labels for year sort mode
final audiobookYears = (_audiobooksSortOrder == 'year' || _audiobooksSortOrder == 'year_desc')
? audiobooks.map((a) => a.year?.toString() ?? '?').toList()
: null;
// Match music albums tab layout - no header, direct list/grid
return RefreshIndicator(
@@ -2653,12 +2762,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _audiobooksScrollController,
items: audiobookNames,
displayMode: _getScrollbarDisplayMode(_audiobooksSortOrder),
displayLabels: audiobookYears,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _audiobooksViewMode == 'list'
? ListView.builder(
controller: _audiobooksScrollController,
key: PageStorageKey<String>('all_books_list_${_showFavoritesOnly ? 'fav' : 'all'}_${enabledProviders.length}'),
key: PageStorageKey<String>('all_books_list_${_showFavoritesOnly ? 'fav' : 'all'}_${enabledProviders.length}_$_audiobooksSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
@@ -2670,7 +2781,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
)
: GridView.builder(
controller: _audiobooksScrollController,
key: PageStorageKey<String>('all_books_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_audiobooksViewMode\_${enabledProviders.length}'),
key: PageStorageKey<String>('all_books_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_audiobooksViewMode\_${enabledProviders.length}_$_audiobooksSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
@@ -2904,6 +3015,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _seriesScrollController,
items: seriesNames,
displayMode: _getScrollbarDisplayMode(_seriesSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _seriesViewMode == 'list'
@@ -3333,6 +3445,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _podcastsScrollController,
items: podcastNames,
displayMode: _getScrollbarDisplayMode(_podcastsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _podcastsViewMode == 'list'
@@ -3603,6 +3716,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _radioScrollController,
items: radioNames,
displayMode: _getScrollbarDisplayMode(_radioSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _radioViewMode == 'list'
@@ -3761,98 +3875,97 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when loading state or providers change
// Always use SyncService data for consistency (it has the complete library)
return Selector<MusicAssistantProvider, (bool, Set<String>)>(
selector: (_, provider) => (provider.isLoading, provider.enabledProviderIds.toSet()),
builder: (context, data, _) {
final (isLoading, enabledProviders) = data;
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Note: We don't use Selector here because we need to react to both
// MusicAssistantProvider changes AND SyncService changes (source tracking).
// The parent's _onSyncServiceChanged listener triggers setState() for SyncService updates.
final maProvider = context.watch<MusicAssistantProvider>();
final isLoading = maProvider.isLoading;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedArtists.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedArtists.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Always use SyncService data - it has the complete library with source tracking
final filteredArtists = enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: syncService.cachedArtists;
// Always use SyncService data - it has the complete library with source tracking
final filteredArtists = enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: syncService.cachedArtists;
// Filter by favorites if enabled
final artists = _showFavoritesOnly
? filteredArtists.where((a) => a.favorite == true).toList()
: filteredArtists;
// Filter by favorites if enabled
final artists = _showFavoritesOnly
? filteredArtists.where((a) => a.favorite == true).toList()
: filteredArtists;
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedArtists = _getSortedArtists(artists);
final artistNames = _cachedArtistNames;
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedArtists = _getSortedArtists(artists);
final artistNames = _cachedArtistNames;
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
displayMode: _getScrollbarDisplayMode(_artistsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
items: artistNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
);
},
),
);
}
@@ -3971,100 +4084,104 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when loading state or providers change
// Always use SyncService data for consistency (it has the complete library)
return Selector<MusicAssistantProvider, (bool, Set<String>)>(
selector: (_, provider) => (provider.isLoading, provider.enabledProviderIds.toSet()),
builder: (context, data, _) {
final (isLoading, enabledProviders) = data;
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Note: We don't use Selector here because we need to react to both
// MusicAssistantProvider changes AND SyncService changes (source tracking).
// The parent's _onSyncServiceChanged listener triggers setState() for SyncService updates.
final maProvider = context.watch<MusicAssistantProvider>();
final isLoading = maProvider.isLoading;
final enabledProviders = maProvider.enabledProviderIds.toSet();
final colorScheme = Theme.of(context).colorScheme;
final syncService = SyncService.instance;
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedAlbums.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Show loading only if actually loading AND no cached data available
if (isLoading && syncService.cachedAlbums.isEmpty) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Always use SyncService data - it has the complete library with source tracking
final filteredAlbums = enabledProviders.isNotEmpty
? syncService.getAlbumsFilteredByProviders(enabledProviders)
: syncService.cachedAlbums;
// Always use SyncService data - it has the complete library with source tracking
final filteredAlbums = enabledProviders.isNotEmpty
? syncService.getAlbumsFilteredByProviders(enabledProviders)
: syncService.cachedAlbums;
// Filter by favorites if enabled
final albums = _showFavoritesOnly
? filteredAlbums.where((a) => a.favorite == true).toList()
: filteredAlbums;
// Filter by favorites if enabled
final albums = _showFavoritesOnly
? filteredAlbums.where((a) => a.favorite == true).toList()
: filteredAlbums;
if (albums.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteAlbums,
subtitle: l10n.tapHeartAlbum,
);
}
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedAlbums = _getSortedAlbums(albums);
final albumNames = _cachedAlbumNames;
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _albumsScrollController,
items: albumNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _albumsViewMode == 'list'
? ListView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return _buildAlbumListTile(context, album);
},
)
: GridView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _albumsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _albumsViewMode == 'grid3' ? 0.70 : 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return AlbumCard(
key: ValueKey(album.uri ?? album.itemId),
album: album,
heroTagSuffix: 'library_grid',
// Use 256 to match detail screen for smooth Hero animation
imageCacheSize: 256,
);
},
),
),
if (albums.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteAlbums,
subtitle: l10n.tapHeartAlbum,
);
},
}
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedAlbums = _getSortedAlbums(albums);
final albumNames = _cachedAlbumNames;
// Generate year labels for year sort mode
final albumYears = (_albumsSortOrder == 'year' || _albumsSortOrder == 'year_desc')
? sortedAlbums.map((a) => a.year?.toString() ?? '?').toList()
: null;
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _albumsScrollController,
items: albumNames,
displayMode: _getScrollbarDisplayMode(_albumsSortOrder),
displayLabels: albumYears,
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _albumsViewMode == 'list'
? ListView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return _buildAlbumListTile(context, album);
},
)
: GridView.builder(
controller: _albumsScrollController,
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_albumsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _albumsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _albumsViewMode == 'grid3' ? 0.70 : 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedAlbums.length,
itemBuilder: (context, index) {
final album = sortedAlbums[index];
return AlbumCard(
key: ValueKey(album.uri ?? album.itemId),
album: album,
heroTagSuffix: 'library_grid',
// Use 256 to match detail screen for smooth Hero animation
imageCacheSize: 256,
);
},
),
),
);
}
@@ -4161,12 +4278,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _playlistsScrollController,
items: _playlistNames,
displayMode: _getScrollbarDisplayMode(_playlistsSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: _playlistsViewMode == 'list'
? ListView.builder(
controller: _playlistsScrollController,
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'),
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode\_$_playlistsSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
@@ -4179,7 +4297,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
)
: GridView.builder(
controller: _playlistsScrollController,
key: PageStorageKey<String>('library_playlists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode'),
key: PageStorageKey<String>('library_playlists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_playlistsViewMode\_$_playlistsSortOrder'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
@@ -4449,6 +4567,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: LetterScrollbar(
controller: _tracksScrollController,
items: trackSortKeys,
displayMode: _getScrollbarDisplayMode(_tracksSortOrder),
onDragStateChanged: _onLetterScrollbarDragChanged,
bottomPadding: BottomSpacing.withMiniPlayer,
child: ListView.builder(
@@ -4589,9 +4708,9 @@ class _FastPageScrollPhysics extends PageScrollPhysics {
@override
SpringDescription get spring => const SpringDescription(
mass: 50, // Lower mass = faster movement
stiffness: 500, // Higher stiffness = snappier
damping: 1.0, // Critical damping for no overshoot
mass: 80, // Increased mass for more controlled movement
stiffness: 100, // Reduced stiffness to prevent over-snapping
damping: 1.2, // Slightly over-damped to prevent overshoot
);
}
@@ -4661,7 +4780,8 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
late String _currentViewMode;
late bool _showFavoritesOnly;
late bool _showOnlyArtistsWithAlbums;
late Set<String> _localEnabledProviders;
// Note: We don't use local state for enabledProviders anymore - the Selector
// watches the Provider directly and rebuilds when it changes
late Map<String, bool> _localAbsLibraryEnabled;
late AnimationController _animController;
late Animation<double> _scaleAnimation;
@@ -4674,7 +4794,6 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
_currentViewMode = widget.currentViewMode;
_showFavoritesOnly = widget.showFavoritesOnly;
_showOnlyArtistsWithAlbums = widget.showOnlyArtistsWithAlbums;
_localEnabledProviders = Set.from(widget.enabledProviderIds);
_localAbsLibraryEnabled = Map.from(widget.absLibraryEnabled);
_animController = AnimationController(
@@ -4702,6 +4821,8 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
if (oldWidget.currentSort != widget.currentSort) {
_currentSort = widget.currentSort;
}
// Note: We don't sync enabledProviders here anymore - the Selector watches
// the Provider directly and handles rebuilds when the state changes
}
void _handleSortTap(String baseField, bool defaultDesc) {
@@ -4744,42 +4865,21 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
}
void _handleProviderTap(String providerId) {
final isCurrentlyEnabled = _localEnabledProviders.isEmpty ||
_localEnabledProviders.contains(providerId);
// Read directly from Provider to get current state (not stale props)
final maProvider = context.read<MusicAssistantProvider>();
final currentEnabled = maProvider.enabledProviderIds;
final isCurrentlyEnabled = currentEnabled.isEmpty || currentEnabled.contains(providerId);
// Don't allow disabling the last provider
if (isCurrentlyEnabled) {
// If all are enabled (empty set), we're switching to selective mode
if (_localEnabledProviders.isEmpty) {
// Enable all except this one
final allIds = widget.relevantProviders.map((p) => p.$1.instanceId).toSet();
if (allIds.length <= 1) return; // Can't disable the only provider
_localEnabledProviders = allIds..remove(providerId);
} else {
// Already in selective mode - disable this one
if (_localEnabledProviders.length <= 1) return; // Can't disable the last one
_localEnabledProviders.remove(providerId);
}
} else {
// Enable this provider
_localEnabledProviders.add(providerId);
// If all providers are now enabled, clear the set (means "all")
final allIds = widget.relevantProviders.map((p) => p.$1.instanceId).toSet();
if (_localEnabledProviders.containsAll(allIds)) {
_localEnabledProviders.clear();
}
if (isCurrentlyEnabled && currentEnabled.isNotEmpty && currentEnabled.length <= 1) {
return; // Can't disable the last one
}
setState(() {});
// Let the callback handle the actual toggle - no local state needed
// The Provider will notifyListeners and trigger a rebuild
widget.onProviderToggled(providerId, !isCurrentlyEnabled);
}
bool _isProviderEnabled(String providerId) {
// Empty set means all are enabled
if (_localEnabledProviders.isEmpty) return true;
return _localEnabledProviders.contains(providerId);
}
void _handleAbsLibraryTap(String libraryPath) {
final isEnabled = _localAbsLibraryEnabled[libraryPath] ?? true;
@@ -5010,82 +5110,93 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
// Providers section - only shown if multiple providers support this category
// relevantProviders is pre-filtered by capability (e.g., radio providers won't appear in Artists)
if (widget.relevantProviders.length > 1) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Providers',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Show all providers that support this category (allows toggling any on/off)
...widget.relevantProviders.map((providerData) {
final (provider, itemCount) = providerData;
final isEnabled = _isProviderEnabled(provider.instanceId);
// Use Consumer to rebuild when provider changes (Selector had issues with List equality)
if (widget.relevantProviders.length > 1)
Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
final enabledSet = maProvider.enabledProviderIds.toSet();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Providers',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Show all providers that support this category (allows toggling any on/off)
...widget.relevantProviders.map((providerData) {
final (provider, itemCount) = providerData;
// Empty set means all enabled
final isEnabled = enabledSet.isEmpty || enabledSet.contains(provider.instanceId);
return InkWell(
onTap: () => _handleProviderTap(provider.instanceId),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: isEnabled
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
isEnabled ? Icons.cloud : Icons.cloud_outlined,
size: 18,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
provider.name,
style: TextStyle(
color: isEnabled ? colorScheme.primary : null,
fontWeight: isEnabled ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
// Item count badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isEnabled
? colorScheme.primary.withOpacity(0.1)
: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
child: Text(
itemCount.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.5),
return InkWell(
onTap: () => _handleProviderTap(provider.instanceId),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Checkmark on left - fixed width
SizedBox(
width: 24,
child: isEnabled
? Icon(Icons.check, size: 18, color: colorScheme.primary)
: null,
),
const SizedBox(width: 8),
Icon(
isEnabled ? Icons.cloud : Icons.cloud_outlined,
size: 18,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
provider.name,
style: TextStyle(
color: isEnabled ? colorScheme.primary : null,
fontWeight: isEnabled ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
// Item count badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isEnabled
? colorScheme.primary.withOpacity(0.1)
: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
child: Text(
itemCount.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isEnabled
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.5),
),
),
),
],
),
),
),
],
),
),
);
}),
],
);
}),
],
);
},
),
// ABS Libraries section - only for Books tab with multiple libraries
if (widget.selectedMediaType == LibraryMediaType.books &&
+257
View File
@@ -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,
),
),
],
],
),
),
],
),
),
],
),
);
}
}
@@ -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);
+81 -7
View File
@@ -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.') ||
+38
View File
@@ -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,
+170 -8
View File
@@ -25,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;
@@ -38,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;
@@ -65,6 +79,12 @@ class MusicAssistantAPI {
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
@@ -285,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;
}
@@ -352,6 +372,9 @@ class MusicAssistantAPI {
}
}
// Clean up stale requests before checking limits
_cleanupStaleRequests();
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
@@ -362,7 +385,7 @@ class MusicAssistantAPI {
final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
_pendingRequests[messageId] = _PendingRequest(completer: completer);
final message = {
'message_id': messageId,
@@ -385,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,
@@ -809,6 +853,15 @@ class MusicAssistantAPI {
}
}
_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([]);
}
}
}
@@ -826,11 +879,13 @@ class MusicAssistantAPI {
// Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR
// 2. We have specific non-ABS providers to query
// 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) {
@@ -3205,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) {
@@ -3267,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
@@ -3666,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();
+675
View File
@@ -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();
}
}
+272
View File
@@ -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;
}
}
+506
View File
@@ -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();
}
}
+36 -5
View File
@@ -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);
}
}
}
+21
View File
@@ -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
@@ -193,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();
+104 -33
View File
@@ -242,24 +242,44 @@ class SyncService with ChangeNotifier {
final playlistMap = <String, Playlist>{};
// 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');
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers (limit: $limit, remote: ${api.isRemoteMode})');
for (final providerId in providerInstanceIds) {
_logger.log(' 📡 Syncing provider: $providerId');
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
api.getAudiobooks(limit: 1000, providerInstanceIds: [providerId]),
api.getPlaylists(limit: 1000, providerInstanceIds: [providerId]),
]);
List<Album> albums;
List<Artist> artists;
List<Audiobook> audiobooks;
List<Playlist> playlists;
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>;
// 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');
@@ -295,26 +315,59 @@ class SyncService with ChangeNotifier {
}
} else {
// No provider filter - fetch all at once (faster, but no source tracking)
_logger.log('📡 Fetching from all providers (no source tracking)');
_logger.log('📡 Fetching from all providers (no source tracking, limit: $limit)');
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
// In remote mode, do sequential requests to avoid overwhelming WebRTC
if (api.isRemoteMode) {
_logger.log('🌐 Remote mode: using sequential requests with delays');
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 = 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;
}
}
}
@@ -505,13 +558,11 @@ class SyncService with ChangeNotifier {
/// 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) {
// Empty set = all providers enabled, show everything
if (enabledProviderIds.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
// STRICT MODE: Hide items without tracking to ensure accurate filtering
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
@@ -524,7 +575,6 @@ class SyncService with ChangeNotifier {
}
return _cachedArtists.where((artist) {
final sources = _artistSourceProviders[artist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
@@ -578,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();
}
}
+2 -2
View File
@@ -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,
),
+143 -27
View File
@@ -121,6 +121,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
bool _isSeeking = false; // Debounce flag to prevent rapid-fire seeks
// Pre-computed static colors to avoid object creation during animation frames
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
@@ -2860,33 +2861,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
children: [
SizedBox(
height: 48, // Increase touch target height
// PERF Phase 1: Use cached SliderThemeData
child: SliderTheme(
data: _sliderTheme,
child: Slider(
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => _seekPositionNotifier.value = value,
onChangeStart: (value) => _seekPositionNotifier.value = value,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor20, // PERF: Use cached color
),
child: _buildProgressSlider(
context,
currentProgress,
currentTrack.duration!.inSeconds.toDouble(),
primaryColor,
primaryColor20,
maProvider,
selectedPlayer.playerId,
),
),
Padding(
@@ -4132,6 +4114,140 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
);
}
/// Custom progress slider that doesn't require Overlay widget
/// (Flutter 3.38's Material 3 Slider uses OverlayPortal which fails in our overlay context)
Widget _buildProgressSlider(
BuildContext context,
double currentProgress,
double maxProgress,
Color activeColor,
Color inactiveColor,
MusicAssistantProvider maProvider,
String playerId,
) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final progress = maxProgress > 0 ? (currentProgress / maxProgress).clamp(0.0, 1.0) : 0.0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragUpdate: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragCancel: () {
_seekPositionNotifier.value = null;
},
onHorizontalDragEnd: (details) async {
final seekPosition = _seekPositionNotifier.value;
if (seekPosition != null && !_isSeeking) {
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
}
},
onTapUp: (details) async {
if (_isSeeking) return; // Ignore if already seeking
// Calculate position from tap location
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
final seekPosition = newProgress * maxProgress;
_seekPositionNotifier.value = seekPosition;
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
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: inactiveColor,
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: width * progress,
height: 6,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (width * progress) - 7,
top: 3,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
),
),
),
],
),
),
),
),
);
},
);
}
}
/// Represents a single line of synced lyrics with timestamp
+3 -4
View File
@@ -530,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,
+101 -29
View File
@@ -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),
),
],
);
+52 -21
View File
@@ -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,
),
),
),
],
),
),
),
),
+150 -67
View File
@@ -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:
@@ -466,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:
@@ -526,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:
@@ -582,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:
@@ -618,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:
@@ -654,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:
@@ -666,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:
@@ -718,10 +793,10 @@ 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:
@@ -950,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:
@@ -1027,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:
@@ -1083,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:
@@ -1171,10 +1246,10 @@ packages:
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:
@@ -1207,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:
@@ -1248,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"
+5 -1
View File
@@ -11,7 +11,7 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
intl: ^0.20.0
# UI
material_design_icons_flutter: ^7.0.7296
@@ -26,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
@@ -40,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
@@ -64,6 +67,7 @@ flutter:
assets:
- assets/images/
- assets/webrtc_engine.js
flutter_launcher_icons:
android: true