733 lines
27 KiB
Dart
733 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'l10n/app_localizations.dart';
|
|
import 'providers/connection_provider.dart';
|
|
import 'providers/library_provider.dart';
|
|
import 'providers/locale_provider.dart';
|
|
import 'providers/local_player_provider.dart';
|
|
import 'providers/music_assistant_provider.dart';
|
|
import 'providers/navigation_provider.dart';
|
|
import 'providers/player_provider.dart';
|
|
import 'providers/sleep_timer_provider.dart';
|
|
import 'providers/ui_state_provider.dart';
|
|
import 'screens/home_screen.dart';
|
|
import 'screens/login_screen.dart';
|
|
import 'services/cache_service.dart';
|
|
import 'services/settings_service.dart';
|
|
import 'services/database_service.dart';
|
|
import 'services/image_helper_service.dart';
|
|
import 'services/position_tracker.dart';
|
|
import 'services/queue_manager_service.dart';
|
|
import 'services/profile_service.dart';
|
|
import 'services/provider_filter_service.dart';
|
|
import 'services/sync_service.dart';
|
|
import 'services/audio/massiv_audio_handler.dart';
|
|
import 'services/auth/auth_manager.dart';
|
|
import 'services/debug_logger.dart';
|
|
import 'services/hardware_volume_service.dart';
|
|
import 'services/music_assistant_api.dart' show MAConnectionState;
|
|
import 'theme/theme_provider.dart';
|
|
import 'theme/app_theme.dart';
|
|
import 'theme/system_theme_helper.dart';
|
|
import 'utils/player_sync_state.dart';
|
|
import 'widgets/global_player_overlay.dart';
|
|
|
|
// Global audio handler instance
|
|
late MassivAudioHandler audioHandler;
|
|
|
|
// Global debug logger instance
|
|
final _logger = DebugLogger();
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Initialize SharedPreferences cache early for performance
|
|
await SettingsService.initialize();
|
|
_logger.log('⚡ SharedPreferences cached');
|
|
|
|
// Initialize local database
|
|
await DatabaseService.instance.initialize();
|
|
_logger.log('💾 Database initialized');
|
|
|
|
// Migrate existing ownerName to profile (one-time for existing users)
|
|
await ProfileService.instance.migrateFromOwnerName();
|
|
|
|
// Migrate credentials to secure storage (one-time for existing users)
|
|
await SettingsService.migrateToSecureStorage();
|
|
_logger.log('🔐 Secure storage migration complete');
|
|
|
|
// Load library from cache for instant startup
|
|
await SyncService.instance.loadFromCache();
|
|
_logger.log('📦 Library cache loaded');
|
|
|
|
// Create auth manager for streaming headers
|
|
final authManager = AuthManager();
|
|
|
|
// Initialize audio_service with our custom handler
|
|
audioHandler = await AudioService.init(
|
|
builder: () => MassivAudioHandler(authManager: authManager),
|
|
config: const AudioServiceConfig(
|
|
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
|
androidNotificationChannelName: 'Ensemble Audio',
|
|
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
|
androidNotificationIcon: 'drawable/ic_notification',
|
|
androidShowNotificationBadge: false,
|
|
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
|
),
|
|
);
|
|
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
|
|
|
// Set preferred orientations
|
|
SystemChrome.setPreferredOrientations([
|
|
DeviceOrientation.portraitUp,
|
|
DeviceOrientation.portraitDown,
|
|
]);
|
|
|
|
// Set initial system UI overlay style based on platform brightness
|
|
// SystemUIWrapper will update this dynamically when theme changes
|
|
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
|
final isDark = platformBrightness == Brightness.dark;
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
),
|
|
);
|
|
|
|
// Set up Flutter error boundary to catch and log widget build errors
|
|
FlutterError.onError = (FlutterErrorDetails details) {
|
|
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
|
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
|
// Still report to Flutter's default handler in debug mode
|
|
FlutterError.presentError(details);
|
|
};
|
|
|
|
// Catch errors from the platform dispatcher (platform channel errors)
|
|
PlatformDispatcher.instance.onError = (error, stack) {
|
|
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
|
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
|
return true; // Handled
|
|
};
|
|
|
|
// Wrap app in zone to catch async errors
|
|
runZonedGuarded(
|
|
() => runApp(const MusicAssistantApp()),
|
|
(error, stackTrace) {
|
|
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
|
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
|
},
|
|
);
|
|
}
|
|
|
|
class MusicAssistantApp extends StatefulWidget {
|
|
const MusicAssistantApp({super.key});
|
|
|
|
@override
|
|
State<MusicAssistantApp> createState() => _MusicAssistantAppState();
|
|
}
|
|
|
|
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
|
late MusicAssistantProvider _musicProvider;
|
|
late ThemeProvider _themeProvider;
|
|
late LocaleProvider _localeProvider;
|
|
|
|
// New multi-provider architecture
|
|
late ConnectionProvider _connectionProvider;
|
|
late UIStateProvider _uiStateProvider;
|
|
late LibraryProvider _libraryProvider;
|
|
late PlayerProvider _playerProvider;
|
|
late LocalPlayerProvider _localPlayerProvider;
|
|
late SleepTimerProvider _sleepTimerProvider;
|
|
late NavigationProvider _navigationProvider;
|
|
|
|
// Shared services
|
|
late final CacheService _cacheService = CacheService();
|
|
late final PositionTracker _positionTracker = PositionTracker();
|
|
late ImageHelperService _imageHelper;
|
|
|
|
final _hardwareVolumeService = HardwareVolumeService();
|
|
StreamSubscription? _volumeUpSub;
|
|
StreamSubscription? _volumeDownSub;
|
|
String? _lastSelectedPlayerId;
|
|
String? _builtinPlayerId;
|
|
|
|
// Volume step size (percentage points per button press)
|
|
static const int _volumeStep = 5;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_themeProvider = ThemeProvider();
|
|
_localeProvider = LocaleProvider();
|
|
_navigationProvider = NavigationProvider();
|
|
_sleepTimerProvider = SleepTimerProvider();
|
|
|
|
// Initialize connection provider first (others depend on it)
|
|
_connectionProvider = ConnectionProvider();
|
|
_uiStateProvider = UIStateProvider();
|
|
|
|
// Initialize shared image helper service
|
|
_imageHelper = ImageHelperService(
|
|
api: _connectionProvider.api,
|
|
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
|
);
|
|
|
|
// Initialize library provider (needs API from connection)
|
|
_libraryProvider = LibraryProvider(
|
|
api: _connectionProvider.api,
|
|
logger: DebugLogger(),
|
|
cacheService: _cacheService,
|
|
settings: null, // Will use static methods
|
|
providerFilterService: ProviderFilterService(
|
|
providerFilter: _connectionProvider.providerFilter,
|
|
playerFilter: _connectionProvider.playerFilter,
|
|
availableMusicProviders: [],
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [],
|
|
podcasts: [],
|
|
radioStations: [],
|
|
),
|
|
);
|
|
|
|
// Initialize local player provider
|
|
_localPlayerProvider = LocalPlayerProvider(
|
|
audioHandler: audioHandler,
|
|
settings: null, // Will use static methods
|
|
);
|
|
|
|
// Initialize player provider (depends on several others)
|
|
// Note: queueManager will be set later to avoid circular dependency
|
|
_playerProvider = PlayerProvider(
|
|
api: _connectionProvider.api,
|
|
logger: DebugLogger(),
|
|
cacheService: _cacheService,
|
|
settings: null, // Will use static methods
|
|
positionTracker: _positionTracker,
|
|
queueManager: null, // Will be set after initialization
|
|
sleepTimerProvider: _sleepTimerProvider,
|
|
imageHelper: _imageHelper,
|
|
playerSyncState: PlayerSyncState(
|
|
availablePlayers: [],
|
|
castToSendspinIdMap: {},
|
|
),
|
|
audioHandler: audioHandler,
|
|
);
|
|
|
|
// Create queue manager with functions that access provider state
|
|
final queueManager = QueueManagerService(
|
|
api: _connectionProvider.api,
|
|
database: DatabaseService.instance,
|
|
logger: DebugLogger(),
|
|
getAvailablePlayers: () => _playerProvider.availablePlayers,
|
|
getCastToSendspinMap: () => _playerProvider.castToSendspinIdMap,
|
|
playTracks: (playerId, tracks, {int? startIndex, bool? clearQueue}) async {
|
|
await _connectionProvider.api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue ?? true);
|
|
},
|
|
);
|
|
|
|
// Set the queue manager after creation (breaks circular dependency)
|
|
_playerProvider.queueManager = queueManager;
|
|
|
|
// Initialize main provider (facade mode - sub-providers not yet wired)
|
|
// Sub-providers have null API at construction time and no update mechanism,
|
|
// so keep facade using its own internal logic until wiring is complete.
|
|
_musicProvider = MusicAssistantProvider();
|
|
|
|
// Wire up player provider callbacks
|
|
_playerProvider.onPlayerChanged = () {
|
|
setState(() {}); // Trigger rebuild on player change
|
|
};
|
|
_playerProvider.onTrackChanged = () {
|
|
setState(() {}); // Trigger rebuild on track change
|
|
};
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_initHardwareVolumeControl();
|
|
// Listen to player selection changes to toggle volume interception
|
|
_musicProvider.addListener(_onProviderChanged);
|
|
}
|
|
|
|
Future<void> _initHardwareVolumeControl() async {
|
|
try {
|
|
_builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
|
await _hardwareVolumeService.init();
|
|
|
|
_volumeUpSub = _hardwareVolumeService.onVolumeUp.listen((_) {
|
|
_adjustVolume(_volumeStep);
|
|
});
|
|
|
|
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
|
|
_adjustVolume(-_volumeStep);
|
|
});
|
|
} catch (e, stack) {
|
|
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
/// Called when provider state changes - check if selected player changed
|
|
void _onProviderChanged() {
|
|
final currentPlayerId = _musicProvider.selectedPlayer?.playerId;
|
|
if (currentPlayerId != _lastSelectedPlayerId) {
|
|
_lastSelectedPlayerId = currentPlayerId;
|
|
_updateVolumeInterception();
|
|
}
|
|
}
|
|
|
|
/// Enable/disable volume button interception based on selected player.
|
|
Future<void> _updateVolumeInterception() async {
|
|
final isBuiltinPlayer = _builtinPlayerId != null &&
|
|
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
|
|
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
|
|
}
|
|
|
|
Future<void> _adjustVolume(int delta) async {
|
|
final player = _musicProvider.selectedPlayer;
|
|
if (player == null) return;
|
|
|
|
final newVolume = (player.volume + delta).clamp(0, 100);
|
|
if (newVolume != player.volume) {
|
|
try {
|
|
await _musicProvider.setVolume(player.playerId, newVolume);
|
|
} catch (e) {
|
|
_logger.error('Hardware volume adjustment failed', context: 'VolumeControl', error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_musicProvider.removeListener(_onProviderChanged);
|
|
_volumeUpSub?.cancel();
|
|
_volumeDownSub?.cancel();
|
|
_hardwareVolumeService.dispose();
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Future<bool> didPopRoute() async {
|
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
|
// Priority order: device list > queue panel > expanded player > normal navigation
|
|
|
|
// Device list has highest priority - dismiss it first
|
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
|
GlobalPlayerOverlay.dismissPlayerReveal();
|
|
return true; // We handled it, don't let Navigator process it
|
|
}
|
|
|
|
// Queue panel second - close it before collapsing player
|
|
// Use target state (not animation value) to handle rapid open-close timing
|
|
// Use withHaptic: false because Android back gesture provides system haptic
|
|
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
|
|
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
|
|
return true; // We handled it, don't let Navigator process it
|
|
}
|
|
|
|
// Then check expanded player
|
|
// No haptic - Android back gesture provides its own
|
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
|
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
|
return true; // We handled it, don't let Navigator process it
|
|
}
|
|
|
|
// Let Navigator handle the back gesture normally
|
|
return super.didPopRoute();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
|
|
if (state == AppLifecycleState.resumed) {
|
|
// App came back to foreground - check connection and reconnect if needed
|
|
_logger.log('📱 App resumed - checking WebSocket connection...');
|
|
_musicProvider.checkAndReconnect();
|
|
} else if (state == AppLifecycleState.paused) {
|
|
_logger.log('📱 App paused (backgrounded)');
|
|
} else if (state == AppLifecycleState.detached) {
|
|
_logger.log('📱 App detached (being destroyed)');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MultiProvider(
|
|
providers: [
|
|
// Core facade provider (will be removed after migration)
|
|
ChangeNotifierProvider.value(value: _musicProvider),
|
|
|
|
// Theme providers
|
|
ChangeNotifierProvider.value(value: _themeProvider),
|
|
ChangeNotifierProvider.value(value: _localeProvider),
|
|
ChangeNotifierProvider.value(value: _navigationProvider),
|
|
|
|
// New multi-provider architecture (available for gradual migration)
|
|
ChangeNotifierProvider.value(value: _connectionProvider),
|
|
ChangeNotifierProvider.value(value: _uiStateProvider),
|
|
ChangeNotifierProvider.value(value: _libraryProvider),
|
|
ChangeNotifierProvider.value(value: _playerProvider),
|
|
ChangeNotifierProvider.value(value: _localPlayerProvider),
|
|
ChangeNotifierProvider.value(value: _sleepTimerProvider),
|
|
],
|
|
child: Consumer2<ThemeProvider, LocaleProvider>(
|
|
builder: (context, themeProvider, localeProvider, _) {
|
|
return FutureBuilder<(ColorScheme, ColorScheme)?>(
|
|
future: themeProvider.useMaterialTheme
|
|
? SystemThemeHelper.getSystemColorSchemes()
|
|
: null,
|
|
builder: (context, snapshot) {
|
|
// Determine which color schemes to use
|
|
ColorScheme? lightColorScheme;
|
|
ColorScheme? darkColorScheme;
|
|
|
|
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
|
// Use system color schemes, but override background to match app design
|
|
// Material You doesn't set background consistently, causing black screen issues
|
|
final (light, dark) = snapshot.data!;
|
|
lightColorScheme = light.copyWith(
|
|
surface: light.surface,
|
|
background: const Color(0xFFFAFAFA), // App's preferred light background
|
|
);
|
|
darkColorScheme = dark.copyWith(
|
|
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
|
|
background: const Color(0xFF1a1a1a), // App's preferred dark background
|
|
);
|
|
} else {
|
|
// Use custom color from theme provider
|
|
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
|
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
|
|
}
|
|
|
|
return SystemUIWrapper(
|
|
themeMode: themeProvider.themeMode,
|
|
lightColorScheme: lightColorScheme,
|
|
darkColorScheme: darkColorScheme,
|
|
child: MaterialApp(
|
|
navigatorKey: navigationProvider.navigatorKey,
|
|
title: 'Ensemble',
|
|
debugShowCheckedModeBanner: false,
|
|
// Localization
|
|
localizationsDelegates: const [
|
|
S.delegate,
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
supportedLocales: S.supportedLocales,
|
|
locale: localeProvider.locale,
|
|
// Fallback to English when locale is not supported
|
|
localeListResolutionCallback: (locales, supportedLocales) {
|
|
// If user has set a specific locale, try to use it
|
|
if (localeProvider.locale != null) {
|
|
for (final supported in supportedLocales) {
|
|
if (supported.languageCode == localeProvider.locale!.languageCode) {
|
|
return supported;
|
|
}
|
|
}
|
|
}
|
|
// Try to match system locales
|
|
if (locales != null) {
|
|
for (final locale in locales) {
|
|
for (final supported in supportedLocales) {
|
|
if (supported.languageCode == locale.languageCode) {
|
|
return supported;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Fallback to English (not German)
|
|
return const Locale('en');
|
|
},
|
|
themeMode: themeProvider.themeMode,
|
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
|
builder: (context, child) {
|
|
// Wrap entire app with global player overlay.
|
|
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
|
return Overlay(
|
|
initialEntries: [
|
|
OverlayEntry(
|
|
builder: (context) => GlobalPlayerOverlay(
|
|
child: child ?? const SizedBox.shrink(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
home: const AppStartup(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Widget that manages system UI overlay style updates efficiently
|
|
/// Only updates when theme properties actually change
|
|
class SystemUIWrapper extends StatefulWidget {
|
|
final Widget child;
|
|
final ThemeMode themeMode;
|
|
final ColorScheme lightColorScheme;
|
|
final ColorScheme darkColorScheme;
|
|
|
|
const SystemUIWrapper({
|
|
super.key,
|
|
required this.child,
|
|
required this.themeMode,
|
|
required this.lightColorScheme,
|
|
required this.darkColorScheme,
|
|
});
|
|
|
|
@override
|
|
State<SystemUIWrapper> createState() => _SystemUIWrapperState();
|
|
}
|
|
|
|
class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
|
ThemeMode? _previousThemeMode;
|
|
Brightness? _previousPlatformBrightness;
|
|
ColorScheme? _previousLightColorScheme;
|
|
ColorScheme? _previousDarkColorScheme;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_updateSystemUIIfNeeded();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(SystemUIWrapper oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_updateSystemUIIfNeeded();
|
|
}
|
|
|
|
void _updateSystemUIIfNeeded() {
|
|
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
|
|
|
// Check if any relevant properties have changed
|
|
final themeChanged = _previousThemeMode != widget.themeMode;
|
|
final platformBrightnessChanged = _previousPlatformBrightness != platformBrightness;
|
|
final lightColorSchemeChanged = _previousLightColorScheme != widget.lightColorScheme;
|
|
final darkColorSchemeChanged = _previousDarkColorScheme != widget.darkColorScheme;
|
|
|
|
if (themeChanged || platformBrightnessChanged || lightColorSchemeChanged || darkColorSchemeChanged) {
|
|
// Determine if we should use dark theme
|
|
final isDark = widget.themeMode == ThemeMode.dark ||
|
|
(widget.themeMode == ThemeMode.system && platformBrightness == Brightness.dark);
|
|
|
|
// Update system UI overlay style
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
systemNavigationBarColor: isDark
|
|
? widget.darkColorScheme.surface
|
|
: widget.lightColorScheme.surface,
|
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
),
|
|
);
|
|
|
|
// Update cached values
|
|
_previousThemeMode = widget.themeMode;
|
|
_previousPlatformBrightness = platformBrightness;
|
|
_previousLightColorScheme = widget.lightColorScheme;
|
|
_previousDarkColorScheme = widget.darkColorScheme;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return widget.child;
|
|
}
|
|
}
|
|
|
|
/// Startup widget that checks if user is logged in and auto-connects.
|
|
///
|
|
/// CRITICAL: This widget is the single source of truth for the startup transition.
|
|
/// It loads settings FIRST (before connection), then waits for the appropriate
|
|
/// conditions before showing HomeScreen. This eliminates the race condition between
|
|
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
|
|
///
|
|
/// For first-time users: Holds dark screen until connected + player selected,
|
|
/// so the welcome overlay can appear seamlessly.
|
|
/// For returning users: Transitions as soon as connection is established.
|
|
class AppStartup extends StatefulWidget {
|
|
const AppStartup({super.key});
|
|
|
|
@override
|
|
State<AppStartup> createState() => _AppStartupState();
|
|
}
|
|
|
|
class _AppStartupState extends State<AppStartup> {
|
|
bool _isConnecting = false;
|
|
String? _savedServerUrl;
|
|
bool _connectionAttempted = false;
|
|
|
|
// Welcome system state - loaded FIRST before connection check
|
|
bool _settingsLoaded = false;
|
|
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
try {
|
|
_loadSettingsFirst();
|
|
} catch (e) {
|
|
_logger.error('AppStartup init error: $e');
|
|
// Fallback - mark as loaded so app can continue
|
|
if (mounted) {
|
|
setState(() {
|
|
_settingsLoaded = true;
|
|
_hasCompletedOnboarding = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load settings BEFORE checking connection.
|
|
/// This ensures we know if this is a first-time user before transitioning.
|
|
Future<void> _loadSettingsFirst() async {
|
|
try {
|
|
// Load onboarding state first - this is critical for determining
|
|
// when to transition to HomeScreen
|
|
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_settingsLoaded = true;
|
|
});
|
|
|
|
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
|
} catch (e) {
|
|
_logger.error('AppStartup settings load error: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_settingsLoaded = true;
|
|
_hasCompletedOnboarding = true; // Default to true on error
|
|
});
|
|
}
|
|
}
|
|
|
|
// Now check connection
|
|
_checkAndConnect();
|
|
}
|
|
|
|
Future<void> _checkAndConnect() async {
|
|
try {
|
|
final serverUrl = await SettingsService.getServerUrl();
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_savedServerUrl = serverUrl;
|
|
});
|
|
|
|
// If we have a saved server URL, attempt auto-connection
|
|
if (serverUrl != null && serverUrl.isNotEmpty) {
|
|
setState(() {
|
|
_isConnecting = true;
|
|
});
|
|
|
|
final provider = context.read<MusicAssistantProvider>();
|
|
|
|
// Connection is handled by MusicAssistantProvider._initialize()
|
|
// Just wait for it to complete or timeout
|
|
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
|
|
|
// For first-time users, we need to wait for both connection AND player selection
|
|
// so the welcome overlay can appear immediately without home screen flash.
|
|
// For returning users, just wait for connection.
|
|
final needsWelcome = !_hasCompletedOnboarding;
|
|
|
|
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
|
// Check connection state periodically
|
|
for (var i = 0; i < 20; i++) {
|
|
await Future.delayed(const Duration(milliseconds: 250));
|
|
|
|
if (needsWelcome) {
|
|
// First-time user: wait for connection + player selection
|
|
if (provider.isConnected && provider.selectedPlayer != null) {
|
|
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
|
break;
|
|
}
|
|
} else {
|
|
// Returning user: just wait for connection
|
|
if (provider.isConnected) {
|
|
_logger.log('🚀 AppStartup: Connection established');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (provider.connectionState == MAConnectionState.error) {
|
|
_logger.log('🚀 AppStartup: Connection failed with error');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!provider.isConnected) {
|
|
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isConnecting = false;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_logger.error('AppStartup connection error: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_isConnecting = false;
|
|
});
|
|
}
|
|
}
|
|
_connectionAttempted = true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Show loading while checking settings or connecting
|
|
// CRITICAL: Don't transition until settings are loaded - this prevents the race
|
|
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF1a1a1a),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const CircularProgressIndicator(color: Colors.white),
|
|
if (_isConnecting) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Connecting...',
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.7),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// If server URL is saved, go to home screen
|
|
// Otherwise, show login screen
|
|
final hasServerUrl = _savedServerUrl != null && _savedServerUrl!.isNotEmpty;
|
|
return hasServerUrl ? const HomeScreen() : const LoginScreen();
|
|
}
|
|
}
|