Files

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();
}
}