Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a1674c28 | ||
|
|
91494f7d25 | ||
|
|
d7b7bbcf0d | ||
|
|
3503978185 | ||
|
|
020ed250f4 | ||
|
|
e5be64a6cd | ||
|
|
b85fadc551 | ||
|
|
3b8339f94f | ||
|
|
c00fb3dc78 | ||
|
|
ff34479169 |
@@ -36,7 +36,8 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
### Queue Management
|
||||
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe left to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe right to play next** - Move tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
@@ -50,7 +51,9 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||
- **Audiobooks** - Browse by title, series, or author with progress tracking
|
||||
- **Favorites Filter** - Toggle to show only your favorite items
|
||||
- **Providers Filter** - Toggle to show only specific provider items
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
- **Provider Icons** - Provider icons on library covers
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
@@ -86,16 +89,18 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
|
||||
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Release Notes Draft
|
||||
|
||||
## v2.9.2-beta (TBD)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Audiobooks
|
||||
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
|
||||
|
||||
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
|
||||
|
||||
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
|
||||
|
||||
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
|
||||
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
|
||||
- Sort handler now properly supports ascending and descending year sorts
|
||||
|
||||
#### Library Sorting
|
||||
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
|
||||
|
||||
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
|
||||
|
||||
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
|
||||
|
||||
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
|
||||
|
||||
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
|
||||
|
||||
#### Provider Management
|
||||
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
|
||||
|
||||
### Technical Changes
|
||||
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
|
||||
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
|
||||
- Provider filter now validates against available providers when toggling, not just on initial load
|
||||
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
|
||||
- Options menu provider toggles now use Consumer to watch Provider state directly
|
||||
@@ -30,16 +30,16 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -59,7 +59,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
ext.kotlin_version = '2.1.0'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath 'com.android.tools.build:gradle:8.7.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 909 KiB |
@@ -1292,6 +1292,16 @@
|
||||
"description": "Description for smart sort players setting"
|
||||
},
|
||||
|
||||
"disableAutoSwitch": "Disable Auto-Switch",
|
||||
"@disableAutoSwitch": {
|
||||
"description": "Setting to disable automatic player switching"
|
||||
},
|
||||
|
||||
"disableAutoSwitchDescription": "Never automatically switch to a different player",
|
||||
"@disableAutoSwitchDescription": {
|
||||
"description": "Description for disable auto-switch setting"
|
||||
},
|
||||
|
||||
"playerStateUnavailable": "Unavailable",
|
||||
"@playerStateUnavailable": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1347,6 +1357,21 @@
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "Settings section title for display options"
|
||||
},
|
||||
|
||||
"showProviderIcons": "Show Provider Icons",
|
||||
"@showProviderIcons": {
|
||||
"description": "Toggle label for showing provider icons on album art"
|
||||
},
|
||||
|
||||
"showProviderIconsDescription": "Display provider logos on album art",
|
||||
"@showProviderIconsDescription": {
|
||||
"description": "Description for show provider icons toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
|
||||
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
|
||||
/// be consistent with the languages listed in the S.supportedLocales
|
||||
/// property.
|
||||
abstract class S {
|
||||
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
S(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
@@ -84,7 +85,8 @@ abstract class S {
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -1761,6 +1763,18 @@ abstract class S {
|
||||
/// **'Sort by status (playing, on, off) instead of alphabetically'**
|
||||
String get smartSortPlayersDescription;
|
||||
|
||||
/// Setting to disable automatic player switching
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable Auto-Switch'**
|
||||
String get disableAutoSwitch;
|
||||
|
||||
/// Description for disable auto-switch setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never automatically switch to a different player'**
|
||||
String get disableAutoSwitchDescription;
|
||||
|
||||
/// Player state when unavailable
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1821,6 +1835,24 @@ abstract class S {
|
||||
/// **'Display helpful tips for discovering features'**
|
||||
String get showHintsDescription;
|
||||
|
||||
/// Settings section title for display options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display'**
|
||||
String get display;
|
||||
|
||||
/// Toggle label for showing provider icons on album art
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Provider Icons'**
|
||||
String get showProviderIcons;
|
||||
|
||||
/// Description for show provider icons toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display provider logos on album art'**
|
||||
String get showProviderIconsDescription;
|
||||
|
||||
/// Hint shown when mini player bounces
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2101,27 +2133,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_SDelegate old) => false;
|
||||
}
|
||||
|
||||
S lookupS(Locale locale) {
|
||||
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'de': return SDe();
|
||||
case 'en': return SEn();
|
||||
case 'es': return SEs();
|
||||
case 'fr': return SFr();
|
||||
case 'de':
|
||||
return SDe();
|
||||
case 'en':
|
||||
return SEn();
|
||||
case 'es':
|
||||
return SEs();
|
||||
case 'fr':
|
||||
return SFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.'
|
||||
);
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SDe extends S {
|
||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
String get portDescription =>
|
||||
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
|
||||
@override
|
||||
String get username => 'Benutzername';
|
||||
@@ -60,10 +61,12 @@ class SDe extends S {
|
||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
String get authServerUrlHint =>
|
||||
'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,49 @@ class SDe extends S {
|
||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
String get httpNotEncrypted =>
|
||||
'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Bitte gib Benutzername und Passwort ein';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get authFailed =>
|
||||
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
String get connectionFailed =>
|
||||
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Keine Authentifizierung';
|
||||
@@ -221,10 +233,12 @@ class SDe extends S {
|
||||
String get noRadioStations => 'Keine Radiosender';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||
String get addRadioStationsHint =>
|
||||
'Füge Radiosender in Music Assistant hinzu';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
String get searchFailed =>
|
||||
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
|
||||
@override
|
||||
String get queue => 'Warteschlange';
|
||||
@@ -471,37 +485,43 @@ class SDe extends S {
|
||||
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Keine Hörbücher';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
String get addAudiobooksHint =>
|
||||
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
String get tapHeartArtist =>
|
||||
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAlbum =>
|
||||
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
String get longPressTrackHint =>
|
||||
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Serien laden';
|
||||
@@ -513,7 +533,8 @@ class SDe extends S {
|
||||
String get notConnectedTitle => 'Nicht verbunden';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
String get connectHint =>
|
||||
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Server konfigurieren';
|
||||
@@ -549,7 +570,8 @@ class SDe extends S {
|
||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
String get noDebugLogsYet =>
|
||||
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Debug anzeigen';
|
||||
@@ -649,10 +671,12 @@ class SDe extends S {
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
String get showRandomAudiobooks =>
|
||||
'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
String get showRandomSeries =>
|
||||
'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||
@@ -667,16 +691,19 @@ class SDe extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Farben aus Album- und Künstlercover extrahieren';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
@@ -785,13 +812,15 @@ class SDe extends S {
|
||||
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||
@@ -800,7 +829,8 @@ class SDe extends S {
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
@@ -820,16 +850,19 @@ class SDe extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
String get noSeriesAvailable =>
|
||||
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
String get pullToLoadSeries =>
|
||||
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
|
||||
@override
|
||||
String get search => 'Suche';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
String get metadataApisDescription =>
|
||||
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||
@@ -901,13 +934,22 @@ class SDe extends S {
|
||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||
@@ -939,7 +981,18 @@ class SDe extends S {
|
||||
String get showHints => 'Hinweise anzeigen';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
String get showHintsDescription =>
|
||||
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||
@@ -957,13 +1010,15 @@ class SDe extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -979,19 +1034,22 @@ class SDe extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEn extends S {
|
||||
String get portHint => 'e.g., 8095 or leave blank';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
String get portDescription =>
|
||||
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
@@ -60,10 +61,12 @@ class SEn extends S {
|
||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||
String get authServerUrlHint =>
|
||||
'e.g., auth.example.com (if different from server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leave empty if authentication is on the same server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,7 +83,8 @@ class SEn extends S {
|
||||
String get unencryptedConnection => 'Unencrypted Connection';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||
@@ -89,31 +93,37 @@ class SEn extends S {
|
||||
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Please enter your Music Assistant server address';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Please enter your name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Please enter a valid port number (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentication failed. Please check your credentials.';
|
||||
String get authFailed =>
|
||||
'Authentication failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant login failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
|
||||
String get connectionFailed =>
|
||||
'Could not connect to server. Please check the address and try again.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detecting authentication...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Cannot determine authentication requirements. Please check server URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'No Authentication';
|
||||
@@ -471,37 +481,43 @@ class SEn extends S {
|
||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tap the heart on an audiobook to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No audiobooks';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
|
||||
String get addAudiobooksHint =>
|
||||
'Add audiobooks to your library to see them here';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No favorite artists';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
|
||||
String get tapHeartArtist =>
|
||||
'Tap the heart on an artist to add them to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No favorite albums';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
|
||||
String get tapHeartAlbum =>
|
||||
'Tap the heart on an album to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No favorite playlists';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tap the heart on a playlist to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No favorite tracks';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
|
||||
String get longPressTrackHint =>
|
||||
'Long-press a track and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Load Series';
|
||||
@@ -513,7 +529,8 @@ class SEn extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||
String get connectHint =>
|
||||
'Connect to your Music Assistant server to start listening';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configure Server';
|
||||
@@ -667,16 +684,19 @@ class SEn extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extract colors from album and artist artwork';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
@@ -791,7 +811,8 @@ class SEn extends S {
|
||||
String get audiobookLibraries => 'Audiobook Libraries';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choose which Audiobookshelf libraries to include';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unknown Library';
|
||||
@@ -800,7 +821,8 @@ class SEn extends S {
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
@@ -820,16 +842,19 @@ class SEn extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||
String get noSeriesAvailable =>
|
||||
'No series available from your audiobook library.\nPull to refresh.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Pull down to load series\nfrom Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
String get metadataApisDescription =>
|
||||
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API Key';
|
||||
@@ -901,13 +926,22 @@ class SEn extends S {
|
||||
String get preferLocalPlayer => 'Prefer Local Player';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Always select this device first when available';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Smart Sort';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Sort by status (playing, on, off) instead of alphabetically';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Unavailable';
|
||||
@@ -939,7 +973,18 @@ class SEn extends S {
|
||||
String get showHints => 'Show Hints';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||
String get showHintsDescription =>
|
||||
'Display helpful tips for discovering features';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Pull to select players';
|
||||
@@ -957,13 +1002,15 @@ class SEn extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -979,19 +1026,22 @@ class SEn extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEs extends S {
|
||||
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
String get portDescription =>
|
||||
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
|
||||
@override
|
||||
String get username => 'Usuario';
|
||||
@@ -57,13 +58,16 @@ class SEs extends S {
|
||||
String get disconnect => 'Desconectar';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||
String get authServerUrlOptional =>
|
||||
'URL del servidor de autenticación (Opcional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||
String get authServerUrlHint =>
|
||||
'ej., auth.example.com (si es diferente del servidor)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
String get authServerUrlDescription =>
|
||||
'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +84,49 @@ class SEs extends S {
|
||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS falló, usando HTTP como alternativa';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Por favor, introduce usuario y contraseña';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
String get authFailed =>
|
||||
'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
String get maLoginFailed =>
|
||||
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
String get connectionFailed =>
|
||||
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detectando autenticación...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
String get cannotDetermineAuth =>
|
||||
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Sin autenticación';
|
||||
@@ -221,10 +234,12 @@ class SEs extends S {
|
||||
String get noRadioStations => 'No hay emisoras de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Añade emisoras de radio en Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
String get searchFailed =>
|
||||
'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
|
||||
@override
|
||||
String get queue => 'Cola';
|
||||
@@ -264,7 +279,8 @@ class SEs extends S {
|
||||
String get system => 'Sistema';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
String get pullToRefresh =>
|
||||
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Ver registros de depuración';
|
||||
@@ -301,7 +317,8 @@ class SEs extends S {
|
||||
String get logsCleared => 'Registros borrados';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||
String get playerListCopied =>
|
||||
'Lista de reproductores copiada al portapapeles';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Aún no hay registros';
|
||||
@@ -471,37 +488,43 @@ class SEs extends S {
|
||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
String get tapHeartAudiobook =>
|
||||
'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No hay audiolibros';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
String get addAudiobooksHint =>
|
||||
'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
String get tapHeartArtist =>
|
||||
'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
String get tapHeartAlbum =>
|
||||
'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
|
||||
String get tapHeartPlaylist =>
|
||||
'Toca el corazón en una lista para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
String get longPressTrackHint =>
|
||||
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Cargar series';
|
||||
@@ -513,7 +536,8 @@ class SEs extends S {
|
||||
String get notConnectedTitle => 'No conectado';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
String get connectHint =>
|
||||
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurar servidor';
|
||||
@@ -549,7 +573,8 @@ class SEs extends S {
|
||||
String get noLogsToCopy => 'No hay registros para copiar';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
String get noDebugLogsYet =>
|
||||
'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Mostrar depuración';
|
||||
@@ -564,7 +589,8 @@ class SEs extends S {
|
||||
String get noChapters => 'Sin capítulos';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||
String get noChapterInfo =>
|
||||
'Este audiolibro no tiene información de capítulos';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -637,7 +663,8 @@ class SEs extends S {
|
||||
String get artist => 'Artista';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Mostrar álbumes reproducidos recientemente';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||
@@ -649,34 +676,41 @@ class SEs extends S {
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Mostrar audiolibros aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
String get showRandomSeries =>
|
||||
'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
||||
String get showFavoriteArtists =>
|
||||
'Mostrar una fila de tus artistas favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||
String get showFavoriteTracks =>
|
||||
'Mostrar una fila de tus canciones favoritas';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraer colores de las portadas de álbumes y artistas';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
@@ -774,7 +808,8 @@ class SEs extends S {
|
||||
String get loadingChapters => 'Cargando capítulos...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'No hay información de capítulos disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,13 +820,15 @@ class SEs extends S {
|
||||
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'Usa \"2\" para nivel gratuito o clave premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Biblioteca desconocida';
|
||||
@@ -800,7 +837,8 @@ class SEs extends S {
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
@@ -820,16 +858,19 @@ class SEs extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
String get noSeriesAvailable =>
|
||||
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Buscar';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
String get metadataApisDescription =>
|
||||
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||
@@ -901,13 +942,22 @@ class SEs extends S {
|
||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Orden inteligente';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'No disponible';
|
||||
@@ -939,7 +989,18 @@ class SEs extends S {
|
||||
String get showHints => 'Mostrar Sugerencias';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||
String get showHintsDescription =>
|
||||
'Mostrar consejos útiles para descubrir funciones';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||
@@ -957,13 +1018,15 @@ class SEs extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -979,19 +1042,22 @@ class SEs extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SFr extends S {
|
||||
String get portHint => 'ex., 8095 ou laisser vide';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
String get portDescription =>
|
||||
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
|
||||
@override
|
||||
String get username => 'Nom d\'utilisateur';
|
||||
@@ -60,10 +61,12 @@ class SFr extends S {
|
||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||
String get authServerUrlHint =>
|
||||
'ex., auth.example.com (si different du serveur)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
String get authServerUrlDescription =>
|
||||
'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,50 @@ class SFr extends S {
|
||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
String get httpNotEncrypted =>
|
||||
'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Veuillez entrer un numero de port valide (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
String get authFailed =>
|
||||
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
String get maLoginFailed =>
|
||||
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
String get connectionFailed =>
|
||||
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detection de l\'authentification...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Pas d\'authentification';
|
||||
@@ -128,7 +141,8 @@ class SFr extends S {
|
||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||
String get pressBackToMinimize =>
|
||||
'Appuyez a nouveau sur retour pour minimiser';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Ecoutes recemment';
|
||||
@@ -221,10 +235,12 @@ class SFr extends S {
|
||||
String get noRadioStations => 'Aucune station de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Ajouter des stations de radio dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
String get searchFailed =>
|
||||
'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
|
||||
@override
|
||||
String get queue => 'File d\'attente';
|
||||
@@ -264,7 +280,8 @@ class SFr extends S {
|
||||
String get system => 'Système';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
String get pullToRefresh =>
|
||||
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||
@@ -301,7 +318,8 @@ class SFr extends S {
|
||||
String get logsCleared => 'Journaux effaces';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
String get playerListCopied =>
|
||||
'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Pas encore de journaux';
|
||||
@@ -471,37 +489,43 @@ class SFr extends S {
|
||||
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAudiobook =>
|
||||
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Aucun livre audio';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
String get addAudiobooksHint =>
|
||||
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
String get tapHeartArtist =>
|
||||
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Aucun album favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAlbum =>
|
||||
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
String get tapHeartPlaylist =>
|
||||
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
String get longPressTrackHint =>
|
||||
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Charger les séries';
|
||||
@@ -513,7 +537,8 @@ class SFr extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
String get connectHint =>
|
||||
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurer le serveur';
|
||||
@@ -549,7 +574,8 @@ class SFr extends S {
|
||||
String get noLogsToCopy => 'Aucun journal a copier';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
String get noDebugLogsYet =>
|
||||
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Afficher le debogage';
|
||||
@@ -564,7 +590,8 @@ class SFr extends S {
|
||||
String get noChapters => 'Aucun chapitre';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
String get noChapterInfo =>
|
||||
'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -637,10 +664,12 @@ class SFr extends S {
|
||||
String get artist => 'Artiste';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Afficher les albums écoutes recemment';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||
String get showRandomArtists =>
|
||||
'Afficher des artistes aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
@@ -649,34 +678,43 @@ class SFr extends S {
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Afficher des livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
String get showRandomSeries =>
|
||||
'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
||||
String get showFavoriteArtists =>
|
||||
'Afficher une rangee de vos artistes favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||
String get showFavoriteTracks =>
|
||||
'Afficher une rangee de vos pistes favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||
String get showFavoritePlaylists =>
|
||||
'Afficher une rangee de vos playlists favorites';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Afficher une rangee de vos stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||
String get showFavoritePodcasts =>
|
||||
'Afficher une rangee de vos podcasts favoris';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
@@ -721,13 +759,15 @@ class SFr extends S {
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||
String get podcastSupportComingSoon =>
|
||||
'Prise en charge des podcasts bientot disponible';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'Aucun podcast';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
String get addPodcastsHint =>
|
||||
'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
@@ -774,7 +814,8 @@ class SFr extends S {
|
||||
String get loadingChapters => 'Chargement des chapitres...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'Aucune information de chapitre disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,13 +826,15 @@ class SFr extends S {
|
||||
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||
@@ -800,7 +843,8 @@ class SFr extends S {
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
@@ -820,16 +864,19 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
String get noSeriesAvailable =>
|
||||
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Rechercher';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
String get metadataApisDescription =>
|
||||
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Cle API Last.fm';
|
||||
@@ -852,7 +899,8 @@ class SFr extends S {
|
||||
String get playOn => 'Lire sur...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||
String get addAlbumToQueueOn =>
|
||||
'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
@@ -889,7 +937,8 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||
String get materialYouDescription =>
|
||||
'Utiliser les couleurs systeme (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Couleur d\'accentuation';
|
||||
@@ -901,13 +950,22 @@ class SFr extends S {
|
||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Toujours sélectionner cet appareil en premier si disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Tri intelligent';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Indisponible';
|
||||
@@ -939,7 +997,18 @@ class SFr extends S {
|
||||
String get showHints => 'Afficher les astuces';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
String get showHintsDescription =>
|
||||
'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||
@@ -951,19 +1020,22 @@ class SFr extends S {
|
||||
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
String get selectPlayerHint =>
|
||||
'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
String get welcomeMessage =>
|
||||
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
|
||||
@override
|
||||
String get skip => 'Passer';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
String get dismissPlayerHint =>
|
||||
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -979,19 +1051,22 @@ class SFr extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
|
||||
@@ -440,9 +440,20 @@ class Audiobook extends MediaItem {
|
||||
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||
final item = MediaItem.fromJson(json);
|
||||
|
||||
// Parse year
|
||||
// Parse year - check top level first, then metadata.release_date (ABS format)
|
||||
int? year;
|
||||
final yearValue = json['year'];
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
dynamic yearValue = json['year'];
|
||||
if (yearValue == null && metadata != null) {
|
||||
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
|
||||
final releaseDate = metadata['release_date'];
|
||||
if (releaseDate is String && releaseDate.isNotEmpty) {
|
||||
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
|
||||
yearValue = releaseDate.split('-').first;
|
||||
} else if (releaseDate is int) {
|
||||
yearValue = releaseDate;
|
||||
}
|
||||
}
|
||||
if (yearValue is int) {
|
||||
year = yearValue;
|
||||
} else if (yearValue is String) {
|
||||
@@ -472,8 +483,6 @@ class Audiobook extends MediaItem {
|
||||
|
||||
// Parse chapters - check top level first, then metadata
|
||||
List<Chapter>? chapters;
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
|
||||
dynamic chaptersData = json['chapters'];
|
||||
if (chaptersData == null && metadata != null) {
|
||||
chaptersData = metadata['chapters'];
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Represents a provider manifest from Music Assistant.
|
||||
///
|
||||
/// Contains metadata about a provider type including its icons.
|
||||
/// This is separate from ProviderInstance which represents a configured
|
||||
/// instance of a provider.
|
||||
class ProviderManifest {
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this provider type
|
||||
final String name;
|
||||
|
||||
/// Description of the provider
|
||||
final String? description;
|
||||
|
||||
/// SVG icon as XML string
|
||||
final String? iconSvg;
|
||||
|
||||
/// SVG icon for dark theme as XML string
|
||||
final String? iconSvgDark;
|
||||
|
||||
/// Monochrome SVG icon as XML string
|
||||
final String? iconSvgMonochrome;
|
||||
|
||||
/// Material Design icon name (fallback)
|
||||
final String? icon;
|
||||
|
||||
ProviderManifest({
|
||||
required this.domain,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.iconSvg,
|
||||
this.iconSvgDark,
|
||||
this.iconSvgMonochrome,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// Whether this manifest has any SVG icon available
|
||||
bool get hasSvgIcon =>
|
||||
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
|
||||
|
||||
/// Get the best available SVG icon for the given brightness
|
||||
/// Prefers dark icon for dark themes, falls back to regular or monochrome
|
||||
String? getSvgIcon({bool isDark = false}) {
|
||||
if (isDark && iconSvgDark != null) {
|
||||
return iconSvgDark;
|
||||
}
|
||||
return iconSvg ?? iconSvgMonochrome;
|
||||
}
|
||||
|
||||
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderManifest(
|
||||
domain: json['domain'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
iconSvg: json['icon_svg'] as String?,
|
||||
iconSvgDark: json['icon_svg_dark'] as String?,
|
||||
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderManifest($domain: $name)';
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -23,6 +24,7 @@ import '../services/position_tracker.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
import '../services/offline_action_queue.dart';
|
||||
import '../services/remote/remote_bridge.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
@@ -63,6 +65,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
||||
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
|
||||
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
|
||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||
@@ -76,6 +80,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
|
||||
|
||||
// Idle service timeout - stops foreground service after 30 min of no playback
|
||||
Timer? _idleServiceTimer;
|
||||
static const _idleServiceTimeout = Duration(minutes: 30);
|
||||
|
||||
// Local player state
|
||||
bool _isLocalPlayerPowered = true;
|
||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||
@@ -98,6 +106,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
SendspinService? _sendspinService;
|
||||
bool _sendspinConnected = false;
|
||||
|
||||
// Remote connection (WebRTC-to-WS bridge for remote access)
|
||||
RemoteBridge? _remoteBridge;
|
||||
bool get isRemoteConnection => _remoteBridge != null && _remoteBridge!.isRunning;
|
||||
|
||||
// PCM audio player for raw Sendspin audio streaming
|
||||
PcmAudioPlayer? _pcmAudioPlayer;
|
||||
|
||||
@@ -227,6 +239,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// List of available music providers from MA
|
||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
||||
|
||||
/// Get all provider manifests (contains icons)
|
||||
Map<String, ProviderManifest> get providerManifests =>
|
||||
_api?.providerManifests ?? {};
|
||||
|
||||
/// Get SVG icon for a provider domain
|
||||
/// Returns null if no icon is available
|
||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||
return _api?.getProviderIconSvg(domain, isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _api?.getProviderManifest(domain);
|
||||
}
|
||||
|
||||
/// List of enabled provider instance IDs (empty = all enabled)
|
||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||
|
||||
@@ -353,22 +380,40 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Build list of providers that support this category, with their item counts
|
||||
// Only include providers that have at least 1 item (hides providers not synced to library)
|
||||
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
|
||||
// but we dedupe if names match exactly (same account re-added with new instance ID)
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
final addedInstanceIds = <String>{};
|
||||
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
|
||||
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type AND have items
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
if (count > 0) {
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
final existingCount = addedNames[provider.name];
|
||||
if (existingCount == null) {
|
||||
// First provider with this name
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
} else if (count > existingCount) {
|
||||
// Same name but more items - likely same account re-added, keep better one
|
||||
result.removeWhere((r) => r.$1.name == provider.name);
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include providers that have items but aren't in _availableMusicProviders
|
||||
// (e.g., search-only providers like iTunes)
|
||||
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
|
||||
// For these synthetic providers, dedupe by domain since we don't have account-specific names
|
||||
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
|
||||
final addedSyntheticDomains = <String>{};
|
||||
|
||||
for (final entry in counts.entries) {
|
||||
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
|
||||
// Create a synthetic ProviderInstance from the instance ID
|
||||
@@ -377,6 +422,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final domain = instanceId.contains('--')
|
||||
? instanceId.split('--').first
|
||||
: instanceId;
|
||||
|
||||
// Skip if this domain is already represented by a real provider or synthetic
|
||||
if (addedRealDomains.contains(domain)) continue;
|
||||
if (addedSyntheticDomains.contains(domain)) continue;
|
||||
|
||||
// Only add if this domain supports the category
|
||||
final capabilities = ProviderInstance.providerCapabilities[domain];
|
||||
if (capabilities != null && capabilities.contains(category)) {
|
||||
@@ -389,6 +439,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
),
|
||||
entry.value,
|
||||
));
|
||||
addedSyntheticDomains.add(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1079,6 +1130,61 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a remote MA server via WebRTC using Remote Access ID.
|
||||
/// Uses a WebRTC-to-WebSocket bridge so MusicAssistantAPI works unchanged.
|
||||
Future<bool> connectRemote(String remoteId, String username, String password) async {
|
||||
_logger.log('🌐 Attempting remote connection to: $remoteId');
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
_connectionState = MAConnectionState.connecting;
|
||||
notifyListeners();
|
||||
|
||||
// Start the WebRTC-to-WS bridge
|
||||
_remoteBridge = RemoteBridge();
|
||||
final port = await _remoteBridge!.start(remoteId, username, password);
|
||||
|
||||
if (port == null) {
|
||||
_logger.log('❌ Remote bridge failed to start');
|
||||
_remoteBridge = null;
|
||||
_connectionState = MAConnectionState.error;
|
||||
_error = 'Remote connection failed';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('✅ Remote bridge started on port $port');
|
||||
|
||||
// Connect via normal API - it will hit the local bridge
|
||||
// The bridge already authenticated, so we use the same credentials
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
await connectToServer('ws://localhost:$port');
|
||||
|
||||
// Wait a moment for connection to establish
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (isConnected) {
|
||||
_logger.log('✅ Remote connection via bridge successful!');
|
||||
return true;
|
||||
} else {
|
||||
_logger.log('❌ Connection to bridge failed, stopping bridge');
|
||||
await _remoteBridge?.stop();
|
||||
_remoteBridge = null;
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Remote connection error: $e');
|
||||
await _remoteBridge?.stop();
|
||||
_remoteBridge = null;
|
||||
_error = e.toString();
|
||||
_connectionState = MAConnectionState.error;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _handleMaAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
@@ -1234,11 +1340,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
await SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
|
||||
// Update local state directly for immediate UI response
|
||||
// If currently "all enabled" (empty list) and disabling one, need to switch to explicit mode
|
||||
if (_enabledProviderIds.isEmpty && !enabled) {
|
||||
// Initialize with all providers except the one being disabled
|
||||
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
|
||||
} else if (enabled) {
|
||||
// Add to list if not present
|
||||
if (!_enabledProviderIds.contains(instanceId)) {
|
||||
_enabledProviderIds = [..._enabledProviderIds, instanceId];
|
||||
}
|
||||
} else {
|
||||
// Remove from list
|
||||
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
|
||||
}
|
||||
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
// Persist to settings (async, but don't wait for UI update)
|
||||
SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
|
||||
|
||||
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
|
||||
|
||||
@@ -1758,6 +1876,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinConnected = false;
|
||||
}
|
||||
await _api?.disconnect();
|
||||
// Stop remote bridge if running
|
||||
if (_remoteBridge != null) {
|
||||
await _remoteBridge!.stop();
|
||||
_remoteBridge = null;
|
||||
}
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
// DON'T clear caches or player state - keep for instant reconnect
|
||||
_logger.log('📡 Explicit disconnect - keeping cached data for instant resume');
|
||||
@@ -1944,24 +2067,21 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return beta >= 20;
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
|
||||
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
|
||||
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
|
||||
/// or manually configure reverse proxy routing to port 8927
|
||||
/// Check if server has built-in Sendspin proxy (added in MA 2.7.1)
|
||||
bool _serverHasSendspinProxy() {
|
||||
final version = _parseServerVersion();
|
||||
if (version == null) return false;
|
||||
|
||||
final (:major, :minor, :patch, :beta) = version;
|
||||
|
||||
// Compare with 2.8.0
|
||||
// Compare with 2.7.1
|
||||
if (major > 2) return true;
|
||||
if (major < 2) return false;
|
||||
// major == 2
|
||||
if (minor > 8) return true;
|
||||
if (minor < 8) return false;
|
||||
// minor == 8, any 2.8.x version has the proxy
|
||||
return true;
|
||||
if (minor > 7) return true;
|
||||
if (minor < 7) return false;
|
||||
// minor == 7, need patch >= 1
|
||||
return patch >= 1;
|
||||
}
|
||||
|
||||
/// Get the server version string for logging
|
||||
@@ -2074,7 +2194,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// This is the replacement for builtin_player when that API is not available.
|
||||
///
|
||||
/// Connection strategy depends on MA version and network:
|
||||
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
|
||||
/// - MA 2.7.1+: Has built-in /sendspin proxy, works with any reverse proxy setup
|
||||
/// - MA 2.7.x: NO proxy, must either:
|
||||
/// - Use local IP with port 8927 exposed, OR
|
||||
/// - Manually configure reverse proxy to route /sendspin to port 8927
|
||||
@@ -2090,7 +2210,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinService?.dispose();
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
|
||||
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
|
||||
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
|
||||
final authToken = await SettingsService.getMaAuthToken();
|
||||
if (authToken != null) {
|
||||
_sendspinService!.setAuthToken(authToken);
|
||||
@@ -2141,10 +2261,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
|
||||
|
||||
// For local IPs, also try the proxy path in case user has a local reverse proxy
|
||||
// For local IPs, also try the proxy path (MA 2.7.1+ has built-in /sendspin proxy)
|
||||
_logger.log('Sendspin: Trying local proxy fallback...');
|
||||
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl, useProxyAuth: true);
|
||||
if (proxyConnected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local proxy');
|
||||
@@ -2155,7 +2275,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
|
||||
if (isHttps || !isLocalIp) {
|
||||
if (hasProxy) {
|
||||
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
|
||||
_logger.log('Sendspin: MA 2.7.1+ detected, using built-in /sendspin proxy');
|
||||
} else {
|
||||
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
|
||||
}
|
||||
@@ -2196,7 +2316,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
|
||||
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
|
||||
} else {
|
||||
// MA 2.8.0+ but still failing
|
||||
// MA 2.7.1+ but still failing
|
||||
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
|
||||
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
|
||||
_logger.log(' Ensure Upgrade and Connection headers are passed through');
|
||||
@@ -2345,6 +2465,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
// Initialize notification with position 0
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: true,
|
||||
@@ -2385,6 +2506,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
// Show paused state with preserved position
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: false,
|
||||
@@ -3727,8 +3849,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
// Log settings for debugging player selection issues
|
||||
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
|
||||
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
|
||||
|
||||
// If "Prefer Local Player" is ON, always try to select local player first
|
||||
// This takes priority even over the currently selected player
|
||||
if (preferLocalPlayer && builtinPlayerId != null) {
|
||||
@@ -3739,12 +3866,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
|
||||
} catch (e) {
|
||||
// Local player not available yet, will fall through to other options
|
||||
_logger.log('⚠️ Prefer local player ON but local player not available');
|
||||
}
|
||||
}
|
||||
|
||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||
// But allow switching to a playing player when preferLocalPlayer is OFF
|
||||
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
||||
// On coldStart, skip this block and apply full priority logic
|
||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
||||
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
|
||||
@@ -3771,71 +3898,73 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
|
||||
);
|
||||
|
||||
// If preferLocalPlayer is OFF, check if we should switch to a playing player
|
||||
if (!preferLocalPlayer) {
|
||||
final currentIsPlaying = currentPlayer.state == 'playing';
|
||||
// Exclude external sources - they're not playing MA content
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
).toList();
|
||||
|
||||
// Switch to playing player only if current isn't playing and exactly one other is
|
||||
if (!currentIsPlaying && playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// Keep current selection if no switch happened
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = currentPlayer;
|
||||
if (currentPlayer.playerId != selectedId) {
|
||||
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||
}
|
||||
// Keep current selection - no auto-switching during non-coldStart refreshes
|
||||
// Auto-switch to playing player is now ONLY done on coldStart (see below)
|
||||
playerToSelect = currentPlayer;
|
||||
if (currentPlayer.playerId != selectedId) {
|
||||
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||
} else {
|
||||
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (coldStart) {
|
||||
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)');
|
||||
_logger.log('🚀 Cold start: applying full priority logic');
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
// Smart auto-selection priority:
|
||||
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
||||
// Smart auto-selection priority (coldStart only for auto-switch to playing):
|
||||
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
|
||||
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
|
||||
// Priority 3: Last manually selected player
|
||||
// Priority 4: First available player
|
||||
|
||||
if (!preferLocalPlayer) {
|
||||
// Priority 1 (normal): Single playing player (skip if multiple are playing)
|
||||
// Exclude external sources - they're not playing MA content
|
||||
// Priority 1: Auto-switch to single playing player
|
||||
// ONLY on coldStart AND only if disableAutoSwitch is OFF
|
||||
// Also apply cooldown to prevent rapid successive auto-switches
|
||||
final cooldownActive = _lastPlayerSwitchTime != null &&
|
||||
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
|
||||
|
||||
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
|
||||
final playingPlayers = _availablePlayers.where(
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
).toList();
|
||||
if (playingPlayers.length == 1) {
|
||||
playerToSelect = playingPlayers.first;
|
||||
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
|
||||
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
|
||||
} else if (playingPlayers.length > 1) {
|
||||
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
|
||||
}
|
||||
} else if (cooldownActive) {
|
||||
_logger.log('⏳ Auto-switch cooldown active, skipping');
|
||||
} else if (!coldStart) {
|
||||
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
|
||||
} else if (disableAutoSwitch) {
|
||||
_logger.log('⚠️ Auto-switch disabled by user setting');
|
||||
}
|
||||
|
||||
// Priority 2: Local player
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||
} catch (e) {
|
||||
// Local player not found or not available - check why
|
||||
final localPlayer = _availablePlayers
|
||||
.where((p) => p.playerId == builtinPlayerId)
|
||||
.toList();
|
||||
if (localPlayer.isEmpty) {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||
} else {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
||||
}
|
||||
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
||||
} catch (e) {
|
||||
// Local player not found or not available - check why
|
||||
final localPlayer = _availablePlayers
|
||||
.where((p) => p.playerId == builtinPlayerId)
|
||||
.toList();
|
||||
if (localPlayer.isEmpty) {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
||||
} else {
|
||||
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
||||
}
|
||||
} else if (playerToSelect == null && builtinPlayerId == null) {
|
||||
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
||||
}
|
||||
} else if (playerToSelect == null && builtinPlayerId == null) {
|
||||
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
||||
}
|
||||
|
||||
// Priority 3: Last manually selected player
|
||||
@@ -3855,6 +3984,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
);
|
||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3890,12 +4020,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||
// stale track info when switching to a non-playing player.
|
||||
// For grouped child players, this returns the leader's track
|
||||
// IMPORTANT: Clear track for external sources - they're playing non-MA content
|
||||
if (player.isExternalSource) {
|
||||
_currentTrack = null;
|
||||
} else {
|
||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||
}
|
||||
// NOTE: Always use cache even for external sources - the overlay shows cached
|
||||
// track info, so we should match that. The async _updatePlayerState() will
|
||||
// clear it if the player is truly playing external content.
|
||||
_currentTrack = getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Initialize position tracker for this player (skip for external sources)
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
@@ -3949,6 +4077,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
} else {
|
||||
// Builtin player is idle - clear notification (fixes issue #42)
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
} else {
|
||||
// For remote players, immediately show notification if we have cached track info
|
||||
@@ -3970,6 +4099,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
// Use position tracker for consistent position
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
@@ -3985,6 +4115,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
artist: 'Loading...',
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
@@ -3995,6 +4126,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Player is idle - clear the notification to prevent stale metadata
|
||||
// from previous player showing (fixes issue #42)
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4148,6 +4280,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: isPlaying,
|
||||
@@ -4332,6 +4465,26 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
/// Start the idle service timer - stops foreground service after 30 min of no playback
|
||||
/// Called when playback stops (player state becomes idle)
|
||||
void _startIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
|
||||
_idleServiceTimer = Timer(_idleServiceTimeout, () {
|
||||
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
|
||||
audioHandler.stopService();
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel the idle service timer - called when playback starts
|
||||
void _cancelIdleServiceTimer() {
|
||||
if (_idleServiceTimer?.isActive == true) {
|
||||
_logger.log('⏱️ Cancelling idle service timer (playback resumed)');
|
||||
_idleServiceTimer?.cancel();
|
||||
_idleServiceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
@@ -4420,6 +4573,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
// Clear notification for external source
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
if (stateChanged) {
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -4606,6 +4760,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
// Use position tracker for consistent position (single source of truth)
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: _selectedPlayer!.state == 'playing',
|
||||
@@ -4640,6 +4795,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
} else {
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: _selectedPlayer!.state == 'playing',
|
||||
@@ -4649,6 +4805,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
} else {
|
||||
audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
_positionTracker.clear();
|
||||
}
|
||||
}
|
||||
@@ -4876,6 +5033,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache with sorted data so UI sees the new order
|
||||
SyncService.instance.updateCachedArtists(_artists);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -4908,6 +5068,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache with sorted data so UI sees the new order
|
||||
SyncService.instance.updateCachedAlbums(_albums);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -5853,6 +6016,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_idleServiceTimer?.cancel();
|
||||
_connectionStateSubscription?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
@@ -877,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.album.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
@@ -366,6 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
@@ -413,6 +415,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1002,37 +1005,46 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0, // Square album art
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: album.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Spacing.vGap8,
|
||||
|
||||
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final _logger = DebugLogger();
|
||||
late List<Audiobook> _audiobooks;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
@@ -407,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -497,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.audiobook.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
// Progress indicator
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
size: 16,
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,11 +5,12 @@ import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/auth/auth_strategy.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/debug/debug_console.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
enum ConnectionMode { local, remote }
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@@ -24,8 +25,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
|
||||
final FocusNode _usernameFocusNode = FocusNode();
|
||||
|
||||
ConnectionMode _connectionMode = ConnectionMode.local;
|
||||
bool _isConnecting = false;
|
||||
bool _isDetectingAuth = false;
|
||||
AuthStrategy? _detectedAuthStrategy;
|
||||
@@ -74,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_authServerUrlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_remoteIdController.dispose();
|
||||
_usernameFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -477,6 +481,68 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectRemote() async {
|
||||
final remoteId = _remoteIdController.text.trim();
|
||||
if (remoteId.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Please enter a Remote Access ID';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterCredentials;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
_addDebugLog('Connecting to remote server: $remoteId');
|
||||
|
||||
try {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Connect via remote access
|
||||
final success = await provider.connectRemote(remoteId, username, password);
|
||||
|
||||
if (success) {
|
||||
_addDebugLog('Remote connection successful');
|
||||
|
||||
// Save credentials for future use
|
||||
await SettingsService.setRemoteAccessId(remoteId);
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_error = 'Remote connection failed. Check your credentials and Remote Access ID.';
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_addDebugLog('Remote connection error: $e');
|
||||
setState(() {
|
||||
_error = 'Connection failed: ${e.toString()}';
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -509,8 +575,160 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Connection mode selector
|
||||
SegmentedButton<ConnectionMode>(
|
||||
segments: [
|
||||
ButtonSegment<ConnectionMode>(
|
||||
value: ConnectionMode.local,
|
||||
label: Text('Local'),
|
||||
icon: Icon(Icons.home_rounded),
|
||||
),
|
||||
ButtonSegment<ConnectionMode>(
|
||||
value: ConnectionMode.remote,
|
||||
label: Text('Remote'),
|
||||
icon: Icon(Icons.cloud_rounded),
|
||||
),
|
||||
],
|
||||
selected: {_connectionMode},
|
||||
onSelectionChanged: _isConnecting || _isDetectingAuth
|
||||
? null
|
||||
: (Set<ConnectionMode> selection) {
|
||||
setState(() {
|
||||
_connectionMode = selection.first;
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
});
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.primaryContainer;
|
||||
}
|
||||
return colorScheme.surfaceVariant.withOpacity(0.3);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Remote Access UI
|
||||
if (_connectionMode == ConnectionMode.remote) ...[
|
||||
Text(
|
||||
'Remote Access ID',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Find this in Music Assistant Settings → Remote Access',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _remoteIdController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'XXXX-XXXX-XXXX-XXXX',
|
||||
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,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Username
|
||||
Text(
|
||||
S.of(context)!.username,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
focusNode: _usernameFocusNode,
|
||||
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
|
||||
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: 24),
|
||||
],
|
||||
|
||||
// Local connection UI
|
||||
if (_connectionMode == ConnectionMode.local) ...[
|
||||
// Server URL
|
||||
Text(
|
||||
S.of(context)!.serverAddress,
|
||||
@@ -856,6 +1074,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
], // End of local connection UI
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -898,11 +1117,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Connect button (changes based on state)
|
||||
// Connect button (changes based on state and mode)
|
||||
ElevatedButton(
|
||||
onPressed: (_isConnecting || _isDetectingAuth)
|
||||
? null
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
: _connectionMode == ConnectionMode.remote
|
||||
? _connectRemote
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
@@ -944,7 +1165,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
|
||||
_connectionMode == ConnectionMode.remote
|
||||
? S.of(context)!.connect
|
||||
: (_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
@@ -691,30 +692,44 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.playlist.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.playlist.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -478,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -41,9 +41,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _disableAutoSwitch = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -84,11 +87,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -106,8 +113,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_homeRowOrder = rowOrder;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -746,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
S.of(context)!.disableAutoSwitch,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.disableAutoSwitchDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _disableAutoSwitch,
|
||||
onChanged: (value) {
|
||||
setState(() => _disableAutoSwitch = value);
|
||||
SettingsService.setDisableAutoSwitch(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -811,6 +845,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Display section
|
||||
Text(
|
||||
S.of(context)!.display,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
S.of(context)!.showProviderIcons,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.showProviderIconsDescription,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
value: _showProviderIcons,
|
||||
onChanged: (value) {
|
||||
setState(() => _showProviderIcons = value);
|
||||
SettingsService.setShowProviderIcons(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Home Screen section
|
||||
|
||||
@@ -157,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
// Note: We don't actually stop playback - this button is repurposed for player switching
|
||||
}
|
||||
|
||||
/// Fully stop the foreground service and release resources
|
||||
/// Called after idle timeout to save battery
|
||||
Future<void> stopService() async {
|
||||
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
|
||||
_isRemoteMode = false;
|
||||
_currentMediaItem = null;
|
||||
await _player.stop();
|
||||
// Call the base stop() to properly stop the foreground service
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../constants/timings.dart' show Timings, LibraryConstants;
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -60,6 +61,10 @@ class MusicAssistantAPI {
|
||||
int? get schemaVersion => _schemaVersion;
|
||||
Map<String, dynamic>? get serverInfo => _serverInfo;
|
||||
|
||||
// Provider manifests (contains icons)
|
||||
final Map<String, ProviderManifest> _providerManifests = {};
|
||||
Map<String, ProviderManifest> get providerManifests => _providerManifests;
|
||||
|
||||
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||
|
||||
// Guard to prevent multiple simultaneous connection attempts
|
||||
@@ -804,6 +809,15 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
|
||||
|
||||
// If browse returned nothing, the saved library paths may be stale
|
||||
// (e.g., after provider was removed and re-added with new instance ID)
|
||||
// Clear the stale settings so we fall back to library_items API
|
||||
if (browseBooks.isEmpty) {
|
||||
_logger.log('📚 Browse returned 0 books - clearing stale library settings');
|
||||
await SettingsService.clearEnabledAbsLibraries();
|
||||
await SettingsService.setDiscoveredAbsLibraries([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,10 +835,13 @@ class MusicAssistantAPI {
|
||||
|
||||
// Only call library_items API if:
|
||||
// 1. No ABS libraries configured (traditional mode), OR
|
||||
// 2. We have non-ABS providers to query
|
||||
// 2. We have specific non-ABS providers to query, OR
|
||||
// 3. ABS browse returned 0 items (stale paths were cleared above)
|
||||
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
|
||||
// as library_items API would return all audiobooks bypassing the library filter
|
||||
final shouldFetchFromApi = enabledLibraries == null ||
|
||||
enabledLibraries.isEmpty ||
|
||||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
|
||||
allAudiobooks.isEmpty || // Fall back if browse got nothing
|
||||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
|
||||
|
||||
if (shouldFetchFromApi) {
|
||||
@@ -3422,10 +3439,22 @@ class MusicAssistantAPI {
|
||||
try {
|
||||
_logger.log('📥 Fetching initial state...');
|
||||
|
||||
// Fetch provider manifests (available providers)
|
||||
// Fetch provider manifests (available providers with icons)
|
||||
try {
|
||||
await _sendCommand('providers/manifests');
|
||||
_logger.log('✓ Provider manifests loaded');
|
||||
final manifestResponse = await _sendCommand('providers/manifests');
|
||||
final manifests = manifestResponse['result'];
|
||||
if (manifests is List) {
|
||||
_providerManifests.clear();
|
||||
for (final manifestData in manifests) {
|
||||
if (manifestData is Map<String, dynamic>) {
|
||||
final manifest = ProviderManifest.fromJson(manifestData);
|
||||
if (manifest.domain.isNotEmpty) {
|
||||
_providerManifests[manifest.domain] = manifest;
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||
}
|
||||
@@ -3470,6 +3499,18 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _providerManifests[domain];
|
||||
}
|
||||
|
||||
/// Get SVG icon for a provider domain
|
||||
/// Returns null if no icon is available
|
||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||
final manifest = _providerManifests[domain];
|
||||
return manifest?.getSvgIcon(isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get current authenticated user's profile
|
||||
/// Returns user info including display_name, username, groups, etc.
|
||||
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
|
||||
|
||||
@@ -154,7 +154,8 @@ class SendspinService {
|
||||
}
|
||||
|
||||
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
|
||||
Future<bool> connectWithUrl(String wsUrl) async {
|
||||
/// If useProxyAuth is true, will authenticate with MA token (needed for proxy connections)
|
||||
Future<bool> connectWithUrl(String wsUrl, {bool useProxyAuth = false}) async {
|
||||
if (_isDisposed) return false;
|
||||
if (_state == SendspinConnectionState.connected) return true;
|
||||
|
||||
@@ -173,7 +174,7 @@ class SendspinService {
|
||||
|
||||
_logger.log('Sendspin: Connecting with URL: $wsUrl');
|
||||
|
||||
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
|
||||
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5), useProxyAuth: useProxyAuth);
|
||||
|
||||
if (!connected) {
|
||||
_updateState(SendspinConnectionState.error);
|
||||
|
||||
@@ -92,6 +92,7 @@ class SettingsService {
|
||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
||||
static const String _keySmartSortPlayers = 'smart_sort_players';
|
||||
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
@@ -167,6 +168,7 @@ class SettingsService {
|
||||
|
||||
// Hint System Settings
|
||||
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
||||
static const String _keyShowProviderIcons = 'show_provider_icons'; // Show provider icons on album art
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||
|
||||
@@ -176,6 +178,9 @@ class SettingsService {
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
// Remote Access Settings
|
||||
static const String _keyRemoteAccessId = 'remote_access_id';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -263,6 +268,22 @@ class SettingsService {
|
||||
await SecureStorageService.clearAuthCredentials();
|
||||
}
|
||||
|
||||
// Get Remote Access ID for remote connections
|
||||
static Future<String?> getRemoteAccessId() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyRemoteAccessId);
|
||||
}
|
||||
|
||||
// Set Remote Access ID for remote connections
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Get username for authentication
|
||||
static Future<String?> getUsername() async {
|
||||
final prefs = await _getPrefs();
|
||||
@@ -360,6 +381,10 @@ class SettingsService {
|
||||
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
||||
|
||||
// Disable Auto-Switch - prevents automatic switching to playing players
|
||||
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
|
||||
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
|
||||
|
||||
// Helper to create player name with possessive apostrophe
|
||||
// Automatically detects Phone vs Tablet based on screen size
|
||||
static String _makePlayerName(String ownerName) {
|
||||
@@ -882,6 +907,24 @@ class SettingsService {
|
||||
await prefs.setBool(_keyShowHints, show);
|
||||
}
|
||||
|
||||
/// Get whether provider icons are shown on album art (default: true)
|
||||
static Future<bool> getShowProviderIcons() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyShowProviderIcons) ?? true;
|
||||
}
|
||||
|
||||
/// Synchronous getter for provider icons setting (uses cached prefs)
|
||||
/// Returns true if prefs not yet initialized
|
||||
static bool getShowProviderIconsSync() {
|
||||
return _prefsCache?.getBool(_keyShowProviderIcons) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether provider icons are shown on album art
|
||||
static Future<void> setShowProviderIcons(bool show) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setBool(_keyShowProviderIcons, show);
|
||||
}
|
||||
|
||||
/// Check if user has ever used the player reveal gesture
|
||||
static Future<bool> getHasUsedPlayerReveal() async {
|
||||
final prefs = await _getPrefs();
|
||||
|
||||
@@ -505,13 +505,11 @@ class SyncService with ChangeNotifier {
|
||||
/// Empty enabledProviderIds = all providers enabled, show everything
|
||||
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
// Empty set = all providers enabled, show everything
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
// STRICT MODE: Hide items without tracking to ensure accurate filtering
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
@@ -524,7 +522,6 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
return _cachedArtists.where((artist) {
|
||||
final sources = _artistSourceProviders[artist.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
@@ -578,4 +575,25 @@ class SyncService with ChangeNotifier {
|
||||
/// Check if we have data available (from cache or sync)
|
||||
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
|
||||
/// Update cached albums with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedAlbums(List<Album> albums) {
|
||||
_cachedAlbums = albums;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update cached artists with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedArtists(List<Artist> artists) {
|
||||
_cachedArtists = artists;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update cached playlists with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
void updateCachedPlaylists(List<Playlist> playlists) {
|
||||
_cachedPlaylists = playlists;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class AppTheme {
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
@@ -91,7 +91,7 @@ class AppTheme {
|
||||
foregroundColor: scheme.onBackground,
|
||||
iconTheme: IconThemeData(color: scheme.onBackground),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 2,
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class AlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
@@ -40,7 +41,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -123,8 +125,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -151,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) {
|
||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||
if (!_maImageFailed && url == maImageUrl) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onImageError();
|
||||
});
|
||||
}
|
||||
return Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.album.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title with year
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Artist name
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.album.artistsString,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -42,7 +42,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
bool _isNavigating = false;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -116,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -186,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Artist name - fixed height container so image size is consistent
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
SizedBox(
|
||||
height: 36, // Fixed height for 2 lines of text
|
||||
child: Hero(
|
||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../screens/audiobook_detail_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class AudiobookRow extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -285,6 +286,11 @@ class _AudiobookCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: audiobook.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../screens/album_details_screen.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'animated_icon_button.dart';
|
||||
import 'global_player_overlay.dart';
|
||||
import 'provider_icon.dart';
|
||||
import 'volume_control.dart';
|
||||
import 'player/player_widgets.dart';
|
||||
import 'player/mini_player_content.dart';
|
||||
@@ -2589,20 +2590,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Fixed cache size to avoid mid-animation cache thrashing
|
||||
memCacheWidth: 512,
|
||||
memCacheHeight: 512,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||
)
|
||||
: _buildPlaceholderArt(colorScheme, t),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Fixed cache size to avoid mid-animation cache thrashing
|
||||
memCacheWidth: 512,
|
||||
memCacheHeight: 512,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||
)
|
||||
: _buildPlaceholderArt(colorScheme, t),
|
||||
// Provider icon overlay (only show when expanded enough)
|
||||
if (t > 0.5 && currentTrack.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: currentTrack.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -3277,7 +3290,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// GPU PERF: Use icon color alpha instead of Opacity
|
||||
if (t > 0.3)
|
||||
Positioned(
|
||||
top: topPadding + 4,
|
||||
top: topPadding - 4,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
@@ -3294,7 +3307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// GPU PERF: Use text color alpha instead of Opacity
|
||||
if (t > 0.5)
|
||||
Positioned(
|
||||
top: topPadding + 12,
|
||||
top: topPadding + 6,
|
||||
left: 56,
|
||||
right: 56,
|
||||
child: IgnorePointer(
|
||||
@@ -3302,7 +3315,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
selectedPlayer.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
|
||||
fontSize: 15,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
@@ -3912,62 +3925,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// Drag handle area (visual only - swipe-to-close handled by outer wrapper)
|
||||
Column(
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: textColor.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
|
||||
onPressed: _closeLyricsPanel,
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lyrics',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (currentTrack != null)
|
||||
Text(
|
||||
'${currentTrack.name} • ${currentTrack.artistsString}',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Header (matches queue and sleep timer style)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
|
||||
onPressed: _closeLyricsPanel,
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Lyrics',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(color: textColor.withOpacity(0.1), height: 1),
|
||||
// Lyrics content
|
||||
@@ -4069,16 +4050,48 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
)
|
||||
// Plain lyrics - show as scrollable text
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_currentLyrics!,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
height: 1.8,
|
||||
// Wrapped with Listener for swipe-to-close (always at "top" for plain text)
|
||||
: Listener(
|
||||
onPointerDown: (event) {
|
||||
_lyricsSwipeStart = event.position;
|
||||
_wasLyricsAtTopOnSwipeStart = true; // Plain lyrics always count as at top
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (_lyricsSwipeStart == null) return;
|
||||
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
||||
final dx = (event.position.dx - _lyricsSwipeStart!.dx).abs();
|
||||
// Only trigger swipe-to-close if swiping down
|
||||
if (dy > 20 && dy > dx * 1.5 && _wasLyricsAtTopOnSwipeStart && !_isSwipingLyricsContent) {
|
||||
_isSwipingLyricsContent = true;
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_isSwipingLyricsContent && _lyricsSwipeStart != null) {
|
||||
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
||||
if (dy > 50) {
|
||||
// Calculate velocity (simplified)
|
||||
final velocity = dy / 200; // Approximate velocity
|
||||
_closeLyricsPanel(velocity: velocity);
|
||||
}
|
||||
}
|
||||
_lyricsSwipeStart = null;
|
||||
_isSwipingLyricsContent = false;
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
_lyricsSwipeStart = null;
|
||||
_isSwipingLyricsContent = false;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_currentLyrics!,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
|
||||
@@ -530,10 +530,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
child: widget.child,
|
||||
),
|
||||
// Global persistent bottom navigation bar - positioned at bottom
|
||||
// Hide when: not connected, no player selected, OR showing welcome screen
|
||||
// For first-time users, selectedPlayer becomes non-null at the same time
|
||||
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
|
||||
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
|
||||
// Hide when: not connected OR showing welcome screen
|
||||
// Always show when connected so users can access Settings/Library even without a player
|
||||
if (isConnected && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||
/// through alphabetically sorted lists.
|
||||
/// Display mode for the scrollbar popup indicator
|
||||
enum ScrollbarDisplayMode {
|
||||
/// Show first letter of item name (A, B, C, #)
|
||||
letter,
|
||||
/// Show year (2024, 2023, etc.)
|
||||
year,
|
||||
/// Show count number
|
||||
count,
|
||||
/// Hide the popup (just show scrollbar thumb)
|
||||
none,
|
||||
}
|
||||
|
||||
/// A scrollbar that shows a contextual popup when dragging, for fast navigation
|
||||
/// through sorted lists.
|
||||
///
|
||||
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
|
||||
/// section you're currently in via a popup indicator.
|
||||
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows contextual info
|
||||
/// about the current position via a popup indicator. The popup content adapts
|
||||
/// based on the display mode (letters for alphabetical, years for date sorts,
|
||||
/// counts for play count sorts).
|
||||
class LetterScrollbar extends StatefulWidget {
|
||||
/// The scrollable child widget (ListView, GridView, etc.)
|
||||
final Widget child;
|
||||
@@ -16,6 +30,14 @@ class LetterScrollbar extends StatefulWidget {
|
||||
/// Each item's first character is used to determine the letter shown in the popup.
|
||||
final List<String> items;
|
||||
|
||||
/// Display mode for the popup indicator
|
||||
final ScrollbarDisplayMode displayMode;
|
||||
|
||||
/// Custom display labels for each item (optional).
|
||||
/// When provided, these are shown in the popup instead of extracting letters.
|
||||
/// Length must match [items] if provided.
|
||||
final List<String>? displayLabels;
|
||||
|
||||
/// Callback when user taps/drags to a specific index.
|
||||
final void Function(int index)? onScrollToIndex;
|
||||
|
||||
@@ -45,6 +67,8 @@ class LetterScrollbar extends StatefulWidget {
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.items,
|
||||
this.displayMode = ScrollbarDisplayMode.letter,
|
||||
this.displayLabels,
|
||||
this.onScrollToIndex,
|
||||
this.onDragStateChanged,
|
||||
this.itemExtent,
|
||||
@@ -85,7 +109,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||
setState(() {
|
||||
_scrollFraction = newFraction;
|
||||
_currentLetter = _getLetterAtFraction(newFraction);
|
||||
_currentLetter = _getLabelAtFraction(newFraction);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -102,15 +126,32 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
return '#';
|
||||
}
|
||||
|
||||
/// Get the letter at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLetterAtFraction(double fraction) {
|
||||
/// Get the display label at a given scroll fraction (0.0 to 1.0)
|
||||
String _getLabelAtFraction(double fraction) {
|
||||
if (widget.items.isEmpty) return '';
|
||||
|
||||
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
|
||||
|
||||
// Use custom display labels if provided
|
||||
if (widget.displayLabels != null && index < widget.displayLabels!.length) {
|
||||
return widget.displayLabels![index];
|
||||
}
|
||||
|
||||
// Otherwise extract from items based on display mode
|
||||
if (index < widget.items.length) {
|
||||
final item = widget.items[index];
|
||||
if (item.isNotEmpty) {
|
||||
return _normalizeToLetter(item[0]);
|
||||
switch (widget.displayMode) {
|
||||
case ScrollbarDisplayMode.letter:
|
||||
return _normalizeToLetter(item[0]);
|
||||
case ScrollbarDisplayMode.year:
|
||||
case ScrollbarDisplayMode.count:
|
||||
// For year/count modes, the item itself should be the display value
|
||||
return item;
|
||||
case ScrollbarDisplayMode.none:
|
||||
// No popup shown, return empty
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -154,7 +195,7 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
final fraction = clampedPosition / effectiveHeight;
|
||||
|
||||
// Get letter for display
|
||||
final letter = _getLetterAtFraction(fraction);
|
||||
final letter = _getLabelAtFraction(fraction);
|
||||
|
||||
// Scroll proportionally
|
||||
final maxScroll = widget.controller.position.maxScrollExtent;
|
||||
@@ -170,6 +211,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
widget.controller.jumpTo(targetScroll);
|
||||
}
|
||||
|
||||
/// Build the popup bubble with appropriate size and styling based on display mode
|
||||
Widget _buildPopupBubble(ColorScheme colorScheme) {
|
||||
// Determine size and font based on content length and display mode
|
||||
final double width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
|
||||
switch (widget.displayMode) {
|
||||
case ScrollbarDisplayMode.letter:
|
||||
case ScrollbarDisplayMode.none: // Fallback (none won't actually reach here)
|
||||
width = 56;
|
||||
height = 56;
|
||||
fontSize = 28;
|
||||
break;
|
||||
case ScrollbarDisplayMode.year:
|
||||
width = 72;
|
||||
height = 48;
|
||||
fontSize = 22;
|
||||
break;
|
||||
case ScrollbarDisplayMode.count:
|
||||
// Dynamic width based on count length
|
||||
final labelLength = _currentLetter.length;
|
||||
width = labelLength <= 2 ? 56 : (labelLength <= 4 ? 72 : 88);
|
||||
height = 48;
|
||||
fontSize = labelLength <= 3 ? 22 : 18;
|
||||
break;
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_currentLetter,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -236,32 +325,15 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
),
|
||||
),
|
||||
|
||||
// The letter popup bubble
|
||||
if (_isDragging && _currentLetter.isNotEmpty)
|
||||
// The popup bubble (letter, year, or count) - hidden when displayMode is none
|
||||
if (_isDragging && _currentLetter.isNotEmpty && widget.displayMode != ScrollbarDisplayMode.none)
|
||||
Positioned(
|
||||
right: 48,
|
||||
top: (_dragPosition - 28).clamp(
|
||||
0.0,
|
||||
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_currentLetter,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _buildPopupBubble(colorScheme),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -623,16 +623,14 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
||||
} catch (e) {
|
||||
// Error playing index - handled silently
|
||||
}
|
||||
|
||||
// Clear optimistic state after delay - server state will take over
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = null;
|
||||
});
|
||||
// Error playing index - clear optimistic state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
|
||||
}
|
||||
|
||||
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
|
||||
@@ -789,7 +787,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class PlaylistCard extends StatefulWidget {
|
||||
final Playlist playlist;
|
||||
@@ -48,8 +49,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -76,51 +77,56 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
||||
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.playlist.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.playlist.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../constants/hero_tags.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'provider_icon.dart';
|
||||
|
||||
class PodcastCard extends StatefulWidget {
|
||||
final MediaItem podcast;
|
||||
@@ -54,8 +55,8 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
if (_isNavigating) return;
|
||||
_isNavigating = true;
|
||||
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
// PERF: Color extraction deferred to detail screen's initState
|
||||
// to avoid competing with Hero animation for GPU resources
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
@@ -82,51 +83,56 @@ class _PodcastCardState extends State<PodcastCard> {
|
||||
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Podcast title
|
||||
Hero(
|
||||
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||
Text(
|
||||
widget.podcast.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Podcast author (if available)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Displays a provider icon (SVG) from Music Assistant.
|
||||
///
|
||||
/// Shows a small icon in a rounded container, typically used as an overlay
|
||||
/// on album art to indicate which provider the item comes from.
|
||||
class ProviderIcon extends StatelessWidget {
|
||||
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Size of the icon container
|
||||
final double size;
|
||||
|
||||
/// Whether to use dark mode icon variant
|
||||
final bool? isDark;
|
||||
|
||||
const ProviderIcon({
|
||||
super.key,
|
||||
required this.domain,
|
||||
this.size = 20,
|
||||
this.isDark,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final useDark = isDark ?? brightness == Brightness.dark;
|
||||
|
||||
final svgString = maProvider.getProviderIconSvg(domain, isDark: useDark);
|
||||
|
||||
if (svgString == null || svgString.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SvgPicture.string(
|
||||
svgString,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A positioned provider icon overlay for use in Stack widgets.
|
||||
///
|
||||
/// Typically positioned in a corner of album art.
|
||||
class ProviderIconOverlay extends StatelessWidget {
|
||||
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Size of the icon container
|
||||
final double size;
|
||||
|
||||
/// Position from the edges
|
||||
final double margin;
|
||||
|
||||
const ProviderIconOverlay({
|
||||
super.key,
|
||||
required this.domain,
|
||||
this.size = 20,
|
||||
this.margin = 4,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if provider icons are enabled in settings
|
||||
if (!SettingsService.getShowProviderIconsSync()) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
child: ProviderIcon(
|
||||
domain: domain,
|
||||
size: size,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "72.0.0"
|
||||
_macros:
|
||||
dependency: transitive
|
||||
description: dart
|
||||
source: sdk
|
||||
version: "0.3.2"
|
||||
version: "91.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.7.0"
|
||||
version: "8.4.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -90,10 +85,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
||||
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "3.0.1"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -114,26 +109,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
||||
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "3.0.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.13"
|
||||
version: "2.6.1"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.2"
|
||||
version: "9.2.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -178,10 +173,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -210,10 +205,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -226,10 +221,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -258,10 +253,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.7"
|
||||
version: "3.1.3"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_webrtc
|
||||
sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -282,18 +285,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621
|
||||
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.23.1"
|
||||
version: "2.30.1"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9
|
||||
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.23.1"
|
||||
version: "2.30.1"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -314,10 +317,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -440,6 +443,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -458,6 +469,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -518,10 +537,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -574,26 +593,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.5"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -602,6 +621,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -610,22 +637,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
macros:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: macros
|
||||
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2-main.4"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.16+1"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -646,10 +665,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -710,10 +729,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -934,15 +961,15 @@ packages:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
||||
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
version: "4.2.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1011,26 +1038,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41"
|
||||
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.40.0"
|
||||
version: "0.43.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1067,10 +1094,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.7.7"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1127,14 +1154,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.18"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.16"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1167,6 +1218,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
webrtc_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webrtc_interface
|
||||
sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1208,5 +1267,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.9.0-beta+48
|
||||
version: 2.9.1-beta+49
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -11,12 +11,13 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: ^0.19.0
|
||||
intl: ^0.20.2
|
||||
|
||||
# UI
|
||||
material_design_icons_flutter: ^7.0.7296
|
||||
dynamic_color: 1.6.8
|
||||
palette_generator: ^0.3.3+3
|
||||
flutter_svg: ^2.0.10+1
|
||||
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
@@ -26,6 +27,9 @@ dependencies:
|
||||
http: ^1.1.0
|
||||
cached_network_image: ^3.3.1
|
||||
|
||||
# WebRTC (for remote access)
|
||||
flutter_webrtc: ^1.2.1
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
flutter_secure_storage: ^9.2.2
|
||||
|
||||