Compare commits
15
Commits
+51
-2
@@ -348,7 +348,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Startup widget that checks if user is logged in and auto-connects
|
/// 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 {
|
class AppStartup extends StatefulWidget {
|
||||||
const AppStartup({super.key});
|
const AppStartup({super.key});
|
||||||
|
|
||||||
@@ -361,9 +370,32 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
String? _savedServerUrl;
|
String? _savedServerUrl;
|
||||||
bool _connectionAttempted = false;
|
bool _connectionAttempted = false;
|
||||||
|
|
||||||
|
// Welcome system state - loaded FIRST before connection check
|
||||||
|
bool _settingsLoaded = false;
|
||||||
|
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadSettingsFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings BEFORE checking connection.
|
||||||
|
/// This ensures we know if this is a first-time user before transitioning.
|
||||||
|
Future<void> _loadSettingsFirst() async {
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// Now check connection
|
||||||
_checkAndConnect();
|
_checkAndConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,14 +420,30 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
// Just wait for it to complete or timeout
|
// Just wait for it to complete or timeout
|
||||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
_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)
|
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||||
// Check connection state periodically
|
// Check connection state periodically
|
||||||
for (var i = 0; i < 20; i++) {
|
for (var i = 0; i < 20; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 250));
|
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) {
|
if (provider.isConnected) {
|
||||||
_logger.log('🚀 AppStartup: Connection established');
|
_logger.log('🚀 AppStartup: Connection established');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (provider.connectionState == MAConnectionState.error) {
|
if (provider.connectionState == MAConnectionState.error) {
|
||||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||||
break;
|
break;
|
||||||
@@ -422,7 +470,8 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Show loading while checking settings or connecting
|
// Show loading while checking settings or connecting
|
||||||
if (!_connectionAttempted || _isConnecting) {
|
// CRITICAL: Don't transition until settings are loaded - this prevents the race
|
||||||
|
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
body: Center(
|
body: Center(
|
||||||
|
|||||||
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.isConnected) {
|
if (provider.isConnected) {
|
||||||
|
// For first-time users, wait for player selection so the welcome
|
||||||
|
// overlay can appear immediately without home screen flash.
|
||||||
|
// This matches the logic in AppStartup._checkAndConnect().
|
||||||
|
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||||
|
if (!hasCompletedOnboarding) {
|
||||||
|
// First-time user: wait for connection + player selection
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
if (provider.selectedPlayer != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to home screen
|
// Navigate to home screen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Ensure keyboard is closed before navigating
|
// Ensure keyboard is closed before navigating
|
||||||
|
|||||||
@@ -167,9 +167,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
// Hint system state
|
// Hint system state
|
||||||
|
// NOTE: AppStartup now handles the settings loading gate. It waits for:
|
||||||
|
// - First-time users: connected + player selected before showing HomeScreen
|
||||||
|
// - Returning users: connected before showing HomeScreen
|
||||||
|
// This eliminates the race condition that caused home screen flash.
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
bool _hasCompletedOnboarding = false; // First-use welcome screen
|
bool _hasCompletedOnboarding = false; // First-use welcome screen
|
||||||
bool _hintTriggered = false; // Prevent multiple triggers per session
|
bool _hintTriggered = false; // Prevent multiple triggers per session
|
||||||
|
bool _settingsLoaded = false; // True once settings have been loaded from storage
|
||||||
|
bool _miniPlayerHintsReady = false; // True once connected with player (can show hints)
|
||||||
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
|
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
// Welcome content fade-in animation
|
// Welcome content fade-in animation
|
||||||
@@ -234,11 +240,65 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
Future<void> _loadHintSettings() async {
|
Future<void> _loadHintSettings() async {
|
||||||
_showHints = await SettingsService.getShowHints();
|
_showHints = await SettingsService.getShowHints();
|
||||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||||
// Show welcome screen only on first use (not completed onboarding yet)
|
if (!mounted) return;
|
||||||
if (!_hasCompletedOnboarding && mounted && !_hintTriggered) {
|
|
||||||
|
setState(() {
|
||||||
|
_settingsLoaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If first-time user and already connected (AppStartup waited for us),
|
||||||
|
// start welcome screen immediately - no race condition possible
|
||||||
|
if (!_hasCompletedOnboarding && !_hintTriggered) {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
_startWelcomeScreen();
|
_startWelcomeScreen();
|
||||||
|
_startMiniPlayerBounce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// Check if we should enable mini player hints now that we have connection
|
||||||
|
_checkConnectionForMiniPlayerHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check connection state and start welcome screen if appropriate.
|
||||||
|
/// This is called from multiple places:
|
||||||
|
/// 1. didChangeDependencies() - when widget first builds
|
||||||
|
/// 2. _loadHintSettings() - when settings finish loading
|
||||||
|
/// 3. build() - when provider state changes (via post-frame callback)
|
||||||
|
void _checkConnectionForMiniPlayerHints() {
|
||||||
|
if (_hintTriggered || !mounted || !_settingsLoaded) return;
|
||||||
|
if (_hasCompletedOnboarding) return; // Not first-time user
|
||||||
|
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||||
|
// Connected with a player - start welcome screen now
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
|
_startWelcomeScreen();
|
||||||
|
// Start the bounce animation now that mini player is visible
|
||||||
|
_startMiniPlayerBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the bounce animation for mini player hints
|
||||||
|
void _startMiniPlayerBounce() {
|
||||||
|
if (!_isHintModeActive || !mounted) return;
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
|
||||||
|
// Repeat bounce every 2 seconds
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
if (_isHintModeActive && mounted) {
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the welcome screen with fade-in animation
|
/// Start the welcome screen with fade-in animation
|
||||||
void _startWelcomeScreen() {
|
void _startWelcomeScreen() {
|
||||||
@@ -250,24 +310,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
_isHintModeActive = true;
|
_isHintModeActive = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fade in welcome content
|
// Fade in welcome content (logo + title)
|
||||||
_welcomeFadeController.forward();
|
_welcomeFadeController.forward();
|
||||||
|
|
||||||
// Start bounce animation after fade completes
|
// Mini player bounce is started separately once connected via _startMiniPlayerBounce()
|
||||||
_welcomeFadeController.addStatusListener((status) {
|
|
||||||
if (status == AnimationStatus.completed && mounted) {
|
|
||||||
_doubleBounceController.reset();
|
|
||||||
_doubleBounceController.forward();
|
|
||||||
|
|
||||||
// Repeat bounce every 2 seconds
|
|
||||||
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
|
||||||
if (_isHintModeActive && mounted) {
|
|
||||||
_doubleBounceController.reset();
|
|
||||||
_doubleBounceController.forward();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -362,6 +408,36 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
// Watch provider to trigger rebuilds when connection state changes
|
||||||
|
final provider = context.watch<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Compute whether this is a first-time user ready for welcome screen.
|
||||||
|
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||||
|
final isFirstTimeUserReady = _settingsLoaded &&
|
||||||
|
!_hasCompletedOnboarding &&
|
||||||
|
provider.isConnected &&
|
||||||
|
provider.selectedPlayer != null;
|
||||||
|
|
||||||
|
// Show welcome backdrop if:
|
||||||
|
// 1. Already in hint mode (_isHintModeActive), OR
|
||||||
|
// 2. First-time user ready but hint mode not started yet (!_hintTriggered)
|
||||||
|
// This ensures backdrop appears IMMEDIATELY when HomeScreen renders,
|
||||||
|
// preventing the flash. Welcome content fades in after via animation.
|
||||||
|
final shouldShowWelcomeBackdrop = _isHintModeActive ||
|
||||||
|
(isFirstTimeUserReady && !_hintTriggered);
|
||||||
|
|
||||||
|
// Trigger hint mode if conditions are met (for animations, bounce, etc)
|
||||||
|
if (isFirstTimeUserReady && !_hintTriggered) {
|
||||||
|
// Schedule for post-frame to avoid setState during build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && !_hintTriggered) {
|
||||||
|
_miniPlayerHintsReady = true;
|
||||||
|
_startWelcomeScreen();
|
||||||
|
_startMiniPlayerBounce();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle back gesture at top level - dismiss hint mode or device list if visible
|
// Handle back gesture at top level - dismiss hint mode or device list if visible
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !_isRevealVisible && !_isHintModeActive,
|
canPop: !_isRevealVisible && !_isHintModeActive,
|
||||||
@@ -383,6 +459,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
// Global persistent bottom navigation bar - positioned at bottom
|
// 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 (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -497,8 +577,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Blur backdrop for hint/welcome mode - animated fade from solid to semi-transparent
|
// Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
|
||||||
if (_isHintModeActive)
|
// Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
|
||||||
|
// preventing the 1-frame gap where home screen would be visible.
|
||||||
|
if (shouldShowWelcomeBackdrop)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
// Hold solid for 2s, then fade to 0.5 over 1s
|
// Hold solid for 2s, then fade to 0.5 over 1s
|
||||||
@@ -509,7 +591,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
return BackdropFilter(
|
return BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: colorScheme.surface.withOpacity(opacity),
|
// Use same dark color as connection screen for seamless transition
|
||||||
|
color: const Color(0xFF1a1a1a).withOpacity(opacity),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -553,8 +636,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom section: Hint text and Skip button (positioned relative to mini player)
|
// Bottom section: Hint text and Skip button (only show once mini player is visible)
|
||||||
if (_isHintModeActive)
|
if (_isHintModeActive && _miniPlayerHintsReady)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
|
|||||||
+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.6.0-beta+28
|
version: 2.6.1-beta+29
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user