Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f35d1a26 | ||
|
|
6918569a82 | ||
|
|
8cf35cfe78 | ||
|
|
42bc1ed16c | ||
|
|
d908cf93a2 | ||
|
|
e21e1eb4fd | ||
|
|
9aa81ef09f | ||
|
|
8d2adb6fc5 | ||
|
|
99ee131712 | ||
|
|
f51ea9cb22 | ||
|
|
9773f28cf7 | ||
|
|
2856ca1e8b | ||
|
|
c832502c49 | ||
|
|
675779c108 | ||
|
|
54a9e6e2b4 | ||
|
|
870acabad1 | ||
|
|
d3a5542c68 | ||
|
|
b93918219f | ||
|
|
a23f9ff05f | ||
|
|
1921a20884 | ||
|
|
516faf6f6a |
@@ -31,8 +31,8 @@ jobs:
|
|||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: '3.24.0'
|
flutter-version: '3.38.0'
|
||||||
channel: 'stable'
|
channel: 'master'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Cache Gradle dependencies
|
- name: Cache Gradle dependencies
|
||||||
@@ -63,10 +63,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup signing keystore
|
- name: Setup signing keystore
|
||||||
run: |
|
run: |
|
||||||
# Decode keystore from secret (persistent key for consistent signatures)
|
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||||
|
|
||||||
# Create key.properties file
|
|
||||||
cat > android/key.properties << EOF
|
cat > android/key.properties << EOF
|
||||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||||
@@ -75,12 +72,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
run: |
|
run: flutter build apk --release
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -91,10 +83,5 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload APK summary
|
- name: Upload APK summary
|
||||||
run: |
|
run: |
|
||||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
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
|
|
||||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.musicassistant.music_assistant"
|
applicationId "com.musicassistant.music_assistant"
|
||||||
minSdk 21
|
minSdkVersion flutter.minSdkVersion
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.9.10'
|
ext.kotlin_version = '2.1.0'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
// Use Maven Central mirrors as fallback for 403 errors
|
// Use Maven Central mirrors as fallback for 403 errors
|
||||||
@@ -9,7 +9,7 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.1.0" apply false
|
id "com.android.application" version "8.7.0" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include ":app"
|
include ":app"
|
||||||
|
|||||||
@@ -1653,5 +1653,55 @@
|
|||||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||||
"@selectPlayerToAddToQueue": {
|
"@selectPlayerToAddToQueue": {
|
||||||
"description": "Hint for selecting player to add to queue"
|
"description": "Hint for selecting player to add to queue"
|
||||||
|
},
|
||||||
|
|
||||||
|
"localConnection": "Server URL",
|
||||||
|
"@localConnection": {
|
||||||
|
"description": "Direct connection mode toggle label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteConnection": "Remote Access ID",
|
||||||
|
"@remoteConnection": {
|
||||||
|
"description": "HA Remote Access connection mode toggle label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteAccessId": "Remote Access ID",
|
||||||
|
"@remoteAccessId": {
|
||||||
|
"description": "Remote access ID field label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
|
||||||
|
"@remoteAccessIdHint": {
|
||||||
|
"description": "Hint for remote access ID field"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteAccessNote": "For Home Assistant add-on users only",
|
||||||
|
"@remoteAccessNote": {
|
||||||
|
"description": "Note about remote access availability"
|
||||||
|
},
|
||||||
|
|
||||||
|
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
|
||||||
|
"@pleaseEnterRemoteId": {
|
||||||
|
"description": "Validation message for remote access ID"
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
|
||||||
|
"@remoteConnectionFailed": {
|
||||||
|
"description": "Error message when remote connection fails"
|
||||||
|
},
|
||||||
|
|
||||||
|
"scanQrCode": "Scan QR Code",
|
||||||
|
"@scanQrCode": {
|
||||||
|
"description": "Title for QR code scanner screen"
|
||||||
|
},
|
||||||
|
|
||||||
|
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
|
||||||
|
"@pointCameraAtQr": {
|
||||||
|
"description": "Instructions for scanning QR code"
|
||||||
|
},
|
||||||
|
|
||||||
|
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
|
||||||
|
"@cameraPermissionDenied": {
|
||||||
|
"description": "Error message when camera permission is denied"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
|
|||||||
/// be consistent with the languages listed in the S.supportedLocales
|
/// be consistent with the languages listed in the S.supportedLocales
|
||||||
/// property.
|
/// property.
|
||||||
abstract class S {
|
abstract class S {
|
||||||
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
S(String locale)
|
||||||
|
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||||
|
|
||||||
final String localeName;
|
final String localeName;
|
||||||
|
|
||||||
@@ -84,7 +85,8 @@ abstract class S {
|
|||||||
/// Additional delegates can be added by appending to this list in
|
/// 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
|
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||||
/// of delegates is preferred or required.
|
/// of delegates is preferred or required.
|
||||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||||
|
<LocalizationsDelegate<dynamic>>[
|
||||||
delegate,
|
delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
@@ -2120,6 +2122,66 @@ abstract class S {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Select player to add to queue'**
|
/// **'Select player to add to queue'**
|
||||||
String get selectPlayerToAddToQueue;
|
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> {
|
class _SDelegate extends LocalizationsDelegate<S> {
|
||||||
@@ -2131,27 +2193,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
bool shouldReload(_SDelegate old) => false;
|
bool shouldReload(_SDelegate old) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
S lookupS(Locale locale) {
|
S lookupS(Locale locale) {
|
||||||
|
|
||||||
|
|
||||||
// Lookup logic when only language code is specified.
|
// Lookup logic when only language code is specified.
|
||||||
switch (locale.languageCode) {
|
switch (locale.languageCode) {
|
||||||
case 'de': return SDe();
|
case 'de':
|
||||||
case 'en': return SEn();
|
return SDe();
|
||||||
case 'es': return SEs();
|
case 'en':
|
||||||
case 'fr': return SFr();
|
return SEn();
|
||||||
|
case 'es':
|
||||||
|
return SEs();
|
||||||
|
case 'fr':
|
||||||
|
return SFr();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw FlutterError(
|
throw FlutterError(
|
||||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||||
'an issue with the localizations generation tool. Please file an issue '
|
'an issue with the localizations generation tool. Please file an issue '
|
||||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||||
'that was used.'
|
'that was used.');
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -39,7 +39,8 @@ class SDe extends S {
|
|||||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get username => 'Benutzername';
|
String get username => 'Benutzername';
|
||||||
@@ -60,10 +61,12 @@ class SDe extends S {
|
|||||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
String get authServerUrlDescription =>
|
||||||
|
'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String detectedAuthType(String authType) {
|
String detectedAuthType(String authType) {
|
||||||
@@ -80,40 +83,49 @@ class SDe extends S {
|
|||||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
String get usingHttpOverTailscale =>
|
||||||
|
'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
String get httpNotEncrypted =>
|
||||||
|
'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
String get pleaseEnterServerAddress =>
|
||||||
|
'Bitte gib deine Music Assistant Serveradresse ein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
String get pleaseEnterValidPort =>
|
||||||
|
'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
String get pleaseEnterCredentials =>
|
||||||
|
'Bitte gib Benutzername und Passwort ein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
String get authFailed =>
|
||||||
|
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
String get maLoginFailed =>
|
||||||
|
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get detectingAuth => 'Erkenne Authentifizierung...';
|
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAuthentication => 'Keine Authentifizierung';
|
String get noAuthentication => 'Keine Authentifizierung';
|
||||||
@@ -221,10 +233,12 @@ class SDe extends S {
|
|||||||
String get noRadioStations => 'Keine Radiosender';
|
String get noRadioStations => 'Keine Radiosender';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
String get addRadioStationsHint =>
|
||||||
|
'Füge Radiosender in Music Assistant hinzu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
String get searchFailed =>
|
||||||
|
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queue => 'Warteschlange';
|
String get queue => 'Warteschlange';
|
||||||
@@ -471,37 +485,43 @@ class SDe extends S {
|
|||||||
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAudiobooks => 'Keine Hörbücher';
|
String get noAudiobooks => 'Keine Hörbücher';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get loadSeries => 'Serien laden';
|
String get loadSeries => 'Serien laden';
|
||||||
@@ -513,7 +533,8 @@ class SDe extends S {
|
|||||||
String get notConnectedTitle => 'Nicht verbunden';
|
String get notConnectedTitle => 'Nicht verbunden';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get configureServer => 'Server konfigurieren';
|
String get configureServer => 'Server konfigurieren';
|
||||||
@@ -549,7 +570,8 @@ class SDe extends S {
|
|||||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
String get noDebugLogsYet =>
|
||||||
|
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showDebug => 'Debug anzeigen';
|
String get showDebug => 'Debug anzeigen';
|
||||||
@@ -649,10 +671,12 @@ class SDe extends S {
|
|||||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
String get showRandomAudiobooks =>
|
||||||
|
'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
String get showRandomSeries =>
|
||||||
|
'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||||
@@ -667,16 +691,19 @@ class SDe extends S {
|
|||||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
String get showFavoriteRadioStations =>
|
||||||
|
'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
String get extractColorsFromArtwork =>
|
||||||
|
'Farben aus Album- und Künstlercover extrahieren';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||||
@@ -785,13 +812,15 @@ class SDe extends S {
|
|||||||
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
String get chooseAudiobookLibraries =>
|
||||||
|
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||||
@@ -800,7 +829,8 @@ class SDe extends S {
|
|||||||
String get musicProviders => 'Music Providers';
|
String get musicProviders => 'Music Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
String get musicProvidersDescription =>
|
||||||
|
'Choose which accounts to show in your library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryRefreshing => 'Refreshing library...';
|
String get libraryRefreshing => 'Refreshing library...';
|
||||||
@@ -820,16 +850,19 @@ class SDe extends S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get search => 'Suche';
|
String get search => 'Suche';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||||
@@ -901,19 +934,22 @@ class SDe extends S {
|
|||||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get smartSortPlayers => 'Intelligente Sortierung';
|
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
String get smartSortPlayersDescription =>
|
||||||
|
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
String get disableAutoSwitchDescription =>
|
||||||
|
'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||||
@@ -945,7 +981,8 @@ class SDe extends S {
|
|||||||
String get showHints => 'Hinweise anzeigen';
|
String get showHints => 'Hinweise anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
String get showHintsDescription =>
|
||||||
|
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get display => 'Display';
|
String get display => 'Display';
|
||||||
@@ -954,7 +991,8 @@ class SDe extends S {
|
|||||||
String get showProviderIcons => 'Show Provider Icons';
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showProviderIconsDescription => 'Display provider logos on album art';
|
String get showProviderIconsDescription =>
|
||||||
|
'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||||
@@ -972,13 +1010,15 @@ class SDe extends S {
|
|||||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get skip => 'Skip';
|
String get skip => 'Skip';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String playingAlbum(String albumName) {
|
String playingAlbum(String albumName) {
|
||||||
@@ -994,19 +1034,22 @@ class SDe extends S {
|
|||||||
String get noTracks => 'No tracks';
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String failedToPlayRadioStation(String error) {
|
String failedToPlayRadioStation(String error) {
|
||||||
@@ -1112,4 +1155,38 @@ class SDe extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get localConnection => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnection => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessId => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessIdHint =>
|
||||||
|
'Find this in Music Assistant Settings → Remote Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnectionFailed =>
|
||||||
|
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scanQrCode => 'Scan QR Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pointCameraAtQr =>
|
||||||
|
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cameraPermissionDenied =>
|
||||||
|
'Camera permission is required to scan QR codes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -39,7 +39,8 @@ class SEn extends S {
|
|||||||
String get portHint => 'e.g., 8095 or leave blank';
|
String get portHint => 'e.g., 8095 or leave blank';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get username => 'Username';
|
String get username => 'Username';
|
||||||
@@ -60,10 +61,12 @@ class SEn extends S {
|
|||||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String detectedAuthType(String authType) {
|
String detectedAuthType(String authType) {
|
||||||
@@ -80,7 +83,8 @@ class SEn extends S {
|
|||||||
String get unencryptedConnection => 'Unencrypted Connection';
|
String get unencryptedConnection => 'Unencrypted Connection';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
String get usingHttpOverTailscale =>
|
||||||
|
'Using HTTP over Tailscale (encrypted tunnel)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
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';
|
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
String get pleaseEnterServerAddress =>
|
||||||
|
'Please enter your Music Assistant server address';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterName => 'Please enter your name';
|
String get pleaseEnterName => 'Please enter your name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
String get pleaseEnterValidPort =>
|
||||||
|
'Please enter a valid port number (1-65535)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pleaseEnterCredentials => 'Please enter username and password';
|
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authFailed => 'Authentication failed. Please check your credentials.';
|
String get authFailed =>
|
||||||
|
'Authentication failed. Please check your credentials.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
String get maLoginFailed =>
|
||||||
|
'Music Assistant login failed. Please check your credentials.';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get detectingAuth => 'Detecting authentication...';
|
String get detectingAuth => 'Detecting authentication...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
String get cannotDetermineAuth =>
|
||||||
|
'Cannot determine authentication requirements. Please check server URL.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noAuthentication => 'No Authentication';
|
String get noAuthentication => 'No Authentication';
|
||||||
@@ -471,37 +481,43 @@ class SEn extends S {
|
|||||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAudiobooks => 'No audiobooks';
|
String get noAudiobooks => 'No audiobooks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteArtists => 'No favorite artists';
|
String get noFavoriteArtists => 'No favorite artists';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteAlbums => 'No favorite albums';
|
String get noFavoriteAlbums => 'No favorite albums';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePlaylists => 'No favorite playlists';
|
String get noFavoritePlaylists => 'No favorite playlists';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteTracks => 'No favorite tracks';
|
String get noFavoriteTracks => 'No favorite tracks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get loadSeries => 'Load Series';
|
String get loadSeries => 'Load Series';
|
||||||
@@ -513,7 +529,8 @@ class SEn extends S {
|
|||||||
String get notConnectedTitle => 'Not Connected';
|
String get notConnectedTitle => 'Not Connected';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get configureServer => 'Configure Server';
|
String get configureServer => 'Configure Server';
|
||||||
@@ -667,16 +684,19 @@ class SEn extends S {
|
|||||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
String get showFavoriteRadioStations =>
|
||||||
|
'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
String get extractColorsFromArtwork =>
|
||||||
|
'Extract colors from album and artist artwork';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get addedToFavorites => 'Added to favorites';
|
String get addedToFavorites => 'Added to favorites';
|
||||||
@@ -791,7 +811,8 @@ class SEn extends S {
|
|||||||
String get audiobookLibraries => 'Audiobook Libraries';
|
String get audiobookLibraries => 'Audiobook Libraries';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
String get chooseAudiobookLibraries =>
|
||||||
|
'Choose which Audiobookshelf libraries to include';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unknownLibrary => 'Unknown Library';
|
String get unknownLibrary => 'Unknown Library';
|
||||||
@@ -800,7 +821,8 @@ class SEn extends S {
|
|||||||
String get musicProviders => 'Music Providers';
|
String get musicProviders => 'Music Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
String get musicProvidersDescription =>
|
||||||
|
'Choose which accounts to show in your library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryRefreshing => 'Refreshing library...';
|
String get libraryRefreshing => 'Refreshing library...';
|
||||||
@@ -820,16 +842,19 @@ class SEn extends S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
String get pullToLoadSeries =>
|
||||||
|
'Pull down to load series\nfrom Music Assistant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get search => 'Search';
|
String get search => 'Search';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get lastFmApiKey => 'Last.fm API Key';
|
String get lastFmApiKey => 'Last.fm API Key';
|
||||||
@@ -901,19 +926,22 @@ class SEn extends S {
|
|||||||
String get preferLocalPlayer => 'Prefer Local Player';
|
String get preferLocalPlayer => 'Prefer Local Player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
String get preferLocalPlayerDescription =>
|
||||||
|
'Always select this device first when available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get smartSortPlayers => 'Smart Sort';
|
String get smartSortPlayers => 'Smart Sort';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
String get disableAutoSwitchDescription =>
|
||||||
|
'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Unavailable';
|
String get playerStateUnavailable => 'Unavailable';
|
||||||
@@ -945,7 +973,8 @@ class SEn extends S {
|
|||||||
String get showHints => 'Show Hints';
|
String get showHints => 'Show Hints';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
String get showHintsDescription =>
|
||||||
|
'Display helpful tips for discovering features';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get display => 'Display';
|
String get display => 'Display';
|
||||||
@@ -954,7 +983,8 @@ class SEn extends S {
|
|||||||
String get showProviderIcons => 'Show Provider Icons';
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showProviderIconsDescription => 'Display provider logos on album art';
|
String get showProviderIconsDescription =>
|
||||||
|
'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Pull to select players';
|
String get pullToSelectPlayers => 'Pull to select players';
|
||||||
@@ -972,13 +1002,15 @@ class SEn extends S {
|
|||||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get skip => 'Skip';
|
String get skip => 'Skip';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String playingAlbum(String albumName) {
|
String playingAlbum(String albumName) {
|
||||||
@@ -994,19 +1026,22 @@ class SEn extends S {
|
|||||||
String get noTracks => 'No tracks';
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String failedToPlayRadioStation(String error) {
|
String failedToPlayRadioStation(String error) {
|
||||||
@@ -1112,4 +1147,38 @@ class SEn extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get localConnection => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnection => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessId => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessIdHint =>
|
||||||
|
'Find this in Music Assistant Settings → Remote Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnectionFailed =>
|
||||||
|
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scanQrCode => 'Scan QR Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pointCameraAtQr =>
|
||||||
|
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cameraPermissionDenied =>
|
||||||
|
'Camera permission is required to scan QR codes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -39,7 +39,8 @@ class SEs extends S {
|
|||||||
String get portHint => 'ej., 8095 o dejar en blanco';
|
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get username => 'Usuario';
|
String get username => 'Usuario';
|
||||||
@@ -57,13 +58,16 @@ class SEs extends S {
|
|||||||
String get disconnect => 'Desconectar';
|
String get disconnect => 'Desconectar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
String get authServerUrlOptional =>
|
||||||
|
'URL del servidor de autenticación (Opcional)';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String detectedAuthType(String authType) {
|
String detectedAuthType(String authType) {
|
||||||
@@ -80,40 +84,49 @@ class SEs extends S {
|
|||||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
String get usingHttpOverTailscale =>
|
||||||
|
'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
String get httpsFailedUsingHttp =>
|
||||||
|
'HTTPS falló, usando HTTP como alternativa';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
String get pleaseEnterCredentials =>
|
||||||
|
'Por favor, introduce usuario y contraseña';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
String get authFailed =>
|
||||||
|
'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get detectingAuth => 'Detectando autenticación...';
|
String get detectingAuth => 'Detectando autenticación...';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAuthentication => 'Sin autenticación';
|
String get noAuthentication => 'Sin autenticación';
|
||||||
@@ -221,10 +234,12 @@ class SEs extends S {
|
|||||||
String get noRadioStations => 'No hay emisoras de radio';
|
String get noRadioStations => 'No hay emisoras de radio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
String get addRadioStationsHint =>
|
||||||
|
'Añade emisoras de radio en Music Assistant';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get queue => 'Cola';
|
String get queue => 'Cola';
|
||||||
@@ -264,7 +279,8 @@ class SEs extends S {
|
|||||||
String get system => 'Sistema';
|
String get system => 'Sistema';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get viewDebugLogs => 'Ver registros de depuración';
|
String get viewDebugLogs => 'Ver registros de depuración';
|
||||||
@@ -301,7 +317,8 @@ class SEs extends S {
|
|||||||
String get logsCleared => 'Registros borrados';
|
String get logsCleared => 'Registros borrados';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
String get playerListCopied =>
|
||||||
|
'Lista de reproductores copiada al portapapeles';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noLogsYet => 'Aún no hay registros';
|
String get noLogsYet => 'Aún no hay registros';
|
||||||
@@ -471,37 +488,43 @@ class SEs extends S {
|
|||||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAudiobooks => 'No hay audiolibros';
|
String get noAudiobooks => 'No hay audiolibros';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteArtists => 'No hay artistas favoritos';
|
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteTracks => 'No hay canciones favoritas';
|
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get loadSeries => 'Cargar series';
|
String get loadSeries => 'Cargar series';
|
||||||
@@ -513,7 +536,8 @@ class SEs extends S {
|
|||||||
String get notConnectedTitle => 'No conectado';
|
String get notConnectedTitle => 'No conectado';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get configureServer => 'Configurar servidor';
|
String get configureServer => 'Configurar servidor';
|
||||||
@@ -549,7 +573,8 @@ class SEs extends S {
|
|||||||
String get noLogsToCopy => 'No hay registros para copiar';
|
String get noLogsToCopy => 'No hay registros para copiar';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get showDebug => 'Mostrar depuración';
|
String get showDebug => 'Mostrar depuración';
|
||||||
@@ -564,7 +589,8 @@ class SEs extends S {
|
|||||||
String get noChapters => 'Sin capítulos';
|
String get noChapters => 'Sin capítulos';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String errorSeeking(String error) {
|
String errorSeeking(String error) {
|
||||||
@@ -637,7 +663,8 @@ class SEs extends S {
|
|||||||
String get artist => 'Artista';
|
String get artist => 'Artista';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
String get showRecentlyPlayedAlbums =>
|
||||||
|
'Mostrar álbumes reproducidos recientemente';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||||
@@ -649,34 +676,41 @@ class SEs extends S {
|
|||||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
String get showRandomAudiobooks =>
|
||||||
|
'Mostrar audiolibros aleatorios para descubrir';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
String get showRandomSeries =>
|
||||||
|
'Mostrar series de audiolibros aleatorias para descubrir';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
String get showFavoriteArtists =>
|
||||||
|
'Mostrar una fila de tus artistas favoritos';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
String get showFavoriteTracks =>
|
||||||
|
'Mostrar una fila de tus canciones favoritas';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
String get showFavoriteRadioStations =>
|
||||||
|
'Show a row of your favorite radio stations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get addedToFavorites => 'Añadido a favoritos';
|
String get addedToFavorites => 'Añadido a favoritos';
|
||||||
@@ -774,7 +808,8 @@ class SEs extends S {
|
|||||||
String get loadingChapters => 'Cargando capítulos...';
|
String get loadingChapters => 'Cargando capítulos...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
String get noChapterInfoAvailable =>
|
||||||
|
'No hay información de capítulos disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String percentComplete(int percent) {
|
String percentComplete(int percent) {
|
||||||
@@ -785,13 +820,15 @@ class SEs extends S {
|
|||||||
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
String get theAudioDbApiKeyHint =>
|
||||||
|
'Usa \"2\" para nivel gratuito o clave premium';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
|
String get chooseAudiobookLibraries =>
|
||||||
|
'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unknownLibrary => 'Biblioteca desconocida';
|
String get unknownLibrary => 'Biblioteca desconocida';
|
||||||
@@ -800,7 +837,8 @@ class SEs extends S {
|
|||||||
String get musicProviders => 'Music Providers';
|
String get musicProviders => 'Music Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
String get musicProvidersDescription =>
|
||||||
|
'Choose which accounts to show in your library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryRefreshing => 'Refreshing library...';
|
String get libraryRefreshing => 'Refreshing library...';
|
||||||
@@ -820,16 +858,19 @@ class SEs extends S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get search => 'Buscar';
|
String get search => 'Buscar';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get lastFmApiKey => 'Clave API de Last.fm';
|
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||||
@@ -901,19 +942,22 @@ class SEs extends S {
|
|||||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
String get preferLocalPlayerDescription =>
|
||||||
|
'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get smartSortPlayers => 'Orden inteligente';
|
String get smartSortPlayers => 'Orden inteligente';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
String get disableAutoSwitchDescription =>
|
||||||
|
'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'No disponible';
|
String get playerStateUnavailable => 'No disponible';
|
||||||
@@ -945,7 +989,8 @@ class SEs extends S {
|
|||||||
String get showHints => 'Mostrar Sugerencias';
|
String get showHints => 'Mostrar Sugerencias';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
String get showHintsDescription =>
|
||||||
|
'Mostrar consejos útiles para descubrir funciones';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get display => 'Display';
|
String get display => 'Display';
|
||||||
@@ -954,7 +999,8 @@ class SEs extends S {
|
|||||||
String get showProviderIcons => 'Show Provider Icons';
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showProviderIconsDescription => 'Display provider logos on album art';
|
String get showProviderIconsDescription =>
|
||||||
|
'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||||
@@ -972,13 +1018,15 @@ class SEs extends S {
|
|||||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get skip => 'Skip';
|
String get skip => 'Skip';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String playingAlbum(String albumName) {
|
String playingAlbum(String albumName) {
|
||||||
@@ -994,19 +1042,22 @@ class SEs extends S {
|
|||||||
String get noTracks => 'No tracks';
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String failedToPlayRadioStation(String error) {
|
String failedToPlayRadioStation(String error) {
|
||||||
@@ -1112,4 +1163,38 @@ class SEs extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get localConnection => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnection => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessId => 'Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessIdHint =>
|
||||||
|
'Find this in Music Assistant Settings → Remote Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteConnectionFailed =>
|
||||||
|
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scanQrCode => 'Scan QR Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pointCameraAtQr =>
|
||||||
|
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cameraPermissionDenied =>
|
||||||
|
'Camera permission is required to scan QR codes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
// ignore: unused_import
|
||||||
import 'package:intl/intl.dart' as intl;
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
|
||||||
import 'app_localizations.dart';
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -39,7 +39,8 @@ class SFr extends S {
|
|||||||
String get portHint => 'ex., 8095 ou laisser vide';
|
String get portHint => 'ex., 8095 ou laisser vide';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get username => 'Nom d\'utilisateur';
|
String get username => 'Nom d\'utilisateur';
|
||||||
@@ -60,10 +61,12 @@ class SFr extends S {
|
|||||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
String get authServerUrlHint =>
|
||||||
|
'ex., auth.example.com (si different du serveur)';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String detectedAuthType(String authType) {
|
String detectedAuthType(String authType) {
|
||||||
@@ -80,40 +83,50 @@ class SFr extends S {
|
|||||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
String get usingHttpOverTailscale =>
|
||||||
|
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
String get httpsFailedUsingHttp =>
|
||||||
|
'HTTPS a échoué, utilisation de HTTP en repli';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
String get authFailed =>
|
||||||
|
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get detectingAuth => 'Detection de l\'authentification...';
|
String get detectingAuth => 'Detection de l\'authentification...';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAuthentication => 'Pas d\'authentification';
|
String get noAuthentication => 'Pas d\'authentification';
|
||||||
@@ -128,7 +141,8 @@ class SFr extends S {
|
|||||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
String get pressBackToMinimize =>
|
||||||
|
'Appuyez a nouveau sur retour pour minimiser';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentlyPlayed => 'Ecoutes recemment';
|
String get recentlyPlayed => 'Ecoutes recemment';
|
||||||
@@ -221,10 +235,12 @@ class SFr extends S {
|
|||||||
String get noRadioStations => 'Aucune station de radio';
|
String get noRadioStations => 'Aucune station de radio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
String get addRadioStationsHint =>
|
||||||
|
'Ajouter des stations de radio dans Music Assistant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
String get searchFailed =>
|
||||||
|
'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queue => 'File d\'attente';
|
String get queue => 'File d\'attente';
|
||||||
@@ -264,7 +280,8 @@ class SFr extends S {
|
|||||||
String get system => 'Système';
|
String get system => 'Système';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||||
@@ -301,7 +318,8 @@ class SFr extends S {
|
|||||||
String get logsCleared => 'Journaux effaces';
|
String get logsCleared => 'Journaux effaces';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
String get playerListCopied =>
|
||||||
|
'Liste des lecteurs copiee dans le presse-papiers !';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noLogsYet => 'Pas encore de journaux';
|
String get noLogsYet => 'Pas encore de journaux';
|
||||||
@@ -471,37 +489,43 @@ class SFr extends S {
|
|||||||
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noAudiobooks => 'Aucun livre audio';
|
String get noAudiobooks => 'Aucun livre audio';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteArtists => 'Aucun artiste favori';
|
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteAlbums => 'Aucun album favori';
|
String get noFavoriteAlbums => 'Aucun album favori';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteTracks => 'Aucune piste favorite';
|
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get loadSeries => 'Charger les séries';
|
String get loadSeries => 'Charger les séries';
|
||||||
@@ -513,7 +537,8 @@ class SFr extends S {
|
|||||||
String get notConnectedTitle => 'Not Connected';
|
String get notConnectedTitle => 'Not Connected';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get configureServer => 'Configurer le serveur';
|
String get configureServer => 'Configurer le serveur';
|
||||||
@@ -549,7 +574,8 @@ class SFr extends S {
|
|||||||
String get noLogsToCopy => 'Aucun journal a copier';
|
String get noLogsToCopy => 'Aucun journal a copier';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get showDebug => 'Afficher le debogage';
|
String get showDebug => 'Afficher le debogage';
|
||||||
@@ -564,7 +590,8 @@ class SFr extends S {
|
|||||||
String get noChapters => 'Aucun chapitre';
|
String get noChapters => 'Aucun chapitre';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String errorSeeking(String error) {
|
String errorSeeking(String error) {
|
||||||
@@ -637,10 +664,12 @@ class SFr extends S {
|
|||||||
String get artist => 'Artiste';
|
String get artist => 'Artiste';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
String get showRecentlyPlayedAlbums =>
|
||||||
|
'Afficher les albums écoutes recemment';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
String get showRandomArtists =>
|
||||||
|
'Afficher des artistes aléatoires a découvrir';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
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';
|
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
String get showFavoriteArtists =>
|
||||||
|
'Afficher une rangee de vos artistes favoris';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
String get showFavoriteTracks =>
|
||||||
|
'Afficher une rangee de vos pistes favorites';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
String get showFavoritePlaylists =>
|
||||||
|
'Afficher une rangee de vos playlists favorites';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
String get showFavoritePodcasts =>
|
||||||
|
'Afficher une rangee de vos podcasts favoris';
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get addedToFavorites => 'Ajoute aux favoris';
|
String get addedToFavorites => 'Ajoute aux favoris';
|
||||||
@@ -721,13 +759,15 @@ class SFr extends S {
|
|||||||
String get podcasts => 'Podcasts';
|
String get podcasts => 'Podcasts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
String get podcastSupportComingSoon =>
|
||||||
|
'Prise en charge des podcasts bientot disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noPodcasts => 'Aucun podcast';
|
String get noPodcasts => 'Aucun podcast';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
String get addPodcastsHint =>
|
||||||
|
'Abonnez-vous a des podcasts dans Music Assistant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get episodes => 'Episodes';
|
String get episodes => 'Episodes';
|
||||||
@@ -774,7 +814,8 @@ class SFr extends S {
|
|||||||
String get loadingChapters => 'Chargement des chapitres...';
|
String get loadingChapters => 'Chargement des chapitres...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
String get noChapterInfoAvailable =>
|
||||||
|
'Aucune information de chapitre disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String percentComplete(int percent) {
|
String percentComplete(int percent) {
|
||||||
@@ -785,13 +826,15 @@ class SFr extends S {
|
|||||||
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
|
String get chooseAudiobookLibraries =>
|
||||||
|
'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||||
@@ -800,7 +843,8 @@ class SFr extends S {
|
|||||||
String get musicProviders => 'Music Providers';
|
String get musicProviders => 'Music Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
String get musicProvidersDescription =>
|
||||||
|
'Choose which accounts to show in your library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryRefreshing => 'Refreshing library...';
|
String get libraryRefreshing => 'Refreshing library...';
|
||||||
@@ -820,16 +864,19 @@ class SFr extends S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
@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
|
@override
|
||||||
String get search => 'Rechercher';
|
String get search => 'Rechercher';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get lastFmApiKey => 'Cle API Last.fm';
|
String get lastFmApiKey => 'Cle API Last.fm';
|
||||||
@@ -852,7 +899,8 @@ class SFr extends S {
|
|||||||
String get playOn => 'Lire sur...';
|
String get playOn => 'Lire sur...';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||||
@@ -889,7 +937,8 @@ class SFr extends S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
String get materialYouDescription =>
|
||||||
|
'Utiliser les couleurs systeme (Android 12+)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accentColor => 'Couleur d\'accentuation';
|
String get accentColor => 'Couleur d\'accentuation';
|
||||||
@@ -901,19 +950,22 @@ class SFr extends S {
|
|||||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get smartSortPlayers => 'Tri intelligent';
|
String get smartSortPlayers => 'Tri intelligent';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
String get disableAutoSwitchDescription =>
|
||||||
|
'Never automatically switch to a different player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerStateUnavailable => 'Indisponible';
|
String get playerStateUnavailable => 'Indisponible';
|
||||||
@@ -945,7 +997,8 @@ class SFr extends S {
|
|||||||
String get showHints => 'Afficher les astuces';
|
String get showHints => 'Afficher les astuces';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get display => 'Display';
|
String get display => 'Display';
|
||||||
@@ -954,7 +1007,8 @@ class SFr extends S {
|
|||||||
String get showProviderIcons => 'Show Provider Icons';
|
String get showProviderIcons => 'Show Provider Icons';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showProviderIconsDescription => 'Display provider logos on album art';
|
String get showProviderIconsDescription =>
|
||||||
|
'Display provider logos on album art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
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';
|
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get skip => 'Passer';
|
String get skip => 'Passer';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String playingAlbum(String albumName) {
|
String playingAlbum(String albumName) {
|
||||||
@@ -994,19 +1051,22 @@ class SFr extends S {
|
|||||||
String get noTracks => 'No tracks';
|
String get noTracks => 'No tracks';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String failedToPlayRadioStation(String error) {
|
String failedToPlayRadioStation(String error) {
|
||||||
@@ -1112,4 +1172,38 @@ class SFr extends S {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
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
@@ -330,7 +330,31 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// Wrap entire app with global player overlay
|
// 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(),
|
home: const AppStartup(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,14 +31,20 @@ class ProviderMapping {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
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(
|
return ProviderMapping(
|
||||||
itemId: json['item_id'] as String? ?? '',
|
itemId: json['item_id'] as String? ?? '',
|
||||||
providerDomain: json['provider_domain'] as String? ?? '',
|
providerDomain: json['provider_domain'] as String? ?? '',
|
||||||
providerInstance: json['provider_instance'] as String? ?? '',
|
providerInstance: json['provider_instance'] as String? ?? '',
|
||||||
available: json['available'] as bool? ?? true,
|
available: json['available'] as bool? ?? true,
|
||||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||||
// Parse in_library field - MA uses 1/0 or true/false
|
inLibrary: inLibrary,
|
||||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_volume_controller/flutter_volume_controller.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/sendspin_service.dart';
|
||||||
import '../services/pcm_audio_player.dart';
|
import '../services/pcm_audio_player.dart';
|
||||||
import '../services/offline_action_queue.dart';
|
import '../services/offline_action_queue.dart';
|
||||||
|
import '../services/remote/remote_bridge.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../main.dart' show audioHandler;
|
import '../main.dart' show audioHandler;
|
||||||
@@ -105,6 +107,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
SendspinService? _sendspinService;
|
SendspinService? _sendspinService;
|
||||||
bool _sendspinConnected = false;
|
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
|
// PCM audio player for raw Sendspin audio streaming
|
||||||
PcmAudioPlayer? _pcmAudioPlayer;
|
PcmAudioPlayer? _pcmAudioPlayer;
|
||||||
|
|
||||||
@@ -145,6 +161,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||||
_connectionState == MAConnectionState.authenticated;
|
_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
|
// Library getters with provider filtering applied
|
||||||
List<Artist> get artists => filterByProvider(_artists);
|
List<Artist> get artists => filterByProvider(_artists);
|
||||||
List<Album> get albums => filterByProvider(_albums);
|
List<Album> get albums => filterByProvider(_albums);
|
||||||
@@ -820,11 +840,102 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||||
await _restoreAuthCredentials();
|
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();
|
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
|
/// Load cached players from database for instant UI display
|
||||||
Future<void> _loadPlayersFromDatabase() async {
|
Future<void> _loadPlayersFromDatabase() async {
|
||||||
try {
|
try {
|
||||||
@@ -1029,16 +1140,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// CONNECTION
|
// CONNECTION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
Future<void> connectToServer(String serverUrl) async {
|
Future<void> connectToServer(String serverUrl, {bool isRemoteMode = false, String? remoteCloudUrl}) async {
|
||||||
try {
|
try {
|
||||||
_error = null;
|
_error = null;
|
||||||
_serverUrl = serverUrl;
|
_serverUrl = serverUrl;
|
||||||
|
_isRemoteMode = isRemoteMode; // Store for reconnection
|
||||||
|
_remoteCloudUrl = remoteCloudUrl; // Store cloud URL for Sendspin in remote mode
|
||||||
await SettingsService.setServerUrl(serverUrl);
|
await SettingsService.setServerUrl(serverUrl);
|
||||||
|
|
||||||
// Dispose the old API to stop any pending reconnects
|
// Dispose the old API to stop any pending reconnects
|
||||||
_api?.dispose();
|
_api?.dispose();
|
||||||
|
|
||||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||||
|
_api!.isRemoteMode = isRemoteMode;
|
||||||
|
|
||||||
_connectionStateSubscription?.cancel();
|
_connectionStateSubscription?.cancel();
|
||||||
_connectionStateSubscription = _api!.connectionState.listen(
|
_connectionStateSubscription = _api!.connectionState.listen(
|
||||||
@@ -1063,9 +1177,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No auth required, initialize immediately
|
// No auth required, initialize immediately
|
||||||
|
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
|
||||||
await _initializeAfterConnection();
|
await _initializeAfterConnection();
|
||||||
} else if (state == MAConnectionState.authenticated) {
|
} else if (state == MAConnectionState.authenticated) {
|
||||||
_logger.log('✅ MA authentication successful');
|
_logger.log('✅ MA authentication successful');
|
||||||
|
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
|
||||||
// Now safe to initialize since we're authenticated
|
// Now safe to initialize since we're authenticated
|
||||||
await _initializeAfterConnection();
|
await _initializeAfterConnection();
|
||||||
} else if (state == MAConnectionState.disconnected) {
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
@@ -1073,11 +1189,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Keep showing cached data for instant UI display on reconnect
|
// Keep showing cached data for instant UI display on reconnect
|
||||||
// Player list and state will be refreshed when connection is restored
|
// Player list and state will be refreshed when connection is restored
|
||||||
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
|
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
|
||||||
|
// Start reconnect timer in remote mode
|
||||||
|
_startRemoteReconnectTimer();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
_logger.log('Connection state stream error: $error');
|
_logger.log('Connection state stream error: $error');
|
||||||
_connectionState = MAConnectionState.error;
|
_connectionState = MAConnectionState.error;
|
||||||
|
_startRemoteReconnectTimer(); // Start retry timer in remote mode
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1875,13 +1994,28 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_connectionState != MAConnectionState.connected &&
|
if (_connectionState != MAConnectionState.connected &&
|
||||||
_connectionState != MAConnectionState.authenticated) {
|
_connectionState != MAConnectionState.authenticated) {
|
||||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl (remote: $_isRemoteMode)');
|
||||||
try {
|
|
||||||
await connectToServer(_serverUrl!);
|
// In remote mode, retry multiple times since bridge may be recovering
|
||||||
_logger.log('🔄 Reconnection successful');
|
final maxRetries = _isRemoteMode ? 5 : 1;
|
||||||
} catch (e) {
|
for (var attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
_logger.log('🔄 Reconnection failed: $e');
|
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 {
|
} else {
|
||||||
_logger.log('🔄 Already connected, verifying connection...');
|
_logger.log('🔄 Already connected, verifying connection...');
|
||||||
try {
|
try {
|
||||||
@@ -1892,7 +2026,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
||||||
try {
|
try {
|
||||||
await connectToServer(_serverUrl!);
|
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
|
||||||
} catch (reconnectError) {
|
} catch (reconnectError) {
|
||||||
_logger.log('🔄 Reconnection failed: $reconnectError');
|
_logger.log('🔄 Reconnection failed: $reconnectError');
|
||||||
}
|
}
|
||||||
@@ -1900,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
|
// LOCAL PLAYER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2133,17 +2320,71 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// - MA 2.7.x: NO proxy, must either:
|
/// - MA 2.7.x: NO proxy, must either:
|
||||||
/// - Use local IP with port 8927 exposed, OR
|
/// - Use local IP with port 8927 exposed, OR
|
||||||
/// - Manually configure reverse proxy to route /sendspin to port 8927
|
/// - Manually configure reverse proxy to route /sendspin to port 8927
|
||||||
|
/// - Remote mode: Uses WebRTC bridge tunnel (ws://localhost:PORT/sendspin)
|
||||||
Future<bool> _connectViaSendspin() async {
|
Future<bool> _connectViaSendspin() async {
|
||||||
if (_api == null || _serverUrl == null) return false;
|
if (_api == null || _serverUrl == null) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final serverVersion = _getServerVersionString();
|
final serverVersion = _getServerVersionString();
|
||||||
final hasProxy = _serverHasSendspinProxy();
|
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?.dispose();
|
||||||
_sendspinService = SendspinService(_serverUrl!);
|
_sendspinService = SendspinService(sendspinServerUrl);
|
||||||
|
|
||||||
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
|
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
|
||||||
final authToken = await SettingsService.getMaAuthToken();
|
final authToken = await SettingsService.getMaAuthToken();
|
||||||
@@ -2176,13 +2417,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||||
_logger.log('Sendspin: Player ID: $playerId');
|
_logger.log('Sendspin: Player ID: $playerId');
|
||||||
|
|
||||||
// Parse server URL to determine connection strategy
|
// Parse server URL to determine connection strategy (local mode only - remote handled above)
|
||||||
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
|
final serverUri = Uri.parse(sendspinServerUrl.startsWith('http')
|
||||||
? _serverUrl!
|
? sendspinServerUrl
|
||||||
: 'https://$_serverUrl');
|
: 'https://$sendspinServerUrl');
|
||||||
final isLocalIp = _isLocalNetworkHost(serverUri.host);
|
final isLocalIp = _isLocalNetworkHost(serverUri.host);
|
||||||
final isHttps = serverUri.scheme == 'https' ||
|
final isHttps = serverUri.scheme == 'https' ||
|
||||||
(!_serverUrl!.contains('://') && !isLocalIp);
|
(!sendspinServerUrl.contains('://') && !isLocalIp);
|
||||||
|
|
||||||
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
|
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
|
||||||
if (isLocalIp) {
|
if (isLocalIp) {
|
||||||
@@ -2747,7 +2988,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Map<String, dynamic>? metadata;
|
Map<String, dynamic>? metadata;
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
var finalImageUrl = imageUrl;
|
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 {
|
try {
|
||||||
final imgUri = Uri.parse(imageUrl);
|
final imgUri = Uri.parse(imageUrl);
|
||||||
final queryString = imgUri.query;
|
final queryString = imgUri.query;
|
||||||
@@ -2987,7 +3235,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
artist ??= 'Unknown Artist';
|
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 {
|
try {
|
||||||
final imgUri = Uri.parse(imageUrl);
|
final imgUri = Uri.parse(imageUrl);
|
||||||
final queryString = imgUri.query;
|
final queryString = imgUri.query;
|
||||||
@@ -4836,7 +5086,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch tracks from API (not cached - too many items)
|
// 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 {
|
try {
|
||||||
_tracks = await _api!.getTracks(
|
_tracks = await _api!.getTracks(
|
||||||
limit: LibraryConstants.maxLibraryItems,
|
limit: LibraryConstants.maxLibraryItems,
|
||||||
@@ -4846,6 +5097,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||||
}
|
}
|
||||||
|
} else if (_api?.isRemoteMode == true) {
|
||||||
|
_logger.log('🌐 Remote mode: skipping tracks fetch');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -4867,6 +5120,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> _syncLibraryInBackground() async {
|
Future<void> _syncLibraryInBackground() async {
|
||||||
if (_api == null) return;
|
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;
|
final syncService = SyncService.instance;
|
||||||
|
|
||||||
// Listen for sync completion to update our lists
|
// Listen for sync completion to update our lists
|
||||||
@@ -4881,7 +5141,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncService.addListener(onSyncComplete);
|
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
|
/// Load radio stations from the library
|
||||||
@@ -5952,6 +6219,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_sleepTimer?.cancel();
|
_sleepTimer?.cancel();
|
||||||
_sleepTimerDisplayTimer?.cancel();
|
_sleepTimerDisplayTimer?.cancel();
|
||||||
_idleServiceTimer?.cancel();
|
_idleServiceTimer?.cancel();
|
||||||
|
_remoteReconnectTimer?.cancel();
|
||||||
|
_remoteBridgeStateSubscription?.cancel();
|
||||||
_connectionStateSubscription?.cancel();
|
_connectionStateSubscription?.cancel();
|
||||||
_localPlayerEventSubscription?.cancel();
|
_localPlayerEventSubscription?.cancel();
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||||
|
|
||||||
void _copyLogs() {
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(S.of(context)!.logsCopied),
|
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
report,
|
report,
|
||||||
subject: S.of(context)!.ensembleBugReport,
|
subject: S.of(context)!.ensembleBugReport,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Share failed', error: e);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isGeneratingReport = false);
|
setState(() => _isGeneratingReport = false);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import '../services/settings_service.dart';
|
|||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../services/profile_service.dart';
|
import '../services/profile_service.dart';
|
||||||
import '../services/auth/auth_strategy.dart';
|
import '../services/auth/auth_strategy.dart';
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../widgets/debug/debug_console.dart';
|
import '../widgets/debug/debug_console.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
import 'qr_scanner_screen.dart';
|
||||||
|
|
||||||
|
enum ConnectionMode { local, remote }
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
@@ -24,6 +26,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
||||||
final TextEditingController _usernameController = TextEditingController();
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
|
||||||
final FocusNode _usernameFocusNode = FocusNode();
|
final FocusNode _usernameFocusNode = FocusNode();
|
||||||
|
|
||||||
bool _isConnecting = false;
|
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)
|
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
|
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||||
|
|
||||||
|
// Remote connection state
|
||||||
|
ConnectionMode _connectionMode = ConnectionMode.local;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -74,7 +80,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_authServerUrlController.dispose();
|
_authServerUrlController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_remoteIdController.dispose();
|
||||||
_usernameFocusNode.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();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +452,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.isConnected) {
|
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
|
// For first-time users, wait for player selection so the welcome
|
||||||
// overlay can appear immediately without home screen flash.
|
// overlay can appear immediately without home screen flash.
|
||||||
// This matches the logic in AppStartup._checkAndConnect().
|
// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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(
|
Text(
|
||||||
S.of(context)!.serverAddress,
|
S.of(context)!.serverAddress,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
@@ -856,6 +1217,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
], // End of local mode conditional
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -902,7 +1264,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: (_isConnecting || _isDetectingAuth)
|
onPressed: (_isConnecting || _isDetectingAuth)
|
||||||
? null
|
? null
|
||||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
: _connectionMode == ConnectionMode.remote
|
||||||
|
? _connectRemote
|
||||||
|
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
@@ -944,7 +1308,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: 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(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
@@ -226,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
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
|
// Sync indicator - shows when library is syncing in background
|
||||||
ListenableBuilder(
|
ListenableBuilder(
|
||||||
listenable: SyncService.instance,
|
listenable: SyncService.instance,
|
||||||
|
|||||||
@@ -1502,8 +1502,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
itemCount: _tabCount,
|
itemCount: _tabCount,
|
||||||
// Faster settling so vertical scroll works sooner after swipe
|
// Use default physics - custom physics may cause issues with Flutter 3.38
|
||||||
physics: const _FastPageScrollPhysics(),
|
physics: const PageScrollPhysics(),
|
||||||
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
|
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -4708,9 +4708,9 @@ class _FastPageScrollPhysics extends PageScrollPhysics {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
SpringDescription get spring => const SpringDescription(
|
SpringDescription get spring => const SpringDescription(
|
||||||
mass: 50, // Lower mass = faster movement
|
mass: 80, // Increased mass for more controlled movement
|
||||||
stiffness: 500, // Higher stiffness = snappier
|
stiffness: 100, // Reduced stiffness to prevent over-snapping
|
||||||
damping: 1.0, // Critical damping for no overshoot
|
damping: 1.2, // Slightly over-damped to prevent overshoot
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'basic_auth_strategy.dart';
|
|||||||
import 'authelia_strategy.dart';
|
import 'authelia_strategy.dart';
|
||||||
import 'ma_auth_strategy.dart';
|
import 'ma_auth_strategy.dart';
|
||||||
import '../debug_logger.dart';
|
import '../debug_logger.dart';
|
||||||
|
import '../../constants/network.dart';
|
||||||
|
|
||||||
/// Central authentication manager
|
/// Central authentication manager
|
||||||
/// Manages auth strategy selection, auto-detection, and credential lifecycle
|
/// Manages auth strategy selection, auto-detection, and credential lifecycle
|
||||||
@@ -65,16 +66,18 @@ class AuthManager {
|
|||||||
return result;
|
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) {
|
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://')) {
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
// Use http for local IPs, https for domains
|
// Use http for local IPs, https for domains
|
||||||
if (baseUrl.startsWith('192.') ||
|
if (_isLocalAddress(baseUrl)) {
|
||||||
baseUrl.startsWith('10.') ||
|
|
||||||
baseUrl.startsWith('172.') ||
|
|
||||||
baseUrl == 'localhost' ||
|
|
||||||
baseUrl.startsWith('127.')) {
|
|
||||||
baseUrl = 'http://$baseUrl';
|
baseUrl = 'http://$baseUrl';
|
||||||
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||||
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||||
@@ -84,9 +87,80 @@ class AuthManager {
|
|||||||
baseUrl = 'https://$baseUrl';
|
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;
|
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
|
/// Check if a hostname is a local/private IP address
|
||||||
bool _isLocalIpAddress(String host) {
|
bool _isLocalIpAddress(String host) {
|
||||||
return host.startsWith('192.168.') ||
|
return host.startsWith('192.168.') ||
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ enum ErrorType {
|
|||||||
network,
|
network,
|
||||||
playback,
|
playback,
|
||||||
library,
|
library,
|
||||||
|
remote, // Remote connection (WebRTC) errors
|
||||||
|
dataChannel, // WebRTC data channel errors
|
||||||
|
signaling, // WebRTC signaling server errors
|
||||||
unknown,
|
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
|
// Unknown errors
|
||||||
return ErrorInfo(
|
return ErrorInfo(
|
||||||
type: ErrorType.unknown,
|
type: ErrorType.unknown,
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ enum MAConnectionState {
|
|||||||
error,
|
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 {
|
class MusicAssistantAPI {
|
||||||
final String serverUrl;
|
final String serverUrl;
|
||||||
final AuthManager authManager;
|
final AuthManager authManager;
|
||||||
@@ -38,7 +52,7 @@ class MusicAssistantAPI {
|
|||||||
MAConnectionState _currentState = MAConnectionState.disconnected;
|
MAConnectionState _currentState = MAConnectionState.disconnected;
|
||||||
MAConnectionState get currentConnectionState => _currentState;
|
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 = {};
|
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
|
||||||
|
|
||||||
Completer<void>? _connectionCompleter;
|
Completer<void>? _connectionCompleter;
|
||||||
@@ -65,6 +79,12 @@ class MusicAssistantAPI {
|
|||||||
final Map<String, ProviderManifest> _providerManifests = {};
|
final Map<String, ProviderManifest> _providerManifests = {};
|
||||||
Map<String, ProviderManifest> get providerManifests => _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);
|
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||||
|
|
||||||
// Guard to prevent multiple simultaneous connection attempts
|
// Guard to prevent multiple simultaneous connection attempts
|
||||||
@@ -285,15 +305,15 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
// Handle response to a request
|
// Handle response to a request
|
||||||
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||||
final completer = _pendingRequests.remove(messageId);
|
final pending = _pendingRequests.remove(messageId);
|
||||||
|
|
||||||
if (data.containsKey('error_code')) {
|
if (data.containsKey('error_code')) {
|
||||||
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
|
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
|
||||||
completer!.completeError(
|
pending!.completer.completeError(
|
||||||
Exception('${data['error_code']}: ${data['details']}'),
|
Exception('${data['error_code']}: ${data['details']}'),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
completer!.complete(data);
|
pending!.completer.complete(data);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -352,6 +372,9 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up stale requests before checking limits
|
||||||
|
_cleanupStaleRequests();
|
||||||
|
|
||||||
// Prevent memory leaks by limiting pending requests
|
// Prevent memory leaks by limiting pending requests
|
||||||
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
|
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
@@ -362,7 +385,7 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
final messageId = _uuid.v4();
|
final messageId = _uuid.v4();
|
||||||
final completer = Completer<Map<String, dynamic>>();
|
final completer = Completer<Map<String, dynamic>>();
|
||||||
_pendingRequests[messageId] = completer;
|
_pendingRequests[messageId] = _PendingRequest(completer: completer);
|
||||||
|
|
||||||
final message = {
|
final message = {
|
||||||
'message_id': messageId,
|
'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
|
// Library browsing methods
|
||||||
Future<List<Artist>> getArtists({
|
Future<List<Artist>> getArtists({
|
||||||
int? limit,
|
int? limit,
|
||||||
@@ -3216,6 +3260,12 @@ class MusicAssistantAPI {
|
|||||||
return null;
|
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)
|
// Try to find a non-remotely accessible image first (prefer local/opensubsonic)
|
||||||
Map<String, dynamic>? selectedImage;
|
Map<String, dynamic>? selectedImage;
|
||||||
for (var img in images) {
|
for (var img in images) {
|
||||||
@@ -3278,6 +3328,107 @@ class MusicAssistantAPI {
|
|||||||
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
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 ====================
|
// ==================== iTunes Artwork Lookup ====================
|
||||||
|
|
||||||
/// Search iTunes for a podcast and return high-res artwork URL
|
/// Search iTunes for a podcast and return high-res artwork URL
|
||||||
@@ -3677,8 +3828,8 @@ class MusicAssistantAPI {
|
|||||||
/// Cancel all pending requests with an error message
|
/// Cancel all pending requests with an error message
|
||||||
void _cancelPendingRequests(String reason) {
|
void _cancelPendingRequests(String reason) {
|
||||||
for (final entry in _pendingRequests.entries) {
|
for (final entry in _pendingRequests.entries) {
|
||||||
if (!entry.value.isCompleted) {
|
if (!entry.value.completer.isCompleted) {
|
||||||
entry.value.completeError(Exception(reason));
|
entry.value.completer.completeError(Exception(reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_pendingRequests.clear();
|
_pendingRequests.clear();
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
|
||||||
|
import 'package:ensemble/services/debug_logger.dart';
|
||||||
|
|
||||||
|
/// Error types for RemoteBridge
|
||||||
|
enum RemoteBridgeErrorType {
|
||||||
|
webrtcConnection,
|
||||||
|
signaling,
|
||||||
|
authentication,
|
||||||
|
dataChannel,
|
||||||
|
timeout,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error information from RemoteBridge
|
||||||
|
class RemoteBridgeError {
|
||||||
|
final RemoteBridgeErrorType type;
|
||||||
|
final String message;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
RemoteBridgeError({
|
||||||
|
required this.type,
|
||||||
|
required this.message,
|
||||||
|
DateTime? timestamp,
|
||||||
|
}) : timestamp = timestamp ?? DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection state for RemoteBridge
|
||||||
|
enum RemoteBridgeState {
|
||||||
|
disconnected,
|
||||||
|
connecting,
|
||||||
|
authenticating,
|
||||||
|
connected,
|
||||||
|
reconnecting,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection quality metrics
|
||||||
|
class ConnectionMetrics {
|
||||||
|
final int requestsSent;
|
||||||
|
final int responsesReceived;
|
||||||
|
final int reconnectionCount;
|
||||||
|
final Duration? lastLatency;
|
||||||
|
final DateTime? lastActivity;
|
||||||
|
|
||||||
|
ConnectionMetrics({
|
||||||
|
required this.requestsSent,
|
||||||
|
required this.responsesReceived,
|
||||||
|
required this.reconnectionCount,
|
||||||
|
this.lastLatency,
|
||||||
|
this.lastActivity,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get successRate {
|
||||||
|
if (requestsSent == 0) return 1.0;
|
||||||
|
return responsesReceived / requestsSent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
|
||||||
|
/// for remote connections. All traffic is tunneled through a local WebSocket server.
|
||||||
|
///
|
||||||
|
/// Architecture:
|
||||||
|
/// ```
|
||||||
|
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
|
||||||
|
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
|
||||||
|
/// ```
|
||||||
|
class RemoteBridge {
|
||||||
|
HttpServer? _server;
|
||||||
|
WebViewWebRTCConnection? _webrtcConnection;
|
||||||
|
int? _port;
|
||||||
|
|
||||||
|
// API client (MusicAssistantAPI)
|
||||||
|
WebSocket? _apiClientSocket;
|
||||||
|
StreamSubscription? _apiClientSubscription;
|
||||||
|
StreamSubscription? _apiWebrtcSubscription;
|
||||||
|
|
||||||
|
// Sendspin client (local player audio)
|
||||||
|
WebSocket? _sendspinClientSocket;
|
||||||
|
StreamSubscription? _sendspinClientSubscription;
|
||||||
|
StreamSubscription? _sendspinTextSubscription;
|
||||||
|
StreamSubscription? _sendspinBinarySubscription;
|
||||||
|
|
||||||
|
Timer? _healthCheckTimer;
|
||||||
|
Timer? _reconnectTimer;
|
||||||
|
|
||||||
|
// Connection parameters for reconnection
|
||||||
|
String? _remoteId;
|
||||||
|
String? _username;
|
||||||
|
String? _password;
|
||||||
|
bool _isReconnecting = false;
|
||||||
|
int _reconnectAttempts = 0;
|
||||||
|
static const int _maxReconnectAttempts = 5;
|
||||||
|
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||||
|
static const Duration _rateLimitDelay = Duration(seconds: 65);
|
||||||
|
bool _isRateLimited = false;
|
||||||
|
|
||||||
|
// Cached server hello to replay when WS client connects
|
||||||
|
String? _cachedServerHello;
|
||||||
|
|
||||||
|
// Debug counters
|
||||||
|
int _apiMessagesSent = 0;
|
||||||
|
int _apiMessagesReceived = 0;
|
||||||
|
int _sendspinMessagesSent = 0;
|
||||||
|
int _sendspinMessagesReceived = 0;
|
||||||
|
DateTime? _lastMessageReceived;
|
||||||
|
int _totalReconnections = 0;
|
||||||
|
|
||||||
|
// State tracking
|
||||||
|
RemoteBridgeState _state = RemoteBridgeState.disconnected;
|
||||||
|
final _stateController = StreamController<RemoteBridgeState>.broadcast();
|
||||||
|
final _errorController = StreamController<RemoteBridgeError>.broadcast();
|
||||||
|
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
|
||||||
|
int? get port => _port;
|
||||||
|
bool get isRunning => _server != null;
|
||||||
|
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
|
||||||
|
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
|
||||||
|
RemoteBridgeState get state => _state;
|
||||||
|
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
|
||||||
|
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
|
||||||
|
|
||||||
|
/// Build the hidden WebView widget that must be added to the widget tree.
|
||||||
|
/// This must be called and the returned widget added to the tree BEFORE calling start().
|
||||||
|
/// The WebView is 1x1 pixel and invisible.
|
||||||
|
Widget buildWebView() {
|
||||||
|
// Create the connection if not already created
|
||||||
|
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||||
|
return _webrtcConnection!.buildWebView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current connection metrics
|
||||||
|
ConnectionMetrics get metrics => ConnectionMetrics(
|
||||||
|
requestsSent: _apiMessagesSent,
|
||||||
|
responsesReceived: _apiMessagesReceived,
|
||||||
|
reconnectionCount: _totalReconnections,
|
||||||
|
lastActivity: _lastMessageReceived,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Update state and notify listeners
|
||||||
|
void _setState(RemoteBridgeState newState) {
|
||||||
|
if (_state != newState) {
|
||||||
|
_logger.log('RemoteBridge: State changed: $_state -> $newState');
|
||||||
|
_state = newState;
|
||||||
|
_stateController.add(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an error to listeners
|
||||||
|
void _emitError(RemoteBridgeErrorType type, String message) {
|
||||||
|
final error = RemoteBridgeError(type: type, message: message);
|
||||||
|
_logger.log('RemoteBridge: Error [$type]: $message');
|
||||||
|
_errorController.add(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the bridge and connect to the remote MA server.
|
||||||
|
/// Returns the local port number to connect to, or null if failed.
|
||||||
|
Future<int?> start(String remoteId, String username, String password) async {
|
||||||
|
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
|
||||||
|
_setState(RemoteBridgeState.connecting);
|
||||||
|
|
||||||
|
// Store connection parameters for reconnection
|
||||||
|
_remoteId = remoteId;
|
||||||
|
_username = username;
|
||||||
|
_password = password;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
|
||||||
|
// The WebView must be in the widget tree first - call buildWebView() before start()
|
||||||
|
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||||
|
|
||||||
|
_webrtcConnection!.onStateChanged = (state) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC state: $state');
|
||||||
|
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||||
|
state == WebViewWebRTCConnectionState.failed) {
|
||||||
|
_handleWebRTCDisconnection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_webrtcConnection!.onError = (error) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC error: $error');
|
||||||
|
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
|
||||||
|
};
|
||||||
|
|
||||||
|
final connected = await _webrtcConnection!.connect(remoteId);
|
||||||
|
if (!connected) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC connection failed');
|
||||||
|
_setState(RemoteBridgeState.failed);
|
||||||
|
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
|
||||||
|
await stop();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
|
||||||
|
_setState(RemoteBridgeState.authenticating);
|
||||||
|
|
||||||
|
// Step 2: Authenticate with MA
|
||||||
|
final authenticated = await _authenticate(username, password);
|
||||||
|
if (!authenticated) {
|
||||||
|
_logger.log('RemoteBridge: Authentication failed');
|
||||||
|
_setState(RemoteBridgeState.failed);
|
||||||
|
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
|
||||||
|
await stop();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Authentication successful');
|
||||||
|
_setState(RemoteBridgeState.connected);
|
||||||
|
|
||||||
|
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
|
||||||
|
if (_webrtcConnection!.serverInfo != null) {
|
||||||
|
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||||
|
_logger.log('RemoteBridge: Cached server hello');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Start local WebSocket server
|
||||||
|
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
_port = _server!.port;
|
||||||
|
_logger.log('RemoteBridge: Local WS server started on port $_port');
|
||||||
|
|
||||||
|
// Handle incoming connections
|
||||||
|
_server!.listen(_handleHttpRequest);
|
||||||
|
|
||||||
|
// Step 5: Subscribe to WebRTC API messages to forward to API client
|
||||||
|
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||||
|
_apiMessagesReceived++;
|
||||||
|
_lastMessageReceived = DateTime.now();
|
||||||
|
if (_apiClientSocket != null) {
|
||||||
|
_apiClientSocket!.add(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
|
||||||
|
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||||
|
_sendspinMessagesReceived++;
|
||||||
|
_lastMessageReceived = DateTime.now();
|
||||||
|
if (_sendspinClientSocket != null) {
|
||||||
|
_sendspinClientSocket!.add(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
|
||||||
|
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||||
|
_sendspinMessagesReceived++;
|
||||||
|
if (_sendspinClientSocket != null) {
|
||||||
|
// Forward binary audio data to Sendspin client
|
||||||
|
_sendspinClientSocket!.add(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: Start health check timer
|
||||||
|
_startHealthCheck();
|
||||||
|
|
||||||
|
return _port;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('RemoteBridge: Start failed: $e');
|
||||||
|
_setState(RemoteBridgeState.failed);
|
||||||
|
_emitError(RemoteBridgeErrorType.unknown, e.toString());
|
||||||
|
await stop();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate with the MA server over WebRTC.
|
||||||
|
Future<bool> _authenticate(String username, String password) async {
|
||||||
|
try {
|
||||||
|
// Step 1: Login to get access token
|
||||||
|
_logger.log('RemoteBridge: Sending auth/login...');
|
||||||
|
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'device_name': 'Ensemble Mobile App',
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Login result: $loginResult');
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
|
if (loginResult.containsKey('error_code')) {
|
||||||
|
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? accessToken;
|
||||||
|
if (loginResult['result'] != null) {
|
||||||
|
final result = loginResult['result'];
|
||||||
|
if (result is Map<String, dynamic>) {
|
||||||
|
if (result['success'] == true) {
|
||||||
|
accessToken = result['access_token'] as String?;
|
||||||
|
final user = result['user'] as Map<String, dynamic>?;
|
||||||
|
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken == null) {
|
||||||
|
_logger.log('RemoteBridge: No access token received');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Authenticate the session with the token
|
||||||
|
_logger.log('RemoteBridge: Authenticating session with token...');
|
||||||
|
final authResult = await _webrtcConnection!.sendRequest('auth', {
|
||||||
|
'token': accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Auth result: $authResult');
|
||||||
|
|
||||||
|
if (authResult.containsKey('error_code')) {
|
||||||
|
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Session authenticated successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('RemoteBridge: Auth error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming HTTP requests and upgrade to WebSocket.
|
||||||
|
void _handleHttpRequest(HttpRequest request) async {
|
||||||
|
final path = request.uri.path;
|
||||||
|
_logger.log('RemoteBridge: Incoming HTTP request: $path');
|
||||||
|
|
||||||
|
if (WebSocketTransformer.isUpgradeRequest(request)) {
|
||||||
|
try {
|
||||||
|
final socket = await WebSocketTransformer.upgrade(request);
|
||||||
|
|
||||||
|
// Route based on path: /sendspin for audio, anything else for API
|
||||||
|
if (path == '/sendspin') {
|
||||||
|
_handleSendspinConnection(socket);
|
||||||
|
} else {
|
||||||
|
_handleApiConnection(socket);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
|
||||||
|
request.response.statusCode = HttpStatus.internalServerError;
|
||||||
|
await request.response.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not a WebSocket request
|
||||||
|
request.response.statusCode = HttpStatus.badRequest;
|
||||||
|
await request.response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a new API WebSocket client connection.
|
||||||
|
void _handleApiConnection(WebSocket socket) {
|
||||||
|
_logger.log('RemoteBridge: API client connected');
|
||||||
|
|
||||||
|
// Only allow one API client at a time
|
||||||
|
if (_apiClientSocket != null) {
|
||||||
|
_logger.log('RemoteBridge: Closing existing API client connection');
|
||||||
|
_apiClientSubscription?.cancel();
|
||||||
|
_apiClientSocket?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClientSocket = socket;
|
||||||
|
|
||||||
|
// Step 1: Send cached server hello immediately
|
||||||
|
if (_cachedServerHello != null) {
|
||||||
|
_logger.log('RemoteBridge: Sending cached server hello to API client');
|
||||||
|
_apiClientSocket!.add(_cachedServerHello!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
|
||||||
|
_apiClientSubscription = _apiClientSocket!.listen(
|
||||||
|
(message) {
|
||||||
|
if (message is String && _webrtcConnection != null) {
|
||||||
|
_apiMessagesSent++;
|
||||||
|
_webrtcConnection!.sendRaw(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_logger.log('RemoteBridge: API client disconnected');
|
||||||
|
_apiClientSocket = null;
|
||||||
|
_apiClientSubscription = null;
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('RemoteBridge: API client error: $error');
|
||||||
|
_apiClientSocket = null;
|
||||||
|
_apiClientSubscription = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
|
||||||
|
void _handleSendspinConnection(WebSocket socket) {
|
||||||
|
_logger.log('RemoteBridge: Sendspin client connected');
|
||||||
|
|
||||||
|
// Only allow one Sendspin client at a time
|
||||||
|
if (_sendspinClientSocket != null) {
|
||||||
|
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
|
||||||
|
_sendspinClientSubscription?.cancel();
|
||||||
|
_sendspinClientSocket?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendspinClientSocket = socket;
|
||||||
|
|
||||||
|
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
|
||||||
|
_sendspinClientSubscription = _sendspinClientSocket!.listen(
|
||||||
|
(message) {
|
||||||
|
if (_webrtcConnection != null) {
|
||||||
|
_sendspinMessagesSent++;
|
||||||
|
if (message is String) {
|
||||||
|
// Text message (JSON control)
|
||||||
|
_webrtcConnection!.sendSendspinText(message);
|
||||||
|
} else if (message is List<int>) {
|
||||||
|
// Binary message (would be audio data from client, unlikely but handle it)
|
||||||
|
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_logger.log('RemoteBridge: Sendspin client disconnected');
|
||||||
|
_sendspinClientSocket = null;
|
||||||
|
_sendspinClientSubscription = null;
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('RemoteBridge: Sendspin client error: $error');
|
||||||
|
_sendspinClientSocket = null;
|
||||||
|
_sendspinClientSubscription = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle WebRTC disconnection - attempt reconnection.
|
||||||
|
void _handleWebRTCDisconnection() {
|
||||||
|
_logger.log('RemoteBridge: WebRTC disconnected');
|
||||||
|
|
||||||
|
// Don't close WS clients immediately - they will reconnect when WebRTC is back
|
||||||
|
// Instead, attempt to reconnect WebRTC
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule a WebRTC reconnection attempt.
|
||||||
|
void _scheduleReconnect() {
|
||||||
|
if (_isReconnecting) {
|
||||||
|
_logger.log('RemoteBridge: Already reconnecting, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_remoteId == null || _username == null || _password == null) {
|
||||||
|
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
|
||||||
|
_setState(RemoteBridgeState.failed);
|
||||||
|
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
|
||||||
|
_closeAllClients();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||||
|
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
|
||||||
|
_setState(RemoteBridgeState.failed);
|
||||||
|
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
|
||||||
|
_closeAllClients();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setState(RemoteBridgeState.reconnecting);
|
||||||
|
_reconnectAttempts++;
|
||||||
|
_totalReconnections++;
|
||||||
|
|
||||||
|
// Use longer delay if rate limited, otherwise exponential backoff
|
||||||
|
final Duration delay;
|
||||||
|
if (_isRateLimited) {
|
||||||
|
delay = _rateLimitDelay;
|
||||||
|
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
|
||||||
|
} else {
|
||||||
|
delay = _reconnectDelay * _reconnectAttempts;
|
||||||
|
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = Timer(delay, _reconnectWebRTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to reconnect WebRTC.
|
||||||
|
Future<void> _reconnectWebRTC() async {
|
||||||
|
if (_isReconnecting) return;
|
||||||
|
_isReconnecting = true;
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
|
||||||
|
_apiWebrtcSubscription?.cancel();
|
||||||
|
_sendspinTextSubscription?.cancel();
|
||||||
|
_sendspinBinarySubscription?.cancel();
|
||||||
|
await _webrtcConnection?.disconnect();
|
||||||
|
|
||||||
|
// Reuse existing WebView connection (WebView is already in widget tree)
|
||||||
|
// If somehow null, create a new one (shouldn't happen in normal flow)
|
||||||
|
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||||
|
|
||||||
|
_webrtcConnection!.onStateChanged = (state) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
|
||||||
|
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||||
|
state == WebViewWebRTCConnectionState.failed) {
|
||||||
|
_isReconnecting = false;
|
||||||
|
_handleWebRTCDisconnection();
|
||||||
|
} else if (state == WebViewWebRTCConnectionState.connected) {
|
||||||
|
_reconnectAttempts = 0; // Reset on successful connection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_webrtcConnection!.onError = (error) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
|
||||||
|
// Detect rate limiting from signaling server
|
||||||
|
if (error.toLowerCase().contains('rate limit')) {
|
||||||
|
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
|
||||||
|
_isRateLimited = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
final connected = await _webrtcConnection!.connect(_remoteId!);
|
||||||
|
if (!connected) {
|
||||||
|
_logger.log('RemoteBridge: WebRTC reconnection failed');
|
||||||
|
_isReconnecting = false;
|
||||||
|
_scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
|
||||||
|
|
||||||
|
// Re-authenticate
|
||||||
|
final authenticated = await _authenticate(_username!, _password!);
|
||||||
|
if (!authenticated) {
|
||||||
|
_logger.log('RemoteBridge: Re-authentication failed');
|
||||||
|
_isReconnecting = false;
|
||||||
|
_scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Re-authentication successful');
|
||||||
|
|
||||||
|
// Re-cache server hello
|
||||||
|
if (_webrtcConnection!.serverInfo != null) {
|
||||||
|
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||||
|
_logger.log('RemoteBridge: Re-cached server hello');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-subscribe to WebRTC streams
|
||||||
|
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||||
|
_apiMessagesReceived++;
|
||||||
|
_lastMessageReceived = DateTime.now();
|
||||||
|
if (_apiClientSocket != null) {
|
||||||
|
_apiClientSocket!.add(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||||
|
_sendspinMessagesReceived++;
|
||||||
|
_lastMessageReceived = DateTime.now();
|
||||||
|
if (_sendspinClientSocket != null) {
|
||||||
|
_sendspinClientSocket!.add(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||||
|
_sendspinMessagesReceived++;
|
||||||
|
if (_sendspinClientSocket != null) {
|
||||||
|
_sendspinClientSocket!.add(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
|
||||||
|
_setState(RemoteBridgeState.connected);
|
||||||
|
_isReconnecting = false;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
_isRateLimited = false;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('RemoteBridge: Reconnection error: $e');
|
||||||
|
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
|
||||||
|
_isReconnecting = false;
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all WS clients (called when giving up on reconnection).
|
||||||
|
void _closeAllClients() {
|
||||||
|
_logger.log('RemoteBridge: Closing all WS clients');
|
||||||
|
|
||||||
|
_apiClientSocket?.close();
|
||||||
|
_apiClientSocket = null;
|
||||||
|
_apiClientSubscription?.cancel();
|
||||||
|
_apiClientSubscription = null;
|
||||||
|
|
||||||
|
_sendspinClientSocket?.close();
|
||||||
|
_sendspinClientSocket = null;
|
||||||
|
_sendspinClientSubscription?.cancel();
|
||||||
|
_sendspinClientSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start periodic health check to monitor connection state
|
||||||
|
void _startHealthCheck() {
|
||||||
|
_healthCheckTimer?.cancel();
|
||||||
|
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||||
|
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
|
||||||
|
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
|
||||||
|
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
|
||||||
|
final timeSinceLastMsg = _lastMessageReceived != null
|
||||||
|
? DateTime.now().difference(_lastMessageReceived!).inSeconds
|
||||||
|
: -1;
|
||||||
|
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
|
||||||
|
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
|
||||||
|
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
|
||||||
|
'lastRecv:${timeSinceLastMsg}s ago');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the bridge and disconnect from the remote server.
|
||||||
|
Future<void> stop() async {
|
||||||
|
_logger.log('RemoteBridge: Stopping bridge');
|
||||||
|
_setState(RemoteBridgeState.disconnected);
|
||||||
|
|
||||||
|
// Cancel reconnection
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
_isReconnecting = false;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
_healthCheckTimer?.cancel();
|
||||||
|
_healthCheckTimer = null;
|
||||||
|
|
||||||
|
// Cancel API subscriptions
|
||||||
|
_apiClientSubscription?.cancel();
|
||||||
|
_apiClientSubscription = null;
|
||||||
|
_apiWebrtcSubscription?.cancel();
|
||||||
|
_apiWebrtcSubscription = null;
|
||||||
|
|
||||||
|
// Cancel Sendspin subscriptions
|
||||||
|
_sendspinClientSubscription?.cancel();
|
||||||
|
_sendspinClientSubscription = null;
|
||||||
|
_sendspinTextSubscription?.cancel();
|
||||||
|
_sendspinTextSubscription = null;
|
||||||
|
_sendspinBinarySubscription?.cancel();
|
||||||
|
_sendspinBinarySubscription = null;
|
||||||
|
|
||||||
|
// Close client sockets
|
||||||
|
await _apiClientSocket?.close();
|
||||||
|
_apiClientSocket = null;
|
||||||
|
await _sendspinClientSocket?.close();
|
||||||
|
_sendspinClientSocket = null;
|
||||||
|
|
||||||
|
await _server?.close(force: true);
|
||||||
|
_server = null;
|
||||||
|
_port = null;
|
||||||
|
|
||||||
|
await _webrtcConnection?.disconnect();
|
||||||
|
_webrtcConnection = null;
|
||||||
|
|
||||||
|
_cachedServerHello = null;
|
||||||
|
|
||||||
|
// Clear connection parameters
|
||||||
|
_remoteId = null;
|
||||||
|
_username = null;
|
||||||
|
_password = null;
|
||||||
|
|
||||||
|
_logger.log('RemoteBridge: Bridge stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose the bridge and close all stream controllers.
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
_stateController.close();
|
||||||
|
_errorController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:ensemble/services/remote/webrtc_connection.dart';
|
||||||
|
import 'package:ensemble/services/debug_logger.dart';
|
||||||
|
|
||||||
|
/// Manages remote connections to MA servers via WebRTC
|
||||||
|
class RemoteConnectionManager {
|
||||||
|
final WebRTCConnection _connection = WebRTCConnection();
|
||||||
|
|
||||||
|
StreamSubscription? _messageSubscription;
|
||||||
|
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
|
||||||
|
bool _isAuthenticated = false;
|
||||||
|
String? _serverVersion;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
Function(bool connected)? onConnectionChanged;
|
||||||
|
Function(String error)? onError;
|
||||||
|
|
||||||
|
bool get isConnected => _connection.isConnected && _isAuthenticated;
|
||||||
|
Stream<Map<String, dynamic>> get events => _eventController.stream;
|
||||||
|
String? get serverVersion => _serverVersion;
|
||||||
|
|
||||||
|
RemoteConnectionManager() {
|
||||||
|
_connection.onStateChanged = _onConnectionStateChanged;
|
||||||
|
_connection.onError = (error) => onError?.call(error);
|
||||||
|
|
||||||
|
// Forward events from the data channel
|
||||||
|
_messageSubscription = _connection.messages.listen((message) {
|
||||||
|
// Check if this is an event (has 'event' key)
|
||||||
|
if (message.containsKey('event')) {
|
||||||
|
_eventController.add(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a remote MA server using Remote Access ID
|
||||||
|
Future<bool> connect(String remoteId, String username, String password) async {
|
||||||
|
DebugLogger().log('RemoteConnection: Connecting to remote ID: $remoteId');
|
||||||
|
|
||||||
|
// Connect WebRTC
|
||||||
|
final connected = await _connection.connect(remoteId);
|
||||||
|
if (!connected) {
|
||||||
|
DebugLogger().log('RemoteConnection: WebRTC connection failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLogger().log('RemoteConnection: WebRTC connected, authenticating...');
|
||||||
|
|
||||||
|
// Authenticate with MA
|
||||||
|
try {
|
||||||
|
final authenticated = await _authenticate(username, password);
|
||||||
|
if (!authenticated) {
|
||||||
|
DebugLogger().log('RemoteConnection: Authentication failed');
|
||||||
|
await _connection.disconnect();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAuthenticated = true;
|
||||||
|
DebugLogger().log('RemoteConnection: Fully connected and authenticated');
|
||||||
|
onConnectionChanged?.call(true);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('RemoteConnection: Auth error: $e');
|
||||||
|
await _connection.disconnect();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate with the MA server
|
||||||
|
Future<bool> _authenticate(String username, String password) async {
|
||||||
|
try {
|
||||||
|
// Step 1: Login to get access token
|
||||||
|
DebugLogger().log('RemoteConnection: Sending auth/login...');
|
||||||
|
final loginResult = await _connection.sendRequest('auth/login', {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'device_name': 'Ensemble Mobile App',
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLogger().log('RemoteConnection: Login result: $loginResult');
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
|
if (loginResult.containsKey('error_code')) {
|
||||||
|
DebugLogger().log('RemoteConnection: Login error: ${loginResult['details']}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? accessToken;
|
||||||
|
if (loginResult['result'] != null) {
|
||||||
|
final result = loginResult['result'];
|
||||||
|
|
||||||
|
if (result is Map<String, dynamic>) {
|
||||||
|
if (result['success'] == true) {
|
||||||
|
accessToken = result['access_token'] as String?;
|
||||||
|
final user = result['user'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
DebugLogger().log('RemoteConnection: Login successful!');
|
||||||
|
DebugLogger().log('RemoteConnection: User: ${user?['username']} (${user?['role']})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken == null) {
|
||||||
|
DebugLogger().log('RemoteConnection: No access token received');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Authenticate the session with the token
|
||||||
|
DebugLogger().log('RemoteConnection: Authenticating session with token...');
|
||||||
|
final authResult = await _connection.sendRequest('auth', {
|
||||||
|
'token': accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLogger().log('RemoteConnection: Auth result: $authResult');
|
||||||
|
|
||||||
|
if (authResult.containsKey('error_code')) {
|
||||||
|
DebugLogger().log('RemoteConnection: Session auth failed: ${authResult['details']}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the token for later use
|
||||||
|
_accessToken = accessToken;
|
||||||
|
DebugLogger().log('RemoteConnection: Session authenticated successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('RemoteConnection: Login error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _accessToken;
|
||||||
|
|
||||||
|
/// Send a command to the MA server
|
||||||
|
Future<Map<String, dynamic>> sendCommand(
|
||||||
|
String command, [
|
||||||
|
Map<String, dynamic>? params,
|
||||||
|
]) async {
|
||||||
|
if (!_connection.isConnected) {
|
||||||
|
throw Exception('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _connection.sendRequest(command, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to events from the server
|
||||||
|
Future<void> subscribe(List<String> eventTypes) async {
|
||||||
|
await sendCommand('subscribe_events', {
|
||||||
|
'event_types': eventTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect from the remote server
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_isAuthenticated = false;
|
||||||
|
await _connection.disconnect();
|
||||||
|
onConnectionChanged?.call(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConnectionStateChanged(WebRTCConnectionState state) {
|
||||||
|
DebugLogger().log('RemoteConnection: State changed to $state');
|
||||||
|
|
||||||
|
if (state == WebRTCConnectionState.disconnected ||
|
||||||
|
state == WebRTCConnectionState.failed) {
|
||||||
|
_isAuthenticated = false;
|
||||||
|
onConnectionChanged?.call(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_messageSubscription?.cancel();
|
||||||
|
_eventController.close();
|
||||||
|
_connection.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:ensemble/services/debug_logger.dart';
|
||||||
|
|
||||||
|
/// Message types for the MA signaling protocol
|
||||||
|
class SignalingMessageType {
|
||||||
|
static const String ping = 'ping';
|
||||||
|
static const String pong = 'pong';
|
||||||
|
static const String connectRequest = 'connect-request';
|
||||||
|
static const String connected = 'connected';
|
||||||
|
static const String offer = 'offer';
|
||||||
|
static const String answer = 'answer';
|
||||||
|
static const String iceCandidate = 'ice-candidate';
|
||||||
|
static const String peerDisconnected = 'peer-disconnected';
|
||||||
|
static const String error = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICE server configuration from signaling
|
||||||
|
class IceServer {
|
||||||
|
final List<String> urls;
|
||||||
|
final String? username;
|
||||||
|
final String? credential;
|
||||||
|
|
||||||
|
IceServer({
|
||||||
|
required this.urls,
|
||||||
|
this.username,
|
||||||
|
this.credential,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IceServer.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Handle urls as either String or List<String>
|
||||||
|
final urlsRaw = json['urls'];
|
||||||
|
final List<String> urls;
|
||||||
|
if (urlsRaw is String) {
|
||||||
|
urls = [urlsRaw];
|
||||||
|
} else if (urlsRaw is List) {
|
||||||
|
urls = urlsRaw.cast<String>();
|
||||||
|
} else {
|
||||||
|
urls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return IceServer(
|
||||||
|
urls: urls,
|
||||||
|
username: json['username'] as String?,
|
||||||
|
credential: json['credential'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toWebRtcConfig() {
|
||||||
|
final config = <String, dynamic>{'urls': urls};
|
||||||
|
if (username != null) config['username'] = username;
|
||||||
|
if (credential != null) config['credential'] = credential;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client for connecting to the MA signaling server
|
||||||
|
class SignalingClient {
|
||||||
|
static const String _signalingUrl = 'wss://signaling.music-assistant.io/ws';
|
||||||
|
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _subscription;
|
||||||
|
Timer? _pingTimer;
|
||||||
|
|
||||||
|
String? _sessionId;
|
||||||
|
String? _remoteId;
|
||||||
|
List<IceServer> _iceServers = [];
|
||||||
|
bool _isConnected = false;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
Function(String sessionId, List<IceServer> iceServers)? onConnected;
|
||||||
|
Function(Map<String, dynamic> answer)? onAnswer;
|
||||||
|
Function(Map<String, dynamic> candidate)? onIceCandidate;
|
||||||
|
Function(String error)? onError;
|
||||||
|
Function()? onPeerDisconnected;
|
||||||
|
Function()? onDisconnected;
|
||||||
|
|
||||||
|
String? get sessionId => _sessionId;
|
||||||
|
List<IceServer> get iceServers => _iceServers;
|
||||||
|
bool get isConnected => _isConnected;
|
||||||
|
|
||||||
|
/// Connect to the signaling server and request connection to a remote MA server
|
||||||
|
Future<bool> connect(String remoteId) async {
|
||||||
|
DebugLogger().log('Signaling: Connecting to $_signalingUrl');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Normalize remote ID (remove dashes, uppercase)
|
||||||
|
final normalizedId = remoteId.replaceAll('-', '').toUpperCase();
|
||||||
|
_remoteId = normalizedId;
|
||||||
|
DebugLogger().log('Signaling: Remote ID: $normalizedId');
|
||||||
|
|
||||||
|
_channel = WebSocketChannel.connect(Uri.parse(_signalingUrl));
|
||||||
|
await _channel!.ready;
|
||||||
|
|
||||||
|
_subscription = _channel!.stream.listen(
|
||||||
|
_handleMessage,
|
||||||
|
onError: (error) {
|
||||||
|
DebugLogger().log('Signaling: WebSocket error: $error');
|
||||||
|
onError?.call(error.toString());
|
||||||
|
_cleanup();
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
DebugLogger().log('Signaling: WebSocket closed');
|
||||||
|
onDisconnected?.call();
|
||||||
|
_cleanup();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start ping timer to keep connection alive
|
||||||
|
_startPingTimer();
|
||||||
|
|
||||||
|
// Send connect request
|
||||||
|
_send({
|
||||||
|
'type': SignalingMessageType.connectRequest,
|
||||||
|
'remoteId': normalizedId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('Signaling: Failed to connect: $e');
|
||||||
|
onError?.call(e.toString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send WebRTC offer to the remote server
|
||||||
|
void sendOffer(Map<String, dynamic> offer) {
|
||||||
|
if (_sessionId == null || _remoteId == null) {
|
||||||
|
DebugLogger().log('Signaling: Cannot send offer - no session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_send({
|
||||||
|
'type': SignalingMessageType.offer,
|
||||||
|
'remoteId': _remoteId,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'data': offer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send ICE candidate to the remote server
|
||||||
|
void sendIceCandidate(Map<String, dynamic> candidate) {
|
||||||
|
if (_sessionId == null || _remoteId == null) {
|
||||||
|
DebugLogger().log('Signaling: Cannot send ICE candidate - no session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_send({
|
||||||
|
'type': SignalingMessageType.iceCandidate,
|
||||||
|
'remoteId': _remoteId,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'data': candidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect from the signaling server
|
||||||
|
void disconnect() {
|
||||||
|
DebugLogger().log('Signaling: Disconnecting');
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMessage(dynamic data) {
|
||||||
|
try {
|
||||||
|
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||||
|
final type = message['type'] as String?;
|
||||||
|
|
||||||
|
DebugLogger().log('Signaling: Received message type: $type');
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case SignalingMessageType.ping:
|
||||||
|
// Server is pinging us - respond with pong to keep connection alive
|
||||||
|
_send({'type': SignalingMessageType.pong});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.pong:
|
||||||
|
// Ignore pong responses to our pings
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.connected:
|
||||||
|
_handleConnected(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.answer:
|
||||||
|
_handleAnswer(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.iceCandidate:
|
||||||
|
_handleIceCandidate(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.peerDisconnected:
|
||||||
|
DebugLogger().log('Signaling: Peer disconnected');
|
||||||
|
onPeerDisconnected?.call();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingMessageType.error:
|
||||||
|
final error = message['error'] as String? ?? 'Unknown error';
|
||||||
|
DebugLogger().log('Signaling: Error from server: $error');
|
||||||
|
onError?.call(error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
DebugLogger().log('Signaling: Unknown message type: $type');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('Signaling: Failed to handle message: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnected(Map<String, dynamic> message) {
|
||||||
|
_sessionId = message['sessionId'] as String?;
|
||||||
|
_isConnected = true;
|
||||||
|
|
||||||
|
// Parse ICE servers
|
||||||
|
final iceServersJson = message['iceServers'] as List<dynamic>?;
|
||||||
|
if (iceServersJson != null) {
|
||||||
|
_iceServers = iceServersJson
|
||||||
|
.map((s) => IceServer.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLogger().log('Signaling: Connected with session $_sessionId');
|
||||||
|
DebugLogger().log('Signaling: Received ${_iceServers.length} ICE servers');
|
||||||
|
|
||||||
|
onConnected?.call(_sessionId!, _iceServers);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAnswer(Map<String, dynamic> message) {
|
||||||
|
final answer = message['data'] as Map<String, dynamic>?;
|
||||||
|
if (answer != null) {
|
||||||
|
DebugLogger().log('Signaling: Received answer');
|
||||||
|
onAnswer?.call(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleIceCandidate(Map<String, dynamic> message) {
|
||||||
|
final candidate = message['data'] as Map<String, dynamic>?;
|
||||||
|
if (candidate != null) {
|
||||||
|
DebugLogger().log('Signaling: Received ICE candidate');
|
||||||
|
onIceCandidate?.call(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send(Map<String, dynamic> message) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
|
||||||
|
final json = jsonEncode(message);
|
||||||
|
DebugLogger().log('Signaling: Sending ${message['type']}');
|
||||||
|
_channel!.sink.add(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPingTimer() {
|
||||||
|
_pingTimer?.cancel();
|
||||||
|
// Send ping every 15 seconds to keep connection alive
|
||||||
|
_pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||||
|
_send({'type': SignalingMessageType.ping});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanup() {
|
||||||
|
_pingTimer?.cancel();
|
||||||
|
_pingTimer = null;
|
||||||
|
_subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
_channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
_sessionId = null;
|
||||||
|
_remoteId = null;
|
||||||
|
_isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:ensemble/services/remote/signaling_client.dart';
|
||||||
|
import 'package:ensemble/services/debug_logger.dart';
|
||||||
|
|
||||||
|
/// WebRTC connection state
|
||||||
|
enum WebRTCConnectionState {
|
||||||
|
disconnected,
|
||||||
|
connecting,
|
||||||
|
connected,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages a WebRTC peer connection for remote MA access
|
||||||
|
class WebRTCConnection {
|
||||||
|
RTCPeerConnection? _peerConnection;
|
||||||
|
RTCDataChannel? _dataChannel;
|
||||||
|
RTCDataChannel? _sendspinDataChannel;
|
||||||
|
final SignalingClient _signalingClient = SignalingClient();
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
|
WebRTCConnectionState _state = WebRTCConnectionState.disconnected;
|
||||||
|
Completer<bool>? _connectionCompleter;
|
||||||
|
bool _serverHelloReceived = false;
|
||||||
|
Completer<void>? _serverHelloCompleter;
|
||||||
|
|
||||||
|
// Message handling for MA API
|
||||||
|
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _rawMessageController = StreamController<String>.broadcast();
|
||||||
|
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||||
|
|
||||||
|
// Sendspin message handling (text and binary)
|
||||||
|
final _sendspinTextController = StreamController<String>.broadcast();
|
||||||
|
final _sendspinBinaryController = StreamController<Uint8List>.broadcast();
|
||||||
|
|
||||||
|
// Server info
|
||||||
|
Map<String, dynamic>? serverInfo;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
Function(WebRTCConnectionState state)? onStateChanged;
|
||||||
|
Function(String error)? onError;
|
||||||
|
|
||||||
|
WebRTCConnectionState get state => _state;
|
||||||
|
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||||
|
Stream<String> get rawMessages => _rawMessageController.stream;
|
||||||
|
bool get isConnected => _state == WebRTCConnectionState.connected;
|
||||||
|
|
||||||
|
// Sendspin channel streams
|
||||||
|
Stream<String> get sendspinTextMessages => _sendspinTextController.stream;
|
||||||
|
Stream<Uint8List> get sendspinBinaryMessages => _sendspinBinaryController.stream;
|
||||||
|
bool get isSendspinConnected =>
|
||||||
|
_sendspinDataChannel?.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||||
|
|
||||||
|
/// Connect to a remote MA server using the Remote Access ID
|
||||||
|
Future<bool> connect(String remoteId) async {
|
||||||
|
if (_state == WebRTCConnectionState.connecting) {
|
||||||
|
DebugLogger().log('WebRTC: Already connecting');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setState(WebRTCConnectionState.connecting);
|
||||||
|
_connectionCompleter = Completer<bool>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up signaling callbacks
|
||||||
|
_signalingClient.onConnected = _onSignalingConnected;
|
||||||
|
_signalingClient.onAnswer = _onAnswer;
|
||||||
|
_signalingClient.onIceCandidate = _onRemoteIceCandidate;
|
||||||
|
_signalingClient.onError = _onSignalingError;
|
||||||
|
_signalingClient.onPeerDisconnected = _onPeerDisconnected;
|
||||||
|
_signalingClient.onDisconnected = _onSignalingDisconnected;
|
||||||
|
|
||||||
|
// Connect to signaling server
|
||||||
|
final connected = await _signalingClient.connect(remoteId);
|
||||||
|
if (!connected) {
|
||||||
|
_setState(WebRTCConnectionState.failed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for connection to complete (with timeout)
|
||||||
|
final result = await _connectionCompleter!.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () {
|
||||||
|
DebugLogger().log('WebRTC: Connection timeout');
|
||||||
|
_onSignalingError('Connection timeout');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('WebRTC: Connection error: $e');
|
||||||
|
_setState(WebRTCConnectionState.failed);
|
||||||
|
onError?.call(e.toString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a JSON-RPC request and wait for response
|
||||||
|
Future<Map<String, dynamic>> sendRequest(
|
||||||
|
String command, [
|
||||||
|
Map<String, dynamic>? params,
|
||||||
|
]) async {
|
||||||
|
if (_dataChannel == null || _dataChannel!.state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||||
|
throw Exception('Data channel not open');
|
||||||
|
}
|
||||||
|
|
||||||
|
final messageId = _uuid.v4();
|
||||||
|
final request = {
|
||||||
|
'message_id': messageId,
|
||||||
|
'command': command,
|
||||||
|
if (params != null) 'args': params,
|
||||||
|
};
|
||||||
|
|
||||||
|
final completer = Completer<Map<String, dynamic>>();
|
||||||
|
_pendingRequests[messageId] = completer;
|
||||||
|
|
||||||
|
final jsonStr = jsonEncode(request);
|
||||||
|
DebugLogger().log('WebRTC: Sending request: $jsonStr');
|
||||||
|
_dataChannel!.send(RTCDataChannelMessage(jsonStr));
|
||||||
|
|
||||||
|
// Timeout after 30 seconds
|
||||||
|
return completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () {
|
||||||
|
_pendingRequests.remove(messageId);
|
||||||
|
throw TimeoutException('Request timed out: $command');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a raw message without waiting for response (for bridge passthrough)
|
||||||
|
void sendRaw(String message) {
|
||||||
|
if (_dataChannel == null) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send raw - data channel is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final state = _dataChannel!.state;
|
||||||
|
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send raw - data channel state: $state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLogger().log('WebRTC: Sending raw: ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||||
|
_dataChannel!.send(RTCDataChannelMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the data channel is healthy
|
||||||
|
bool get isDataChannelHealthy {
|
||||||
|
if (_dataChannel == null) return false;
|
||||||
|
return _dataChannel!.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get data channel state for debugging
|
||||||
|
String get dataChannelStateDebug {
|
||||||
|
if (_dataChannel == null) return 'null';
|
||||||
|
return _dataChannel!.state.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect from the remote server
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
DebugLogger().log('WebRTC: Disconnecting');
|
||||||
|
|
||||||
|
_signalingClient.disconnect();
|
||||||
|
await _dataChannel?.close();
|
||||||
|
await _sendspinDataChannel?.close();
|
||||||
|
await _peerConnection?.close();
|
||||||
|
|
||||||
|
_dataChannel = null;
|
||||||
|
_sendspinDataChannel = null;
|
||||||
|
_peerConnection = null;
|
||||||
|
|
||||||
|
// Complete all pending requests with an error before clearing
|
||||||
|
_completePendingRequestsWithError('WebRTC connection disconnected');
|
||||||
|
|
||||||
|
_serverHelloReceived = false;
|
||||||
|
_serverHelloCompleter = null;
|
||||||
|
serverInfo = null;
|
||||||
|
|
||||||
|
_setState(WebRTCConnectionState.disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete all pending requests with an error message
|
||||||
|
void _completePendingRequestsWithError(String reason) {
|
||||||
|
for (final entry in _pendingRequests.entries) {
|
||||||
|
if (!entry.value.isCompleted) {
|
||||||
|
entry.value.completeError(Exception(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(WebRTCConnectionState newState) {
|
||||||
|
if (_state != newState) {
|
||||||
|
DebugLogger().log('WebRTC: State changed: $_state -> $newState');
|
||||||
|
_state = newState;
|
||||||
|
onStateChanged?.call(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSignalingConnected(String sessionId, List<IceServer> iceServers) async {
|
||||||
|
DebugLogger().log('WebRTC: Signaling connected, creating peer connection');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Configure Android audio to NOT manage focus since we only use data channels
|
||||||
|
// This prevents flutter_webrtc from interfering with system audio/speakers
|
||||||
|
await AndroidNativeAudioManagement.setAndroidAudioConfiguration(
|
||||||
|
AndroidAudioConfiguration(
|
||||||
|
manageAudioFocus: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
DebugLogger().log('WebRTC: Android audio configured - manageAudioFocus: false');
|
||||||
|
|
||||||
|
// Create peer connection with ICE servers
|
||||||
|
final config = <String, dynamic>{
|
||||||
|
'iceServers': iceServers.map((s) => s.toWebRtcConfig()).toList(),
|
||||||
|
'sdpSemantics': 'unified-plan',
|
||||||
|
};
|
||||||
|
|
||||||
|
DebugLogger().log('WebRTC: ICE servers: ${config['iceServers']}');
|
||||||
|
|
||||||
|
_peerConnection = await createPeerConnection(config);
|
||||||
|
|
||||||
|
// Set up peer connection callbacks
|
||||||
|
_peerConnection!.onIceCandidate = _onLocalIceCandidate;
|
||||||
|
_peerConnection!.onIceConnectionState = _onIceConnectionState;
|
||||||
|
_peerConnection!.onConnectionState = _onConnectionState;
|
||||||
|
_peerConnection!.onDataChannel = _onDataChannel;
|
||||||
|
|
||||||
|
// Create data channel for MA API
|
||||||
|
final dataChannelInit = RTCDataChannelInit()
|
||||||
|
..ordered = true
|
||||||
|
..protocol = 'ma-api';
|
||||||
|
|
||||||
|
_dataChannel = await _peerConnection!.createDataChannel('ma-api', dataChannelInit);
|
||||||
|
_setupDataChannel(_dataChannel!);
|
||||||
|
|
||||||
|
// TEST: Sendspin channel DISABLED to test if multi-channel bug causes disconnects
|
||||||
|
// If stability improves, the flutter_webrtc multi-channel bug is confirmed
|
||||||
|
DebugLogger().log('WebRTC: ⚠️ TEST BUILD - Sendspin channel DISABLED (single channel mode)');
|
||||||
|
// final sendspinChannelInit = RTCDataChannelInit()
|
||||||
|
// ..ordered = true
|
||||||
|
// ..protocol = 'sendspin';
|
||||||
|
// _sendspinDataChannel = await _peerConnection!.createDataChannel('sendspin', sendspinChannelInit);
|
||||||
|
// _setupSendspinDataChannel(_sendspinDataChannel!);
|
||||||
|
|
||||||
|
// Create and send offer
|
||||||
|
final offer = await _peerConnection!.createOffer();
|
||||||
|
await _peerConnection!.setLocalDescription(offer);
|
||||||
|
|
||||||
|
DebugLogger().log('WebRTC: Sending offer');
|
||||||
|
_signalingClient.sendOffer({
|
||||||
|
'type': offer.type,
|
||||||
|
'sdp': offer.sdp,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('WebRTC: Failed to create peer connection: $e');
|
||||||
|
_onSignalingError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAnswer(Map<String, dynamic> answer) async {
|
||||||
|
DebugLogger().log('WebRTC: Received answer');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final description = RTCSessionDescription(
|
||||||
|
answer['sdp'] as String?,
|
||||||
|
answer['type'] as String?,
|
||||||
|
);
|
||||||
|
await _peerConnection?.setRemoteDescription(description);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('WebRTC: Failed to set remote description: $e');
|
||||||
|
_onSignalingError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLocalIceCandidate(RTCIceCandidate candidate) {
|
||||||
|
DebugLogger().log('WebRTC: Local ICE candidate: ${candidate.candidate?.substring(0, 50)}...');
|
||||||
|
|
||||||
|
_signalingClient.sendIceCandidate({
|
||||||
|
'candidate': candidate.candidate,
|
||||||
|
'sdpMid': candidate.sdpMid,
|
||||||
|
'sdpMLineIndex': candidate.sdpMLineIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRemoteIceCandidate(Map<String, dynamic> candidateData) async {
|
||||||
|
DebugLogger().log('WebRTC: Remote ICE candidate received');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final candidate = RTCIceCandidate(
|
||||||
|
candidateData['candidate'] as String?,
|
||||||
|
candidateData['sdpMid'] as String?,
|
||||||
|
candidateData['sdpMLineIndex'] as int?,
|
||||||
|
);
|
||||||
|
await _peerConnection?.addCandidate(candidate);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('WebRTC: Failed to add ICE candidate: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIceConnectionState(RTCIceConnectionState state) {
|
||||||
|
DebugLogger().log('WebRTC: ICE connection state: $state');
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case RTCIceConnectionState.RTCIceConnectionStateFailed:
|
||||||
|
_onSignalingError('ICE connection failed');
|
||||||
|
break;
|
||||||
|
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
|
||||||
|
DebugLogger().log('WebRTC: ICE disconnected, may recover');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConnectionState(RTCPeerConnectionState state) {
|
||||||
|
DebugLogger().log('WebRTC: Peer connection state: $state');
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||||
|
// Connection established, but wait for data channel
|
||||||
|
break;
|
||||||
|
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||||
|
_onSignalingError('Peer connection failed');
|
||||||
|
break;
|
||||||
|
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
|
||||||
|
_setState(WebRTCConnectionState.disconnected);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDataChannel(RTCDataChannel channel) {
|
||||||
|
DebugLogger().log('WebRTC: Remote data channel received: ${channel.label}');
|
||||||
|
if (channel.label == 'ma-api') {
|
||||||
|
_dataChannel = channel;
|
||||||
|
_setupDataChannel(channel);
|
||||||
|
} else if (channel.label == 'sendspin') {
|
||||||
|
_sendspinDataChannel = channel;
|
||||||
|
_setupSendspinDataChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupDataChannel(RTCDataChannel channel) {
|
||||||
|
channel.onDataChannelState = (state) {
|
||||||
|
DebugLogger().log('WebRTC: Data channel state: $state');
|
||||||
|
|
||||||
|
if (state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||||
|
DebugLogger().log('WebRTC: Data channel open - waiting for server hello...');
|
||||||
|
// Don't complete yet - wait for server hello
|
||||||
|
_serverHelloCompleter = Completer<void>();
|
||||||
|
_serverHelloCompleter!.future.then((_) {
|
||||||
|
DebugLogger().log('WebRTC: Server hello received - connection complete!');
|
||||||
|
_setState(WebRTCConnectionState.connected);
|
||||||
|
_safeCompleteConnection(true);
|
||||||
|
});
|
||||||
|
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
|
||||||
|
DebugLogger().log('WebRTC: Data channel closed, completing pending requests with error');
|
||||||
|
_completePendingRequestsWithError('Data channel closed');
|
||||||
|
_setState(WebRTCConnectionState.disconnected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onMessage = _onDataChannelMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDataChannelMessage(RTCDataChannelMessage message) {
|
||||||
|
try {
|
||||||
|
DebugLogger().log('WebRTC: Raw message: ${message.text.substring(0, message.text.length > 200 ? 200 : message.text.length)}...');
|
||||||
|
final data = jsonDecode(message.text) as Map<String, dynamic>;
|
||||||
|
final messageId = data['message_id']?.toString();
|
||||||
|
|
||||||
|
DebugLogger().log('WebRTC: Parsed message keys: ${data.keys.toList()}, message_id: $messageId');
|
||||||
|
|
||||||
|
// Check for server hello message (has server_id but no message_id)
|
||||||
|
if (data.containsKey('server_id') && messageId == null && !_serverHelloReceived) {
|
||||||
|
DebugLogger().log('WebRTC: Server hello received - server_version: ${data['server_version']}');
|
||||||
|
serverInfo = data;
|
||||||
|
_serverHelloReceived = true;
|
||||||
|
_serverHelloCompleter?.complete();
|
||||||
|
// Don't forward server hello to rawMessages - bridge caches it separately
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a response to a pending request (for internal use like auth)
|
||||||
|
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||||
|
DebugLogger().log('WebRTC: Matched pending request $messageId');
|
||||||
|
_pendingRequests.remove(messageId)?.complete(data);
|
||||||
|
// Don't forward to rawMessages - this was an internal request
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward all other messages to rawMessages stream (for bridge passthrough)
|
||||||
|
_rawMessageController.add(message.text);
|
||||||
|
|
||||||
|
// Also broadcast parsed event/notification for internal listeners
|
||||||
|
DebugLogger().log('WebRTC: No match for message_id=$messageId, pending: ${_pendingRequests.keys.toList()}');
|
||||||
|
_messageController.add(data);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger().log('WebRTC: Failed to parse message: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup Sendspin data channel for audio streaming
|
||||||
|
void _setupSendspinDataChannel(RTCDataChannel channel) {
|
||||||
|
channel.onDataChannelState = (state) {
|
||||||
|
DebugLogger().log('WebRTC: Sendspin data channel state: $state');
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onMessage = _onSendspinMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming Sendspin messages (both text and binary)
|
||||||
|
void _onSendspinMessage(RTCDataChannelMessage message) {
|
||||||
|
if (message.isBinary) {
|
||||||
|
// Binary audio data
|
||||||
|
final bytes = message.binary;
|
||||||
|
if (bytes != null && !_sendspinBinaryController.isClosed) {
|
||||||
|
_sendspinBinaryController.add(bytes);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Text control message (JSON)
|
||||||
|
final text = message.text;
|
||||||
|
DebugLogger().log('WebRTC: Sendspin text message: ${text.substring(0, text.length > 100 ? 100 : text.length)}...');
|
||||||
|
if (!_sendspinTextController.isClosed) {
|
||||||
|
_sendspinTextController.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a text message to Sendspin channel (JSON control messages)
|
||||||
|
void sendSendspinText(String message) {
|
||||||
|
if (_sendspinDataChannel == null) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send sendspin text - channel is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final state = _sendspinDataChannel!.state;
|
||||||
|
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send sendspin text - channel state: $state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendspinDataChannel!.send(RTCDataChannelMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send binary data to Sendspin channel (audio data from local player)
|
||||||
|
void sendSendspinBinary(Uint8List data) {
|
||||||
|
if (_sendspinDataChannel == null) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final state = _sendspinDataChannel!.state;
|
||||||
|
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||||
|
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel state: $state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendspinDataChannel!.send(RTCDataChannelMessage.fromBinary(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSignalingError(String error) {
|
||||||
|
DebugLogger().log('WebRTC: Signaling error: $error');
|
||||||
|
_setState(WebRTCConnectionState.failed);
|
||||||
|
onError?.call(error);
|
||||||
|
_safeCompleteConnection(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPeerDisconnected() {
|
||||||
|
DebugLogger().log('WebRTC: Peer disconnected, completing pending requests with error');
|
||||||
|
_completePendingRequestsWithError('Remote peer disconnected');
|
||||||
|
_setState(WebRTCConnectionState.disconnected);
|
||||||
|
onError?.call('Remote server disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSignalingDisconnected() {
|
||||||
|
DebugLogger().log('WebRTC: Signaling disconnected');
|
||||||
|
// Only set failed if we were still connecting
|
||||||
|
if (_state == WebRTCConnectionState.connecting) {
|
||||||
|
_setState(WebRTCConnectionState.failed);
|
||||||
|
_safeCompleteConnection(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safely complete the connection completer (only once)
|
||||||
|
void _safeCompleteConnection(bool success) {
|
||||||
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||||
|
_connectionCompleter!.complete(success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_messageController.close();
|
||||||
|
_rawMessageController.close();
|
||||||
|
_sendspinTextController.close();
|
||||||
|
_sendspinBinaryController.close();
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ class SendspinService {
|
|||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
Timer? _heartbeatTimer;
|
Timer? _heartbeatTimer;
|
||||||
Timer? _reconnectTimer;
|
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
|
// Connection state
|
||||||
SendspinConnectionState _state = SendspinConnectionState.disconnected;
|
SendspinConnectionState _state = SendspinConnectionState.disconnected;
|
||||||
@@ -279,6 +283,7 @@ class SendspinService {
|
|||||||
|
|
||||||
_logger.log('Sendspin: Connected and registered successfully');
|
_logger.log('Sendspin: Connected and registered successfully');
|
||||||
_updateState(SendspinConnectionState.connected);
|
_updateState(SendspinConnectionState.connected);
|
||||||
|
_resetReconnectAttempts();
|
||||||
_startHeartbeat();
|
_startHeartbeat();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -559,6 +564,7 @@ class SendspinService {
|
|||||||
'state': playerState,
|
'state': playerState,
|
||||||
'volume': _volume,
|
'volume': _volume,
|
||||||
'muted': _isMuted,
|
'muted': _isMuted,
|
||||||
|
'position': _position,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -627,6 +633,7 @@ class SendspinService {
|
|||||||
'state': 'synchronized',
|
'state': 'synchronized',
|
||||||
'volume': _volume,
|
'volume': _volume,
|
||||||
'muted': _isMuted,
|
'muted': _isMuted,
|
||||||
|
'position': _position,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, allowDuringHandshake: true);
|
}, allowDuringHandshake: true);
|
||||||
@@ -638,12 +645,27 @@ class SendspinService {
|
|||||||
_heartbeatTimer = null;
|
_heartbeatTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule reconnection attempt
|
/// Schedule reconnection attempt with exponential backoff
|
||||||
void _scheduleReconnect() {
|
void _scheduleReconnect() {
|
||||||
if (_isDisposed) return;
|
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?.cancel();
|
||||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
_reconnectTimer = Timer(delay, () {
|
||||||
if (!_isDisposed && _state != SendspinConnectionState.connected) {
|
if (!_isDisposed && _state != SendspinConnectionState.connected) {
|
||||||
_logger.log('Sendspin: Attempting reconnection...');
|
_logger.log('Sendspin: Attempting reconnection...');
|
||||||
if (_connectedUrl != null) {
|
if (_connectedUrl != null) {
|
||||||
@@ -653,11 +675,19 @@ class SendspinService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset reconnection attempts counter (call on successful connection)
|
||||||
|
void _resetReconnectAttempts() {
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Update connection state
|
/// Update connection state
|
||||||
void _updateState(SendspinConnectionState newState) {
|
void _updateState(SendspinConnectionState newState) {
|
||||||
if (_state != newState) {
|
if (_state != newState) {
|
||||||
_state = newState;
|
_state = newState;
|
||||||
_stateController.add(newState);
|
// Don't add events if controller is closed (disposed)
|
||||||
|
if (!_isDisposed) {
|
||||||
|
_stateController.add(newState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class SettingsService {
|
|||||||
static const String _keyServerUrl = 'server_url';
|
static const String _keyServerUrl = 'server_url';
|
||||||
static const String _keyAuthServerUrl = 'auth_server_url';
|
static const String _keyAuthServerUrl = 'auth_server_url';
|
||||||
static const String _keyWebSocketPort = 'websocket_port';
|
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 _keyAuthToken = 'auth_token';
|
||||||
static const String _keyMaAuthToken = 'ma_auth_token'; // Music Assistant native 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
|
static const String _keyAuthCredentials = 'auth_credentials'; // NEW: Serialized auth strategy credentials
|
||||||
@@ -193,6 +194,26 @@ class SettingsService {
|
|||||||
await prefs.remove(_keyServerUrl);
|
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)
|
// Get authentication server URL (returns null if not set, meaning use server URL)
|
||||||
static Future<String?> getAuthServerUrl() async {
|
static Future<String?> getAuthServerUrl() async {
|
||||||
final prefs = await _getPrefs();
|
final prefs = await _getPrefs();
|
||||||
|
|||||||
@@ -242,24 +242,44 @@ class SyncService with ChangeNotifier {
|
|||||||
final playlistMap = <String, Playlist>{};
|
final playlistMap = <String, Playlist>{};
|
||||||
|
|
||||||
// If specific providers are requested, sync each separately for accurate source tracking
|
// 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) {
|
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) {
|
for (final providerId in providerInstanceIds) {
|
||||||
_logger.log(' 📡 Syncing provider: $providerId');
|
_logger.log(' 📡 Syncing provider: $providerId');
|
||||||
|
|
||||||
// Fetch from this specific provider
|
List<Album> albums;
|
||||||
final results = await Future.wait([
|
List<Artist> artists;
|
||||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
List<Audiobook> audiobooks;
|
||||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
|
List<Playlist> playlists;
|
||||||
api.getAudiobooks(limit: 1000, providerInstanceIds: [providerId]),
|
|
||||||
api.getPlaylists(limit: 1000, providerInstanceIds: [providerId]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
final albums = results[0] as List<Album>;
|
// In remote mode, do sequential requests to avoid overwhelming WebRTC
|
||||||
final artists = results[1] as List<Artist>;
|
if (api.isRemoteMode) {
|
||||||
final audiobooks = results[2] as List<Audiobook>;
|
albums = await api.getAlbums(limit: limit, providerInstanceIds: [providerId]);
|
||||||
final playlists = results[3] as List<Playlist>;
|
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');
|
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||||
|
|
||||||
@@ -295,26 +315,59 @@ class SyncService with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No provider filter - fetch all at once (faster, but no source tracking)
|
// 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([
|
// In remote mode, do sequential requests to avoid overwhelming WebRTC
|
||||||
api.getAlbums(limit: 1000),
|
if (api.isRemoteMode) {
|
||||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
_logger.log('🌐 Remote mode: using sequential requests with delays');
|
||||||
api.getAudiobooks(limit: 1000),
|
|
||||||
api.getPlaylists(limit: 1000),
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (final album in results[0] as List<Album>) {
|
final albums = await api.getAlbums(limit: limit);
|
||||||
albumMap[album.itemId] = album;
|
for (final album in albums) {
|
||||||
}
|
albumMap[album.itemId] = album;
|
||||||
for (final artist in results[1] as List<Artist>) {
|
}
|
||||||
artistMap[artist.itemId] = artist;
|
_logger.log(' 📥 Got ${albums.length} albums');
|
||||||
}
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
for (final audiobook in results[2] as List<Audiobook>) {
|
|
||||||
audiobookMap[audiobook.itemId] = audiobook;
|
final artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums);
|
||||||
}
|
for (final artist in artists) {
|
||||||
for (final playlist in results[3] as List<Playlist>) {
|
artistMap[artist.itemId] = artist;
|
||||||
playlistMap[playlist.itemId] = playlist;
|
}
|
||||||
|
_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class AppTheme {
|
|||||||
foregroundColor: scheme.onBackground,
|
foregroundColor: scheme.onBackground,
|
||||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||||
),
|
),
|
||||||
cardTheme: CardTheme(
|
cardTheme: CardThemeData(
|
||||||
color: scheme.surface,
|
color: scheme.surface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
),
|
),
|
||||||
@@ -91,7 +91,7 @@ class AppTheme {
|
|||||||
foregroundColor: scheme.onBackground,
|
foregroundColor: scheme.onBackground,
|
||||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||||
),
|
),
|
||||||
cardTheme: CardTheme(
|
cardTheme: CardThemeData(
|
||||||
color: scheme.surface,
|
color: scheme.surface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
|
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
|
||||||
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
||||||
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
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
|
// Pre-computed static colors to avoid object creation during animation frames
|
||||||
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
|
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
|
||||||
@@ -2860,33 +2861,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 48, // Increase touch target height
|
height: 48, // Increase touch target height
|
||||||
// PERF Phase 1: Use cached SliderThemeData
|
child: _buildProgressSlider(
|
||||||
child: SliderTheme(
|
context,
|
||||||
data: _sliderTheme,
|
currentProgress,
|
||||||
child: Slider(
|
currentTrack.duration!.inSeconds.toDouble(),
|
||||||
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
|
primaryColor,
|
||||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
primaryColor20,
|
||||||
onChanged: (value) => _seekPositionNotifier.value = value,
|
maProvider,
|
||||||
onChangeStart: (value) => _seekPositionNotifier.value = value,
|
selectedPlayer.playerId,
|
||||||
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
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
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
|
/// Represents a single line of synced lyrics with timestamp
|
||||||
|
|||||||
@@ -225,11 +225,8 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final sliderWidth = constraints.maxWidth;
|
final sliderWidth = constraints.maxWidth;
|
||||||
// Calculate thumb position to match Flutter Slider's internal positioning
|
// Calculate thumb position for the custom slider track
|
||||||
// The Slider uses overlayRadius as padding on each side for the track
|
final thumbPosition = sliderWidth * currentVolume.clamp(0.0, 1.0);
|
||||||
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
|
|
||||||
final thumbPosition = overlayRadius +
|
|
||||||
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
|
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -311,22 +308,56 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
_pendingVolume = null;
|
_pendingVolume = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: SliderTheme(
|
// Custom slider track (no Overlay needed - Flutter 3.38 Slider uses OverlayPortal)
|
||||||
data: SliderThemeData(
|
child: SizedBox(
|
||||||
trackHeight: 2,
|
height: 48,
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
child: Center(
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
child: SizedBox(
|
||||||
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
|
height: 20,
|
||||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
child: Stack(
|
||||||
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
|
clipBehavior: Clip.none,
|
||||||
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
|
children: [
|
||||||
),
|
// Inactive track (full width, thinner)
|
||||||
child: AbsorbPointer(
|
Positioned(
|
||||||
// AbsorbPointer prevents Slider from handling gestures
|
left: 0,
|
||||||
// Our GestureDetector handles everything for live updates
|
right: 0,
|
||||||
child: Slider(
|
top: 8.5,
|
||||||
value: currentVolume.clamp(0.0, 1.0),
|
child: Container(
|
||||||
onChanged: (_) {},
|
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
@@ -5,23 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
|
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "72.0.0"
|
version: "91.0.0"
|
||||||
_macros:
|
|
||||||
dependency: transitive
|
|
||||||
description: dart
|
|
||||||
source: sdk
|
|
||||||
version: "0.3.2"
|
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
|
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.7.0"
|
version: "8.4.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -90,10 +85,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "3.0.1"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -114,26 +109,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_resolvers
|
name: build_resolvers
|
||||||
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "3.0.1"
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.13"
|
version: "2.6.1"
|
||||||
build_runner_core:
|
build_runner_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_runner_core
|
name: build_runner_core
|
||||||
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.3.2"
|
version: "9.2.1"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -178,10 +173,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.4.0"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -210,10 +205,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.2"
|
||||||
code_builder:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -226,10 +221,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.1"
|
||||||
convert:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -258,10 +253,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
|
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -282,18 +285,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621
|
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.23.1"
|
version: "2.30.1"
|
||||||
drift_dev:
|
drift_dev:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: drift_dev
|
name: drift_dev
|
||||||
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9
|
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.23.1"
|
version: "2.30.1"
|
||||||
drift_flutter:
|
drift_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -314,10 +317,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.3"
|
||||||
ffi:
|
ffi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -355,6 +358,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
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:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -466,6 +533,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -526,10 +601,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.20.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -582,26 +657,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.5"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.5"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -618,22 +693,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
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:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.16+1"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -654,10 +721,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -666,6 +733,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -718,10 +793,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.0"
|
version: "1.9.1"
|
||||||
path_parsing:
|
path_parsing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -950,15 +1025,15 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.99"
|
version: "0.0.0"
|
||||||
source_gen:
|
source_gen:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "4.2.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1027,26 +1102,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlparser
|
name: sqlparser
|
||||||
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41"
|
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.40.0"
|
version: "0.43.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.11.1"
|
version: "1.12.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stream_channel
|
name: stream_channel
|
||||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.4"
|
||||||
stream_transform:
|
stream_transform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1083,10 +1158,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.7.7"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1171,10 +1246,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1207,6 +1282,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1248,5 +1331,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.5.0 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.24.0"
|
flutter: ">=3.24.0"
|
||||||
|
|||||||
+5
-1
@@ -11,7 +11,7 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
intl: ^0.19.0
|
intl: ^0.20.0
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
material_design_icons_flutter: ^7.0.7296
|
material_design_icons_flutter: ^7.0.7296
|
||||||
@@ -26,6 +26,8 @@ dependencies:
|
|||||||
web_socket_channel: ^2.4.0
|
web_socket_channel: ^2.4.0
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
flutter_webrtc: ^0.14.1
|
||||||
|
flutter_inappwebview: ^6.0.0
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
@@ -40,6 +42,7 @@ dependencies:
|
|||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
device_info_plus: ^10.1.0
|
device_info_plus: ^10.1.0
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
|
mobile_scanner: ^6.0.2
|
||||||
|
|
||||||
# Audio - Local Playback
|
# Audio - Local Playback
|
||||||
just_audio: ^0.9.36
|
just_audio: ^0.9.36
|
||||||
@@ -64,6 +67,7 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
- assets/webrtc_engine.js
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
|
|||||||
Reference in New Issue
Block a user