Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa6299130 | ||
|
|
e6a02c191e | ||
|
|
7d7e5266c7 |
@@ -44,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
|
|||||||
- **Playlist Support** - Browse and play your playlists
|
- **Playlist Support** - Browse and play your playlists
|
||||||
- **Search** - Find music across your entire library
|
- **Search** - Find music across your entire library
|
||||||
|
|
||||||
|
### Audiobooks
|
||||||
|
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
|
||||||
|
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||||
|
- **Author Browsing** - Browse audiobooks by author
|
||||||
|
- **Chapter Navigation** - Jump between chapters with timestamp display
|
||||||
|
- **Progress Tracking** - Track your listening progress across sessions
|
||||||
|
- **Continue Listening** - Pick up where you left off
|
||||||
|
- **Mark as Finished/Unplayed** - Manage your reading progress
|
||||||
|
|
||||||
### Smart Features
|
### Smart Features
|
||||||
|
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||||
- **Caching** - Fast navigation with cached content
|
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||||
- **Hero Animations** - Smooth transitions between screens
|
- **Hero Animations** - Smooth transitions between screens
|
||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
@@ -94,11 +104,10 @@ Ensemble supports multiple authentication methods:
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Music Assistant server (v2.7.0 or later recommended)
|
- Music Assistant server (v2.7.0 beta 20 or later)
|
||||||
- Network connectivity to your Music Assistant server
|
- Network connectivity to your Music Assistant server
|
||||||
- Android device (Android 5.0+)
|
- Android device (Android 5.0+)
|
||||||
|
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||||
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _showDebug = false;
|
bool _showDebug = false;
|
||||||
List<String> _debugLogs = [];
|
List<String> _debugLogs = [];
|
||||||
|
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
|
||||||
|
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -96,10 +98,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return 'http://$url';
|
return 'http://$url';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
|
||||||
|
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
|
||||||
|
return 'http://$url';
|
||||||
|
}
|
||||||
|
|
||||||
// For domain names, default to https://
|
// For domain names, default to https://
|
||||||
return 'https://$url';
|
return 'https://$url';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the URL is using HTTP (for security warning display)
|
||||||
|
bool _isHttpConnection(String url) {
|
||||||
|
final normalized = _normalizeServerUrl(url);
|
||||||
|
return normalized.startsWith('http://') && !normalized.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the URL is a Tailscale URL (safe for HTTP)
|
||||||
|
bool _isTailscaleUrl(String url) {
|
||||||
|
final normalized = url.trim().toLowerCase();
|
||||||
|
return normalized.contains('.ts.net');
|
||||||
|
}
|
||||||
|
|
||||||
String _buildServerUrl() {
|
String _buildServerUrl() {
|
||||||
// Normalize the base URL
|
// Normalize the base URL
|
||||||
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||||
@@ -149,6 +168,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_detectedAuthStrategy = null;
|
_detectedAuthStrategy = null;
|
||||||
_detectedAuthType = null;
|
_detectedAuthType = null;
|
||||||
|
_resolvedServerUrl = null;
|
||||||
|
_usedHttpFallback = false;
|
||||||
_debugLogs.clear();
|
_debugLogs.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +183,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
// Auto-detect authentication requirements
|
// Auto-detect authentication requirements
|
||||||
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
||||||
|
|
||||||
|
// Check if auth manager used a different URL (e.g., HTTP fallback)
|
||||||
|
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
|
||||||
|
if (resolvedUrl != null && resolvedUrl != serverUrl) {
|
||||||
|
_addDebugLog('URL resolved via fallback: $resolvedUrl');
|
||||||
|
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
||||||
|
|
||||||
if (strategy == null) {
|
if (strategy == null) {
|
||||||
@@ -175,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_detectedAuthStrategy = strategy;
|
_detectedAuthStrategy = strategy;
|
||||||
_detectedAuthType = _getAuthTypeName(strategy.name);
|
_detectedAuthType = _getAuthTypeName(strategy.name);
|
||||||
|
_resolvedServerUrl = resolvedUrl;
|
||||||
_isDetectingAuth = false;
|
_isDetectingAuth = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final serverUrl = _buildServerUrl();
|
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
|
||||||
|
final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
|
||||||
|
_addDebugLog('Using server URL for connection: $serverUrl');
|
||||||
final port = _portController.text.trim();
|
final port = _portController.text.trim();
|
||||||
|
|
||||||
// Validate port (if provided)
|
// Validate port (if provided)
|
||||||
@@ -635,6 +666,71 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// HTTP connection info banner
|
||||||
|
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.secondaryContainer.withOpacity(0.3)
|
||||||
|
: colorScheme.errorContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? null
|
||||||
|
: Border.all(color: colorScheme.error.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? Icons.vpn_lock_rounded
|
||||||
|
: Icons.warning_amber_rounded,
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.secondary
|
||||||
|
: colorScheme.error,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? 'Tailscale VPN Connection'
|
||||||
|
: 'Unencrypted Connection',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.onSecondaryContainer
|
||||||
|
: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? 'Using HTTP over Tailscale (encrypted tunnel)'
|
||||||
|
: _usedHttpFallback
|
||||||
|
? 'HTTPS failed, using HTTP fallback'
|
||||||
|
: 'HTTP connection - data is not encrypted',
|
||||||
|
style: TextStyle(
|
||||||
|
color: (_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.onSecondaryContainer
|
||||||
|
: colorScheme.onErrorContainer).withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Authentication fields (shown only if auth required)
|
// Authentication fields (shown only if auth required)
|
||||||
if (needsAuth) ...[
|
if (needsAuth) ...[
|
||||||
// Auth Server URL (only for Authelia)
|
// Auth Server URL (only for Authelia)
|
||||||
|
|||||||
@@ -31,11 +31,42 @@ class AuthManager {
|
|||||||
|
|
||||||
/// Auto-detect authentication requirements for a server
|
/// Auto-detect authentication requirements for a server
|
||||||
/// Returns null if server is unreachable or auth method unknown
|
/// Returns null if server is unreachable or auth method unknown
|
||||||
|
/// Also returns the final URL used (in case of HTTP fallback) via the callback
|
||||||
|
String? _lastSuccessfulUrl;
|
||||||
|
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
|
||||||
|
|
||||||
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
||||||
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
||||||
|
_lastSuccessfulUrl = null;
|
||||||
|
|
||||||
// Normalize URL
|
// Normalize URL
|
||||||
var baseUrl = serverUrl;
|
var baseUrl = _normalizeUrl(serverUrl);
|
||||||
|
_logger.log('Normalized URL: $baseUrl');
|
||||||
|
|
||||||
|
// Try detection with the initial URL
|
||||||
|
var result = await _tryDetectAuth(baseUrl);
|
||||||
|
|
||||||
|
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
|
||||||
|
if (result == null && baseUrl.startsWith('https://')) {
|
||||||
|
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
|
||||||
|
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
|
||||||
|
result = await _tryDetectAuth(httpUrl);
|
||||||
|
if (result != null) {
|
||||||
|
baseUrl = httpUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
_lastSuccessfulUrl = baseUrl;
|
||||||
|
_logger.log('✅ Auth detection successful with URL: $baseUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a server URL with appropriate protocol
|
||||||
|
String _normalizeUrl(String url) {
|
||||||
|
var baseUrl = url;
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
// Use http for local IPs, https for domains
|
// Use http for local IPs, https for domains
|
||||||
if (baseUrl.startsWith('192.') ||
|
if (baseUrl.startsWith('192.') ||
|
||||||
@@ -44,13 +75,22 @@ class AuthManager {
|
|||||||
baseUrl == 'localhost' ||
|
baseUrl == 'localhost' ||
|
||||||
baseUrl.startsWith('127.')) {
|
baseUrl.startsWith('127.')) {
|
||||||
baseUrl = 'http://$baseUrl';
|
baseUrl = 'http://$baseUrl';
|
||||||
|
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||||
|
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||||
|
baseUrl = 'http://$baseUrl';
|
||||||
|
_logger.log('🔗 Detected Tailscale URL, using HTTP');
|
||||||
} else {
|
} else {
|
||||||
baseUrl = 'https://$baseUrl';
|
baseUrl = 'https://$baseUrl';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to detect auth strategy for a given URL
|
||||||
|
/// Returns the strategy if successful, null if connection failed
|
||||||
|
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||||
// First, check if this is a Music Assistant server with native auth
|
// First, check if this is a Music Assistant server with native auth
|
||||||
_logger.log('Checking for Music Assistant native auth...');
|
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
|
||||||
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||||
if (maAuthResult != null) {
|
if (maAuthResult != null) {
|
||||||
if (maAuthResult == 'required') {
|
if (maAuthResult == 'required') {
|
||||||
@@ -124,7 +164,7 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('✗ Auth detection error: $e');
|
_logger.log('✗ Auth detection error: $e');
|
||||||
return null; // Return null instead of rethrow to show generic error
|
return null; // Return null to trigger fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('⚠️ Could not determine auth method');
|
_logger.log('⚠️ Could not determine auth method');
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.1.0-beta+16
|
version: 2.1.1-beta+17
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user