Add 6 predefined accent colors (purple, blue, teal, deep orange, pink, green) that users can choose when Material You is disabled. The colors are displayed as circular swatches above the Material You toggle and are greyed out when Material You is enabled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
15 KiB
Dart
455 lines
15 KiB
Dart
import 'dart:async';
|
|
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/locale_provider.dart';
|
|
import 'providers/music_assistant_provider.dart';
|
|
import 'providers/navigation_provider.dart';
|
|
import 'screens/home_screen.dart';
|
|
import 'screens/login_screen.dart';
|
|
import 'services/settings_service.dart';
|
|
import 'services/database_service.dart';
|
|
import 'services/profile_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 '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 local database
|
|
await DatabaseService.instance.initialize();
|
|
_logger.log('💾 Database initialized');
|
|
|
|
// Migrate existing ownerName to profile (one-time for existing users)
|
|
await ProfileService.instance.migrateFromOwnerName();
|
|
|
|
// 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 system UI overlay style
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
const SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: Brightness.light,
|
|
systemNavigationBarColor: Color(0xFF1a1a1a),
|
|
systemNavigationBarIconBrightness: Brightness.light,
|
|
),
|
|
);
|
|
|
|
runApp(const MusicAssistantApp());
|
|
}
|
|
|
|
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;
|
|
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();
|
|
_musicProvider = MusicAssistantProvider();
|
|
_themeProvider = ThemeProvider();
|
|
_localeProvider = LocaleProvider();
|
|
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 > 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
|
|
}
|
|
|
|
// Then check expanded player
|
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
|
GlobalPlayerOverlay.collapsePlayer();
|
|
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: [
|
|
ChangeNotifierProvider.value(value: _musicProvider),
|
|
ChangeNotifierProvider.value(value: _themeProvider),
|
|
ChangeNotifierProvider.value(value: _localeProvider),
|
|
],
|
|
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
|
|
final (light, dark) = snapshot.data!;
|
|
lightColorScheme = light;
|
|
darkColorScheme = dark;
|
|
} 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,
|
|
themeMode: themeProvider.themeMode,
|
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
|
builder: (context, child) {
|
|
// Wrap entire app with global player overlay
|
|
return 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.background
|
|
: widget.lightColorScheme.background,
|
|
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
|
|
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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_checkAndConnect();
|
|
}
|
|
|
|
Future<void> _checkAndConnect() async {
|
|
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');
|
|
|
|
// 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 (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;
|
|
_connectionAttempted = true;
|
|
});
|
|
}
|
|
} else {
|
|
setState(() {
|
|
_connectionAttempted = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Show loading while checking settings or connecting
|
|
if (!_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();
|
|
}
|
|
}
|