From 19ab6a21b9b92b4c0d866a0e88c35277bf46df7f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:58:39 +0100 Subject: [PATCH] feat: settings page --- lib/main.dart | 22 +- lib/providers/theme_provider.dart | 99 +++ lib/screens/about_screen.dart | 82 +-- lib/screens/licenses_screen.dart | 223 +++++++ lib/screens/main_screen.dart | 8 + lib/screens/settings_screen.dart | 563 ++++++++++++++++++ lib/services/keyboard_shortcuts_service.dart | 371 ++++++++++++ lib/services/settings_service.dart | 536 +++++++++++++++++ lib/utils/desktop_window_padding.dart | 22 +- lib/widgets/hotkey_recorder_widget.dart | 115 ++++ lib/widgets/plex_video_controls.dart | 150 ++--- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 13 + pubspec.lock | 48 ++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 2071 insertions(+), 193 deletions(-) create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/screens/licenses_screen.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/services/keyboard_shortcuts_service.dart create mode 100644 lib/services/settings_service.dart create mode 100644 lib/widgets/hotkey_recorder_widget.dart diff --git a/lib/main.dart b/lib/main.dart index 799e4e71..ea4ef4d5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,10 +13,10 @@ import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; +import 'providers/theme_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; -import 'theme/mono_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -65,14 +65,20 @@ class MainApp extends StatelessWidget { ChangeNotifierProvider( create: (context) => UserProfileProvider()..initialize(), ), + ChangeNotifierProvider(create: (context) => ThemeProvider()), ], - child: MaterialApp( - title: 'Plezy', - debugShowCheckedModeBanner: false, - theme: monoTheme(dark: false), - darkTheme: monoTheme(dark: true), - navigatorObservers: [routeObserver], - home: const SetupScreen(), + child: Consumer( + builder: (context, themeProvider, child) { + return MaterialApp( + title: 'Plezy', + debugShowCheckedModeBanner: false, + theme: themeProvider.lightTheme, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.materialThemeMode, + navigatorObservers: [routeObserver], + home: const SetupScreen(), + ); + }, ), ); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 00000000..3fb984ab --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart' as settings; +import '../theme/mono_theme.dart'; + +class ThemeProvider extends ChangeNotifier { + late settings.SettingsService _settingsService; + settings.ThemeMode _themeMode = settings.ThemeMode.system; + late Brightness _systemBrightness; + + ThemeProvider() { + _systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; + _initializeSettings(); + + // Listen to system theme changes + WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () { + _systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; + if (_themeMode == settings.ThemeMode.system) { + notifyListeners(); + } + }; + } + + Future _initializeSettings() async { + _settingsService = await settings.SettingsService.getInstance(); + _themeMode = _settingsService.getThemeMode(); + notifyListeners(); + } + + settings.ThemeMode get themeMode => _themeMode; + + ThemeData get lightTheme => monoTheme(dark: false); + ThemeData get darkTheme => monoTheme(dark: true); + + ThemeMode get materialThemeMode { + switch (_themeMode) { + case settings.ThemeMode.light: + return ThemeMode.light; + case settings.ThemeMode.dark: + return ThemeMode.dark; + case settings.ThemeMode.system: + return ThemeMode.system; + } + } + + bool get isDarkMode { + switch (_themeMode) { + case settings.ThemeMode.light: + return false; + case settings.ThemeMode.dark: + return true; + case settings.ThemeMode.system: + return _systemBrightness == Brightness.dark; + } + } + + Future setThemeMode(settings.ThemeMode mode) async { + if (_themeMode != mode) { + _themeMode = mode; + await _settingsService.setThemeMode(mode); + notifyListeners(); + } + } + + String get themeModeDisplayName { + switch (_themeMode) { + case settings.ThemeMode.light: + return 'Light'; + case settings.ThemeMode.dark: + return 'Dark'; + case settings.ThemeMode.system: + return 'System'; + } + } + + IconData get themeModeIcon { + switch (_themeMode) { + case settings.ThemeMode.light: + return Icons.light_mode; + case settings.ThemeMode.dark: + return Icons.dark_mode; + case settings.ThemeMode.system: + return Icons.brightness_auto; + } + } + + void toggleTheme() { + switch (_themeMode) { + case settings.ThemeMode.system: + setThemeMode(settings.ThemeMode.light); + break; + case settings.ThemeMode.light: + setThemeMode(settings.ThemeMode.dark); + break; + case settings.ThemeMode.dark: + setThemeMode(settings.ThemeMode.system); + break; + } + } +} \ No newline at end of file diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 94207956..00fbb90a 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../widgets/desktop_app_bar.dart'; +import 'licenses_screen.dart'; class AboutScreen extends StatefulWidget { const AboutScreen({super.key}); @@ -81,61 +82,16 @@ class _AboutScreenState extends State { ), trailing: const Icon(Icons.chevron_right), onTap: () { - showLicensePage( - context: context, - applicationName: appName, - applicationVersion: appVersion, - applicationIcon: Image.asset( - 'assets/plezy.png', - width: 48, - height: 48, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const LicensesScreen(), ), ); }, ), ), - const SizedBox(height: 16), - - // Key Dependencies - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Key Dependencies', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - _buildDependencyItem('http', 'HTTP networking'), - _buildDependencyItem('dio', 'Advanced HTTP client'), - _buildDependencyItem( - 'cached_network_image', - 'Image caching', - ), - _buildDependencyItem('media_kit', 'Video playback'), - _buildDependencyItem( - 'shared_preferences', - 'Local storage', - ), - _buildDependencyItem('xml', 'XML parsing'), - _buildDependencyItem('url_launcher', 'External links'), - _buildDependencyItem( - 'window_manager', - 'Desktop window management', - ), - _buildDependencyItem( - 'macos_window_utils', - 'macOS window controls', - ), - _buildDependencyItem('logger', 'Logging'), - ], - ), - ), - ), const SizedBox(height: 24), ]), @@ -146,32 +102,4 @@ class _AboutScreenState extends State { ); } - Widget _buildDependencyItem(String name, String description) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - const Icon(Icons.circle, size: 6), - const SizedBox(width: 8), - Expanded( - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: name, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - TextSpan( - text: ' - $description', - style: const TextStyle(color: Colors.grey), - ), - ], - style: const TextStyle(fontSize: 13, color: Colors.white), - ), - ), - ), - ], - ), - ); - } } diff --git a/lib/screens/licenses_screen.dart b/lib/screens/licenses_screen.dart new file mode 100644 index 00000000..4f17fc51 --- /dev/null +++ b/lib/screens/licenses_screen.dart @@ -0,0 +1,223 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../widgets/desktop_app_bar.dart'; + +class MergedLicenseEntry { + final String packageName; + final List licenseEntries; + final Set allPackageNames; + + MergedLicenseEntry({ + required this.packageName, + required this.licenseEntries, + required this.allPackageNames, + }); +} + +class LicensesScreen extends StatefulWidget { + const LicensesScreen({super.key}); + + @override + State createState() => _LicensesScreenState(); +} + +class _LicensesScreenState extends State { + List _mergedLicenses = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadLicenses(); + } + + Future _loadLicenses() async { + final licenseMap = >{}; + final allPackageNames = >{}; + + await for (final license in LicenseRegistry.licenses) { + for (final packageName in license.packages) { + if (!licenseMap.containsKey(packageName)) { + licenseMap[packageName] = []; + allPackageNames[packageName] = {}; + } + licenseMap[packageName]!.add(license); + allPackageNames[packageName]!.addAll(license.packages); + } + } + + final mergedLicenses = licenseMap.entries.map((entry) { + return MergedLicenseEntry( + packageName: entry.key, + licenseEntries: entry.value, + allPackageNames: allPackageNames[entry.key]!, + ); + }).toList(); + + mergedLicenses.sort((a, b) => a.packageName.compareTo(b.packageName)); + + if (mounted) { + setState(() { + _mergedLicenses = mergedLicenses; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + body: CustomScrollView( + slivers: [ + const CustomAppBar(title: Text('Licenses'), pinned: true), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final mergedLicense = _mergedLicenses[index]; + final packageName = mergedLicense.packageName; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + packageName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + subtitle: mergedLicense.licenseEntries.length > 1 + ? Text('${mergedLicense.licenseEntries.length} licenses') + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLicenseDetail(mergedLicense), + ), + ); + }, + childCount: _mergedLicenses.length, + ), + ), + ), + ], + ), + ); + } + + void _showLicenseDetail(MergedLicenseEntry mergedLicense) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _LicenseDetailScreen( + mergedLicense: mergedLicense, + ), + ), + ); + } +} + +class _LicenseDetailScreen extends StatelessWidget { + final MergedLicenseEntry mergedLicense; + + const _LicenseDetailScreen({ + required this.mergedLicense, + }); + + @override + Widget build(BuildContext context) { + final packageName = mergedLicense.packageName; + final licenseEntries = mergedLicense.licenseEntries; + + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(packageName), + pinned: true, + ), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // Package info card + if (mergedLicense.allPackageNames.length > 1) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Related Packages', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + mergedLicense.allPackageNames.join(', '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + if (mergedLicense.allPackageNames.length > 1) + const SizedBox(height: 16), + + // License cards + ...licenseEntries.asMap().entries.map((entry) { + final index = entry.key; + final license = entry.value; + final isMultipleLicenses = licenseEntries.length > 1; + + return Column( + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isMultipleLicenses ? 'License ${index + 1}' : 'License', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...license.paragraphs.map((paragraph) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SelectableText( + paragraph.text, + style: TextStyle( + fontFamily: paragraph.indent > 0 ? 'monospace' : null, + fontSize: 14, + ), + ), + ); + }), + ], + ), + ), + ), + if (index < licenseEntries.length - 1) + const SizedBox(height: 16), + ], + ); + }), + ]), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 8b15322c..b2d03bfd 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -8,6 +8,7 @@ import '../mixins/refreshable.dart'; import 'discover_screen.dart'; import 'libraries_screen.dart'; import 'search_screen.dart'; +import 'settings_screen.dart'; class MainScreen extends StatefulWidget { final PlexClient client; @@ -26,6 +27,7 @@ class _MainScreenState extends State with RouteAware { final GlobalKey> _discoverKey = GlobalKey(); final GlobalKey> _librariesKey = GlobalKey(); final GlobalKey> _searchKey = GlobalKey(); + final GlobalKey> _settingsKey = GlobalKey(); @override void initState() { @@ -39,6 +41,7 @@ class _MainScreenState extends State with RouteAware { ), LibrariesScreen(key: _librariesKey, userProfile: widget.userProfile), SearchScreen(key: _searchKey, userProfile: widget.userProfile), + SettingsScreen(key: _settingsKey), ]; // Set up data invalidation callback for profile switching @@ -141,6 +144,11 @@ class _MainScreenState extends State with RouteAware { selectedIcon: Icon(Icons.search), label: 'Search', ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), ], ), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 00000000..fe106927 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,563 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import '../providers/theme_provider.dart'; +import '../services/settings_service.dart' as settings; +import '../services/keyboard_shortcuts_service.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/hotkey_recorder_widget.dart'; +import 'about_screen.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late settings.SettingsService _settingsService; + late KeyboardShortcutsService _keyboardService; + bool _isLoading = true; + + bool _enableDebugLogging = false; + bool _enableHardwareDecoding = true; + int _videoBufferSize = 64; + int _audioBufferSize = 8; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + _settingsService = await settings.SettingsService.getInstance(); + _keyboardService = await KeyboardShortcutsService.getInstance(); + + setState(() { + _enableDebugLogging = _settingsService.getEnableDebugLogging(); + _enableHardwareDecoding = _settingsService.getEnableHardwareDecoding(); + _videoBufferSize = _settingsService.getVideoBufferSize(); + _audioBufferSize = _settingsService.getAudioBufferSize(); + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + body: CustomScrollView( + slivers: [ + const CustomAppBar(title: Text('Settings'), pinned: true), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildAppearanceSection(), + const SizedBox(height: 24), + _buildVideoPlaybackSection(), + const SizedBox(height: 24), + _buildKeyboardShortcutsSection(), + const SizedBox(height: 24), + _buildAdvancedSection(), + const SizedBox(height: 24), + _buildAboutSection(), + const SizedBox(height: 24), + ]), + ), + ), + ], + ), + ); + } + + Widget _buildAppearanceSection() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Appearance', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Consumer( + builder: (context, themeProvider, child) { + return ListTile( + leading: Icon(themeProvider.themeModeIcon), + title: const Text('Theme'), + subtitle: Text(themeProvider.themeModeDisplayName), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showThemeDialog(themeProvider), + ); + }, + ), + ], + ), + ); + } + + Widget _buildVideoPlaybackSection() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Video Playback', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.hardware), + title: const Text('Hardware Decoding'), + subtitle: const Text('Use hardware acceleration when available'), + value: _enableHardwareDecoding, + onChanged: (value) async { + setState(() { + _enableHardwareDecoding = value; + }); + await _settingsService.setEnableHardwareDecoding(value); + }, + ), + ListTile( + leading: const Icon(Icons.memory), + title: const Text('Video Buffer Size'), + subtitle: Text('${_videoBufferSize}MB'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showBufferSizeDialog(true), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('Audio Buffer Size'), + subtitle: Text('${_audioBufferSize}MB'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showBufferSizeDialog(false), + ), + ], + ), + ); + } + + Widget _buildKeyboardShortcutsSection() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Keyboard Shortcuts', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: const Icon(Icons.keyboard), + title: const Text('Video Player Controls'), + subtitle: const Text('Customize keyboard shortcuts'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showKeyboardShortcutsDialog(), + ), + ], + ), + ); + } + + + Widget _buildAdvancedSection() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Advanced', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.bug_report), + title: const Text('Debug Logging'), + subtitle: const Text('Enable detailed logging for troubleshooting'), + value: _enableDebugLogging, + onChanged: (value) async { + setState(() { + _enableDebugLogging = value; + }); + await _settingsService.setEnableDebugLogging(value); + }, + ), + ListTile( + leading: const Icon(Icons.cleaning_services), + title: const Text('Clear Cache'), + subtitle: const Text('Free up storage space'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showClearCacheDialog(), + ), + ListTile( + leading: const Icon(Icons.restore), + title: const Text('Reset Settings'), + subtitle: const Text('Reset all settings to defaults'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showResetSettingsDialog(), + ), + ], + ), + ); + } + + Widget _buildAboutSection() { + return Card( + child: ListTile( + leading: const Icon(Icons.info), + title: const Text('About'), + subtitle: const Text('App information and licenses'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const AboutScreen()), + ); + }, + ), + ); + } + + + void _showThemeDialog(ThemeProvider themeProvider) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Theme'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('System'), + subtitle: const Text('Follow system settings'), + value: settings.ThemeMode.system, + groupValue: themeProvider.themeMode, + onChanged: (value) { + if (value != null) { + themeProvider.setThemeMode(value); + Navigator.pop(context); + } + }, + ), + RadioListTile( + title: const Text('Light'), + value: settings.ThemeMode.light, + groupValue: themeProvider.themeMode, + onChanged: (value) { + if (value != null) { + themeProvider.setThemeMode(value); + Navigator.pop(context); + } + }, + ), + RadioListTile( + title: const Text('Dark'), + value: settings.ThemeMode.dark, + groupValue: themeProvider.themeMode, + onChanged: (value) { + if (value != null) { + themeProvider.setThemeMode(value); + Navigator.pop(context); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + } + + + void _showBufferSizeDialog(bool isVideo) { + final currentSize = isVideo ? _videoBufferSize : _audioBufferSize; + final title = isVideo ? 'Video Buffer Size' : 'Audio Buffer Size'; + final options = isVideo ? [16, 32, 64, 128, 256] : [2, 4, 8, 16, 32]; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: options.map((size) { + return RadioListTile( + title: Text('${size}MB'), + value: size, + groupValue: currentSize, + onChanged: (value) { + if (value != null) { + setState(() { + if (isVideo) { + _videoBufferSize = value; + _settingsService.setVideoBufferSize(value); + } else { + _audioBufferSize = value; + _settingsService.setAudioBufferSize(value); + } + }); + Navigator.pop(context); + } + }, + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + } + + void _showKeyboardShortcutsDialog() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _KeyboardShortcutsScreen( + keyboardService: _keyboardService, + ), + ), + ); + } + + void _showClearCacheDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Clear Cache'), + content: const Text( + 'This will clear all cached images and data. The app may take longer to load content after clearing the cache.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + await _settingsService.clearCache(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cache cleared successfully')), + ); + }, + child: const Text('Clear'), + ), + ], + ); + }, + ); + } + + void _showResetSettingsDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Reset Settings'), + content: const Text( + 'This will reset all settings to their default values. This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + await _settingsService.resetAllSettings(); + await _keyboardService.resetToDefaults(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings reset successfully')), + ); + // Reload settings + _loadSettings(); + }, + child: const Text('Reset'), + ), + ], + ); + }, + ); + } + +} + +class _KeyboardShortcutsScreen extends StatefulWidget { + final KeyboardShortcutsService keyboardService; + + const _KeyboardShortcutsScreen({required this.keyboardService}); + + @override + State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState(); +} + +class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { + Map _hotkeys = {}; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadHotkeys(); + } + + Future _loadHotkeys() async { + await widget.keyboardService.refreshFromStorage(); + setState(() { + _hotkeys = widget.keyboardService.hotkeys; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: const Text('Keyboard Shortcuts'), + pinned: true, + actions: [ + TextButton( + onPressed: () async { + await widget.keyboardService.resetToDefaults(); + await _loadHotkeys(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Shortcuts reset to defaults')), + ); + } + }, + child: const Text('Reset'), + ), + ], + ), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final actions = _hotkeys.keys.toList(); + final action = actions[index]; + final hotkey = _hotkeys[action]!; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(widget.keyboardService.getActionDisplayName(action)), + subtitle: Text(action), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + widget.keyboardService.formatHotkey(hotkey), + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + onTap: () => _editHotkey(action, hotkey), + ), + ); + }, + childCount: _hotkeys.length, + ), + ), + ), + ], + ), + ); + } + + void _editHotkey(String action, HotKey currentHotkey) { + showDialog( + context: context, + builder: (BuildContext context) { + return HotKeyRecorderWidget( + actionName: widget.keyboardService.getActionDisplayName(action), + currentHotKey: currentHotkey, + onHotKeyRecorded: (newHotkey) async { + // Check for conflicts + final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); + if (existingAction != null && existingAction != action) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}'), + ), + ); + return; + } + + // Save the new hotkey + await widget.keyboardService.setHotkey(action, newHotkey); + + if (mounted) { + // Update UI directly instead of reloading from storage + setState(() { + _hotkeys[action] = newHotkey; + }); + + if (mounted) { + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}'), + ), + ); + } + } + }, + onCancel: () => Navigator.pop(context), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart new file mode 100644 index 00000000..6f7db110 --- /dev/null +++ b/lib/services/keyboard_shortcuts_service.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'settings_service.dart'; + +class KeyboardShortcutsService { + static KeyboardShortcutsService? _instance; + late SettingsService _settingsService; + Map _shortcuts = {}; // Legacy string shortcuts for backward compatibility + Map _hotkeys = {}; // New HotKey objects + + KeyboardShortcutsService._(); + + static Future getInstance() async { + if (_instance == null) { + _instance = KeyboardShortcutsService._(); + await _instance!._init(); + } + return _instance!; + } + + Future _init() async { + _settingsService = await SettingsService.getInstance(); + // Ensure settings service is fully initialized before loading data + await Future.delayed(Duration.zero); // Allow event loop to complete + _shortcuts = _settingsService.getKeyboardShortcuts(); // Keep for legacy compatibility + _hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method + } + + Map get shortcuts => Map.from(_shortcuts); + Map get hotkeys => Map.from(_hotkeys); + + String getShortcut(String action) { + return _shortcuts[action] ?? ''; + } + + HotKey? getHotkey(String action) { + return _hotkeys[action]; + } + + Future setShortcut(String action, String key) async { + _shortcuts[action] = key; + await _settingsService.setKeyboardShortcuts(_shortcuts); + } + + Future setHotkey(String action, HotKey hotkey) async { + // Update local cache first + _hotkeys[action] = hotkey; + + // Save to persistent storage + await _settingsService.setKeyboardHotkey(action, hotkey); + + // Verify local cache is still correct + if (_hotkeys[action] != hotkey) { + _hotkeys[action] = hotkey; // Restore correct value + } + } + + Future refreshFromStorage() async { + _hotkeys = await _settingsService.getKeyboardHotkeys(); + } + + Future resetToDefaults() async { + _shortcuts = _settingsService.getDefaultKeyboardShortcuts(); + _hotkeys = _settingsService.getDefaultKeyboardHotkeys(); + await _settingsService.setKeyboardShortcuts(_shortcuts); + await _settingsService.setKeyboardHotkeys(_hotkeys); + // Refresh cache to ensure consistency + await refreshFromStorage(); + } + + // Format HotKey for display + String formatHotkey(HotKey? hotKey) { + if (hotKey == null) return 'No shortcut set'; + + final modifiers = []; + for (final modifier in hotKey.modifiers ?? []) { + switch (modifier) { + case HotKeyModifier.alt: + modifiers.add('Alt'); + break; + case HotKeyModifier.control: + modifiers.add('Ctrl'); + break; + case HotKeyModifier.shift: + modifiers.add('Shift'); + break; + case HotKeyModifier.meta: + modifiers.add('Meta'); + break; + case HotKeyModifier.capsLock: + modifiers.add('CapsLock'); + break; + case HotKeyModifier.fn: + modifiers.add('Fn'); + break; + } + } + + // Format the key name + String keyName = hotKey.key.keyLabel; + if (keyName.startsWith('PhysicalKeyboardKey#')) { + keyName = keyName.substring(20, keyName.length - 1); + } + if (keyName.startsWith('key')) { + keyName = keyName.substring(3).toUpperCase(); + } + + // Special cases for common keys + switch (keyName.toLowerCase()) { + case 'space': + keyName = 'Space'; + break; + case 'arrowup': + keyName = 'Arrow Up'; + break; + case 'arrowdown': + keyName = 'Arrow Down'; + break; + case 'arrowleft': + keyName = 'Arrow Left'; + break; + case 'arrowright': + keyName = 'Arrow Right'; + break; + case 'equal': + keyName = 'Plus'; + break; + case 'minus': + keyName = 'Minus'; + break; + } + + return modifiers.isEmpty ? keyName : '${modifiers.join(' + ')} + $keyName'; + } + + + + // Handle keyboard input for video player + KeyEventResult handleVideoPlayerKeyEvent( + KeyEvent event, + Player player, + VoidCallback? onToggleFullscreen, + VoidCallback? onToggleSubtitles, + VoidCallback? onNextAudioTrack, + VoidCallback? onNextSubtitleTrack, + VoidCallback? onNextChapter, + VoidCallback? onPreviousChapter, + ) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final physicalKey = event.physicalKey; + final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; + final isControlPressed = HardwareKeyboard.instance.isControlPressed; + final isAltPressed = HardwareKeyboard.instance.isAltPressed; + final isMetaPressed = HardwareKeyboard.instance.isMetaPressed; + + // Check each hotkey + for (final entry in _hotkeys.entries) { + final action = entry.key; + final hotkey = entry.value; + + // Check if the physical key matches + if (physicalKey != hotkey.key) continue; + + // Check if modifiers match + final requiredModifiers = hotkey.modifiers ?? []; + bool modifiersMatch = true; + + // Check each required modifier + for (final modifier in requiredModifiers) { + switch (modifier) { + case HotKeyModifier.shift: + if (!isShiftPressed) modifiersMatch = false; + break; + case HotKeyModifier.control: + if (!isControlPressed) modifiersMatch = false; + break; + case HotKeyModifier.alt: + if (!isAltPressed) modifiersMatch = false; + break; + case HotKeyModifier.meta: + if (!isMetaPressed) modifiersMatch = false; + break; + case HotKeyModifier.capsLock: + // CapsLock is typically not used for shortcuts, ignore for now + break; + case HotKeyModifier.fn: + // Fn key is typically not used for shortcuts, ignore for now + break; + } + if (!modifiersMatch) break; + } + + // Check that no extra modifiers are pressed + if (modifiersMatch) { + final hasShift = requiredModifiers.contains(HotKeyModifier.shift); + final hasControl = requiredModifiers.contains(HotKeyModifier.control); + final hasAlt = requiredModifiers.contains(HotKeyModifier.alt); + final hasMeta = requiredModifiers.contains(HotKeyModifier.meta); + + if (isShiftPressed != hasShift || + isControlPressed != hasControl || + isAltPressed != hasAlt || + isMetaPressed != hasMeta) { + continue; + } + + _executeAction(action, player, onToggleFullscreen, onToggleSubtitles, + onNextAudioTrack, onNextSubtitleTrack, onNextChapter, onPreviousChapter); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + void _executeAction( + String action, + Player player, + VoidCallback? onToggleFullscreen, + VoidCallback? onToggleSubtitles, + VoidCallback? onNextAudioTrack, + VoidCallback? onNextSubtitleTrack, + VoidCallback? onNextChapter, + VoidCallback? onPreviousChapter, + ) { + switch (action) { + case 'play_pause': + player.playOrPause(); + break; + case 'volume_up': + final newVolume = (player.state.volume + 10).clamp(0.0, 100.0); + player.setVolume(newVolume); + break; + case 'volume_down': + final newVolume = (player.state.volume - 10).clamp(0.0, 100.0); + player.setVolume(newVolume); + break; + case 'seek_forward': + final newPosition = player.state.position + const Duration(seconds: 10); + player.seek(newPosition); + break; + case 'seek_backward': + final newPosition = player.state.position - const Duration(seconds: 10); + player.seek(newPosition.isNegative ? Duration.zero : newPosition); + break; + case 'seek_forward_large': + final newPosition = player.state.position + const Duration(seconds: 30); + player.seek(newPosition); + break; + case 'seek_backward_large': + final newPosition = player.state.position - const Duration(seconds: 30); + player.seek(newPosition.isNegative ? Duration.zero : newPosition); + break; + case 'fullscreen_toggle': + onToggleFullscreen?.call(); + break; + case 'mute_toggle': + player.setVolume(player.state.volume > 0 ? 0.0 : 100.0); + break; + case 'subtitle_toggle': + onToggleSubtitles?.call(); + break; + case 'audio_track_next': + onNextAudioTrack?.call(); + break; + case 'subtitle_track_next': + onNextSubtitleTrack?.call(); + break; + case 'chapter_next': + onNextChapter?.call(); + break; + case 'chapter_previous': + onPreviousChapter?.call(); + break; + case 'speed_increase': + final newRate = (player.state.rate + 0.1).clamp(0.1, 3.0); + player.setRate(newRate); + break; + case 'speed_decrease': + final newRate = (player.state.rate - 0.1).clamp(0.1, 3.0); + player.setRate(newRate); + break; + case 'speed_reset': + player.setRate(1.0); + break; + } + } + + // Get human-readable action names + String getActionDisplayName(String action) { + switch (action) { + case 'play_pause': + return 'Play/Pause'; + case 'volume_up': + return 'Volume Up'; + case 'volume_down': + return 'Volume Down'; + case 'seek_forward': + return 'Seek Forward'; + case 'seek_backward': + return 'Seek Backward'; + case 'seek_forward_large': + return 'Seek Forward (Large)'; + case 'seek_backward_large': + return 'Seek Backward (Large)'; + case 'fullscreen_toggle': + return 'Toggle Fullscreen'; + case 'mute_toggle': + return 'Toggle Mute'; + case 'subtitle_toggle': + return 'Toggle Subtitles'; + case 'audio_track_next': + return 'Next Audio Track'; + case 'subtitle_track_next': + return 'Next Subtitle Track'; + case 'chapter_next': + return 'Next Chapter'; + case 'chapter_previous': + return 'Previous Chapter'; + case 'speed_increase': + return 'Increase Speed'; + case 'speed_decrease': + return 'Decrease Speed'; + case 'speed_reset': + return 'Reset Speed'; + default: + return action; + } + } + + // Validate if a key combination is valid (legacy method for backward compatibility) + bool isValidKeyShortcut(String keyString) { + // For backward compatibility, assume all non-empty strings are valid + // The new system will use HotKey objects for validation + return keyString.isNotEmpty; + } + + // Check if a shortcut is already assigned to another action + String? getActionForShortcut(String keyString) { + for (final entry in _shortcuts.entries) { + if (entry.value == keyString) { + return entry.key; + } + } + return null; + } + + // Check if a hotkey is already assigned to another action + String? getActionForHotkey(HotKey hotkey) { + for (final entry in _hotkeys.entries) { + if (_hotkeyEquals(entry.value, hotkey)) { + return entry.key; + } + } + return null; + } + + // Helper method to compare two HotKey objects + bool _hotkeyEquals(HotKey a, HotKey b) { + if (a.key != b.key) return false; + + final aModifiers = Set.from(a.modifiers ?? []); + final bModifiers = Set.from(b.modifiers ?? []); + + return aModifiers.length == bModifiers.length && + aModifiers.every((modifier) => bModifiers.contains(modifier)); + } +} \ No newline at end of file diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 00000000..d6202490 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,536 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/services.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; + +enum ThemeMode { system, light, dark } + +class SettingsService { + static const String _keyThemeMode = 'theme_mode'; + static const String _keyEnableDebugLogging = 'enable_debug_logging'; + static const String _keyVideoBufferSize = 'video_buffer_size'; + static const String _keyAudioBufferSize = 'audio_buffer_size'; + static const String _keyKeyboardShortcuts = 'keyboard_shortcuts'; + static const String _keyKeyboardHotkeys = 'keyboard_hotkeys'; + static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding'; + static const String _keyPreferredVideoCodec = 'preferred_video_codec'; + static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; + + static SettingsService? _instance; + late SharedPreferences _prefs; + + SettingsService._(); + + static Future getInstance() async { + if (_instance == null) { + _instance = SettingsService._(); + await _instance!._init(); + } + return _instance!; + } + + Future _init() async { + _prefs = await SharedPreferences.getInstance(); + } + + // Theme Mode + Future setThemeMode(ThemeMode mode) async { + await _prefs.setString(_keyThemeMode, mode.name); + } + + ThemeMode getThemeMode() { + final modeString = _prefs.getString(_keyThemeMode); + return ThemeMode.values + .firstWhere((mode) => mode.name == modeString, orElse: () => ThemeMode.system); + } + + + // Debug Logging + Future setEnableDebugLogging(bool enabled) async { + await _prefs.setBool(_keyEnableDebugLogging, enabled); + } + + bool getEnableDebugLogging() { + return _prefs.getBool(_keyEnableDebugLogging) ?? false; + } + + // Video Buffer Size (in MB) + Future setVideoBufferSize(int sizeInMB) async { + await _prefs.setInt(_keyVideoBufferSize, sizeInMB); + } + + int getVideoBufferSize() { + return _prefs.getInt(_keyVideoBufferSize) ?? 64; // Default 64MB + } + + // Audio Buffer Size (in MB) + Future setAudioBufferSize(int sizeInMB) async { + await _prefs.setInt(_keyAudioBufferSize, sizeInMB); + } + + int getAudioBufferSize() { + return _prefs.getInt(_keyAudioBufferSize) ?? 8; // Default 8MB + } + + // Hardware Decoding + Future setEnableHardwareDecoding(bool enabled) async { + await _prefs.setBool(_keyEnableHardwareDecoding, enabled); + } + + bool getEnableHardwareDecoding() { + return _prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled + } + + // Preferred Video Codec + Future setPreferredVideoCodec(String codec) async { + await _prefs.setString(_keyPreferredVideoCodec, codec); + } + + String getPreferredVideoCodec() { + return _prefs.getString(_keyPreferredVideoCodec) ?? 'auto'; + } + + // Preferred Audio Codec + Future setPreferredAudioCodec(String codec) async { + await _prefs.setString(_keyPreferredAudioCodec, codec); + } + + String getPreferredAudioCodec() { + return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; + } + + // Keyboard Shortcuts (Legacy String-based) + Map getDefaultKeyboardShortcuts() { + return { + 'play_pause': 'Space', + 'volume_up': 'Arrow Up', + 'volume_down': 'Arrow Down', + 'seek_forward': 'Arrow Right', + 'seek_backward': 'Arrow Left', + 'seek_forward_large': 'Shift+Arrow Right', + 'seek_backward_large': 'Shift+Arrow Left', + 'fullscreen_toggle': 'F', + 'mute_toggle': 'M', + 'subtitle_toggle': 'S', + 'audio_track_next': 'A', + 'subtitle_track_next': 'Shift+S', + 'chapter_next': 'N', + 'chapter_previous': 'P', + 'speed_increase': 'Plus', + 'speed_decrease': 'Minus', + 'speed_reset': 'R', + }; + } + + // HotKey Objects (New implementation) + Map getDefaultKeyboardHotkeys() { + return { + 'play_pause': HotKey(key: PhysicalKeyboardKey.space), + 'volume_up': HotKey(key: PhysicalKeyboardKey.arrowUp), + 'volume_down': HotKey(key: PhysicalKeyboardKey.arrowDown), + 'seek_forward': HotKey(key: PhysicalKeyboardKey.arrowRight), + 'seek_backward': HotKey(key: PhysicalKeyboardKey.arrowLeft), + 'seek_forward_large': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), + 'seek_backward_large': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + 'fullscreen_toggle': HotKey(key: PhysicalKeyboardKey.keyF), + 'mute_toggle': HotKey(key: PhysicalKeyboardKey.keyM), + 'subtitle_toggle': HotKey(key: PhysicalKeyboardKey.keyS), + 'audio_track_next': HotKey(key: PhysicalKeyboardKey.keyA), + 'subtitle_track_next': HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + 'chapter_next': HotKey(key: PhysicalKeyboardKey.keyN), + 'chapter_previous': HotKey(key: PhysicalKeyboardKey.keyP), + 'speed_increase': HotKey(key: PhysicalKeyboardKey.equal), + 'speed_decrease': HotKey(key: PhysicalKeyboardKey.minus), + 'speed_reset': HotKey(key: PhysicalKeyboardKey.keyR), + }; + } + + Future setKeyboardShortcuts(Map shortcuts) async { + final jsonString = json.encode(shortcuts); + await _prefs.setString(_keyKeyboardShortcuts, jsonString); + } + + Map getKeyboardShortcuts() { + final jsonString = _prefs.getString(_keyKeyboardShortcuts); + if (jsonString == null) return getDefaultKeyboardShortcuts(); + + try { + final decoded = json.decode(jsonString) as Map; + final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString())); + + // Merge with defaults to ensure all keys exist + final defaults = getDefaultKeyboardShortcuts(); + defaults.addAll(shortcuts); + return defaults; + } catch (e) { + return getDefaultKeyboardShortcuts(); + } + } + + Future setKeyboardShortcut(String action, String key) async { + final shortcuts = getKeyboardShortcuts(); + shortcuts[action] = key; + await setKeyboardShortcuts(shortcuts); + } + + String getKeyboardShortcut(String action) { + final shortcuts = getKeyboardShortcuts(); + return shortcuts[action] ?? ''; + } + + Future resetKeyboardShortcuts() async { + await setKeyboardShortcuts(getDefaultKeyboardShortcuts()); + } + + // HotKey Objects Methods + Future setKeyboardHotkeys(Map hotkeys) async { + final Map> serializedHotkeys = {}; + for (final entry in hotkeys.entries) { + serializedHotkeys[entry.key] = _serializeHotKey(entry.value); + } + final jsonString = json.encode(serializedHotkeys); + await _prefs.setString(_keyKeyboardHotkeys, jsonString); + } + + Future> getKeyboardHotkeys() async { + final jsonString = _prefs.getString(_keyKeyboardHotkeys); + if (jsonString == null) { + return getDefaultKeyboardHotkeys(); + } + + try { + final decoded = json.decode(jsonString) as Map; + final Map hotkeys = {}; + + for (final entry in decoded.entries) { + final hotKey = _deserializeHotKey(entry.value as Map); + if (hotKey != null) { + hotkeys[entry.key] = hotKey; + } + } + + // Merge with defaults to ensure all keys exist, but keep saved hotkeys priority + final defaults = getDefaultKeyboardHotkeys(); + final result = {}; + + // Start with defaults + result.addAll(defaults); + // Override with saved hotkeys (this preserves user customizations) + result.addAll(hotkeys); + + return result; + } catch (e) { + return getDefaultKeyboardHotkeys(); + } + } + + Future setKeyboardHotkey(String action, HotKey hotKey) async { + final hotkeys = await getKeyboardHotkeys(); + hotkeys[action] = hotKey; + await setKeyboardHotkeys(hotkeys); + } + + Future getKeyboardHotkey(String action) async { + final hotkeys = await getKeyboardHotkeys(); + return hotkeys[action]; + } + + Future resetKeyboardHotkeys() async { + await setKeyboardHotkeys(getDefaultKeyboardHotkeys()); + } + + // Helper methods for HotKey serialization + Map _serializeHotKey(HotKey hotKey) { + return { + 'key': hotKey.key.toString(), + 'modifiers': hotKey.modifiers?.map((m) => m.name).toList() ?? [], + }; + } + + HotKey? _deserializeHotKey(Map data) { + try { + final keyString = data['key'] as String; + final modifierNames = (data['modifiers'] as List).cast(); + + final modifiers = modifierNames.map((name) { + switch (name) { + case 'alt': + return HotKeyModifier.alt; + case 'control': + return HotKeyModifier.control; + case 'shift': + return HotKeyModifier.shift; + case 'meta': + return HotKeyModifier.meta; + case 'capsLock': + return HotKeyModifier.capsLock; + case 'fn': + return HotKeyModifier.fn; + default: + return null; + } + }).where((m) => m != null).cast().toList(); + + final key = _findKeyByString(keyString); + if (key != null) { + return HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null); + } + } catch (e) { + // Ignore deserialization errors + } + return null; + } + + // Helper method to find PhysicalKeyboardKey by string representation + PhysicalKeyboardKey? _findKeyByString(String keyString) { + + // Handle exact string matches first for better performance + const keyMap = { + 'PhysicalKeyboardKey#0002c': PhysicalKeyboardKey.space, + 'PhysicalKeyboardKey#7002a': PhysicalKeyboardKey.backspace, + 'PhysicalKeyboardKey#7004c': PhysicalKeyboardKey.delete, + 'PhysicalKeyboardKey#70028': PhysicalKeyboardKey.enter, + 'PhysicalKeyboardKey#70029': PhysicalKeyboardKey.escape, + 'PhysicalKeyboardKey#7002b': PhysicalKeyboardKey.tab, + 'PhysicalKeyboardKey#7004a': PhysicalKeyboardKey.home, + 'PhysicalKeyboardKey#7004d': PhysicalKeyboardKey.end, + 'PhysicalKeyboardKey#7004b': PhysicalKeyboardKey.pageUp, + 'PhysicalKeyboardKey#7004e': PhysicalKeyboardKey.pageDown, + 'PhysicalKeyboardKey#70050': PhysicalKeyboardKey.arrowLeft, + 'PhysicalKeyboardKey#70052': PhysicalKeyboardKey.arrowUp, + 'PhysicalKeyboardKey#7004f': PhysicalKeyboardKey.arrowRight, + 'PhysicalKeyboardKey#70051': PhysicalKeyboardKey.arrowDown, + }; + + // Check exact matches first + if (keyMap.containsKey(keyString)) { + return keyMap[keyString]; + } + + // Alternative approach: extract USB HID usage code from the toString() output + // Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space") + try { + final usbHidMatch = RegExp(r'usbHidUsage: "0x([0-9a-fA-F]+)"').firstMatch(keyString); + if (usbHidMatch != null) { + final usbHidCode = usbHidMatch.group(1)!.toLowerCase(); + + // Map USB HID codes to PhysicalKeyboardKey objects + const usbHidMap = { + '0007002c': PhysicalKeyboardKey.space, + '0007002a': PhysicalKeyboardKey.backspace, + '0007004c': PhysicalKeyboardKey.delete, + '00070028': PhysicalKeyboardKey.enter, + '00070029': PhysicalKeyboardKey.escape, + '0007002b': PhysicalKeyboardKey.tab, + '00070039': PhysicalKeyboardKey.capsLock, + // Function keys + '0007003a': PhysicalKeyboardKey.f1, + '0007003b': PhysicalKeyboardKey.f2, + '0007003c': PhysicalKeyboardKey.f3, + '0007003d': PhysicalKeyboardKey.f4, + '0007003e': PhysicalKeyboardKey.f5, + '0007003f': PhysicalKeyboardKey.f6, + '00070040': PhysicalKeyboardKey.f7, + '00070041': PhysicalKeyboardKey.f8, + '00070042': PhysicalKeyboardKey.f9, + '00070043': PhysicalKeyboardKey.f10, + '00070044': PhysicalKeyboardKey.f11, + '00070045': PhysicalKeyboardKey.f12, + // Number keys + '00070027': PhysicalKeyboardKey.digit0, + '0007001e': PhysicalKeyboardKey.digit1, + '0007001f': PhysicalKeyboardKey.digit2, + '00070020': PhysicalKeyboardKey.digit3, + '00070021': PhysicalKeyboardKey.digit4, + '00070022': PhysicalKeyboardKey.digit5, + '00070023': PhysicalKeyboardKey.digit6, + '00070024': PhysicalKeyboardKey.digit7, + '00070025': PhysicalKeyboardKey.digit8, + '00070026': PhysicalKeyboardKey.digit9, + // Letter keys + '00070004': PhysicalKeyboardKey.keyA, + '00070005': PhysicalKeyboardKey.keyB, + '00070006': PhysicalKeyboardKey.keyC, + '00070007': PhysicalKeyboardKey.keyD, + '00070008': PhysicalKeyboardKey.keyE, + '00070009': PhysicalKeyboardKey.keyF, + '0007000a': PhysicalKeyboardKey.keyG, + '0007000b': PhysicalKeyboardKey.keyH, + '0007000c': PhysicalKeyboardKey.keyI, + '0007000d': PhysicalKeyboardKey.keyJ, + '0007000e': PhysicalKeyboardKey.keyK, + '0007000f': PhysicalKeyboardKey.keyL, + '00070010': PhysicalKeyboardKey.keyM, + '00070011': PhysicalKeyboardKey.keyN, + '00070012': PhysicalKeyboardKey.keyO, + '00070013': PhysicalKeyboardKey.keyP, + '00070014': PhysicalKeyboardKey.keyQ, + '00070015': PhysicalKeyboardKey.keyR, + '00070016': PhysicalKeyboardKey.keyS, + '00070017': PhysicalKeyboardKey.keyT, + '00070018': PhysicalKeyboardKey.keyU, + '00070019': PhysicalKeyboardKey.keyV, + '0007001a': PhysicalKeyboardKey.keyW, + '0007001b': PhysicalKeyboardKey.keyX, + '0007001c': PhysicalKeyboardKey.keyY, + '0007001d': PhysicalKeyboardKey.keyZ, + // Arrow keys + '00070050': PhysicalKeyboardKey.arrowLeft, + '00070052': PhysicalKeyboardKey.arrowUp, + '0007004f': PhysicalKeyboardKey.arrowRight, + '00070051': PhysicalKeyboardKey.arrowDown, + // Other common keys + '0007002d': PhysicalKeyboardKey.equal, + '0007002e': PhysicalKeyboardKey.minus, + '0007004a': PhysicalKeyboardKey.home, + '0007004d': PhysicalKeyboardKey.end, + '0007004b': PhysicalKeyboardKey.pageUp, + '0007004e': PhysicalKeyboardKey.pageDown, + }; + + if (usbHidMap.containsKey(usbHidCode)) { + return usbHidMap[usbHidCode]; + } + } + } catch (e) { + // Ignore parsing errors + } + + // Fall back to contains() checks for partial matches + if (keyString.contains('space')) { + return PhysicalKeyboardKey.space; + } else if (keyString.contains('arrowUp')) { + return PhysicalKeyboardKey.arrowUp; + } else if (keyString.contains('arrowDown')) { + return PhysicalKeyboardKey.arrowDown; + } else if (keyString.contains('arrowLeft')) { + return PhysicalKeyboardKey.arrowLeft; + } else if (keyString.contains('arrowRight')) { + return PhysicalKeyboardKey.arrowRight; + } else if (keyString.contains('equal')) { + return PhysicalKeyboardKey.equal; + } else if (keyString.contains('minus')) { + return PhysicalKeyboardKey.minus; + } else if (keyString.contains('escape')) { + return PhysicalKeyboardKey.escape; + } else if (keyString.contains('enter')) { + return PhysicalKeyboardKey.enter; + } else if (keyString.contains('tab')) { + return PhysicalKeyboardKey.tab; + } else if (keyString.contains('backspace')) { + return PhysicalKeyboardKey.backspace; + } else if (keyString.contains('delete')) { + return PhysicalKeyboardKey.delete; + } else if (keyString.contains('home')) { + return PhysicalKeyboardKey.home; + } else if (keyString.contains('end')) { + return PhysicalKeyboardKey.end; + } else if (keyString.contains('pageUp')) { + return PhysicalKeyboardKey.pageUp; + } else if (keyString.contains('pageDown')) { + return PhysicalKeyboardKey.pageDown; + } else { + // Try function keys F1-F12 + for (int i = 1; i <= 12; i++) { + if (keyString.contains('f$i') || keyString.contains('F$i')) { + switch (i) { + case 1: return PhysicalKeyboardKey.f1; + case 2: return PhysicalKeyboardKey.f2; + case 3: return PhysicalKeyboardKey.f3; + case 4: return PhysicalKeyboardKey.f4; + case 5: return PhysicalKeyboardKey.f5; + case 6: return PhysicalKeyboardKey.f6; + case 7: return PhysicalKeyboardKey.f7; + case 8: return PhysicalKeyboardKey.f8; + case 9: return PhysicalKeyboardKey.f9; + case 10: return PhysicalKeyboardKey.f10; + case 11: return PhysicalKeyboardKey.f11; + case 12: return PhysicalKeyboardKey.f12; + } + } + } + + // Try number keys 0-9 + for (int i = 0; i <= 9; i++) { + if (keyString.contains('digit$i') || keyString.contains('Digit$i')) { + switch (i) { + case 0: return PhysicalKeyboardKey.digit0; + case 1: return PhysicalKeyboardKey.digit1; + case 2: return PhysicalKeyboardKey.digit2; + case 3: return PhysicalKeyboardKey.digit3; + case 4: return PhysicalKeyboardKey.digit4; + case 5: return PhysicalKeyboardKey.digit5; + case 6: return PhysicalKeyboardKey.digit6; + case 7: return PhysicalKeyboardKey.digit7; + case 8: return PhysicalKeyboardKey.digit8; + case 9: return PhysicalKeyboardKey.digit9; + } + } + } + + // Try letter keys A-Z (both upper and lower case patterns) + const letterKeys = { + 'A': PhysicalKeyboardKey.keyA, 'B': PhysicalKeyboardKey.keyB, 'C': PhysicalKeyboardKey.keyC, + 'D': PhysicalKeyboardKey.keyD, 'E': PhysicalKeyboardKey.keyE, 'F': PhysicalKeyboardKey.keyF, + 'G': PhysicalKeyboardKey.keyG, 'H': PhysicalKeyboardKey.keyH, 'I': PhysicalKeyboardKey.keyI, + 'J': PhysicalKeyboardKey.keyJ, 'K': PhysicalKeyboardKey.keyK, 'L': PhysicalKeyboardKey.keyL, + 'M': PhysicalKeyboardKey.keyM, 'N': PhysicalKeyboardKey.keyN, 'O': PhysicalKeyboardKey.keyO, + 'P': PhysicalKeyboardKey.keyP, 'Q': PhysicalKeyboardKey.keyQ, 'R': PhysicalKeyboardKey.keyR, + 'S': PhysicalKeyboardKey.keyS, 'T': PhysicalKeyboardKey.keyT, 'U': PhysicalKeyboardKey.keyU, + 'V': PhysicalKeyboardKey.keyV, 'W': PhysicalKeyboardKey.keyW, 'X': PhysicalKeyboardKey.keyX, + 'Y': PhysicalKeyboardKey.keyY, 'Z': PhysicalKeyboardKey.keyZ, + }; + + for (final entry in letterKeys.entries) { + if (keyString.contains('key${entry.key}') || keyString.contains('Key${entry.key}')) { + return entry.value; + } + } + + return null; + } + + return null; + } + + + // Reset all settings to defaults + Future resetAllSettings() async { + await Future.wait([ + _prefs.remove(_keyThemeMode), + _prefs.remove(_keyEnableDebugLogging), + _prefs.remove(_keyVideoBufferSize), + _prefs.remove(_keyAudioBufferSize), + _prefs.remove(_keyKeyboardShortcuts), + _prefs.remove(_keyKeyboardHotkeys), + _prefs.remove(_keyEnableHardwareDecoding), + _prefs.remove(_keyPreferredVideoCodec), + _prefs.remove(_keyPreferredAudioCodec), + ]); + } + + // Clear cache (for storage cleanup) + Future clearCache() async { + // This would be expanded to clear various cache directories + // For now, we'll just clear any cache-related preferences + await Future.wait([ + // Add cache clearing logic here + ]); + } + + // Get all settings as a map for debugging/export + Future> getAllSettings() async { + final hotkeys = await getKeyboardHotkeys(); + return { + 'themeMode': getThemeMode().name, + 'enableDebugLogging': getEnableDebugLogging(), + 'videoBufferSize': getVideoBufferSize(), + 'audioBufferSize': getAudioBufferSize(), + 'enableHardwareDecoding': getEnableHardwareDecoding(), + 'preferredVideoCodec': getPreferredVideoCodec(), + 'preferredAudioCodec': getPreferredAudioCodec(), + 'keyboardShortcuts': getKeyboardShortcuts(), + 'keyboardHotkeys': hotkeys.map((key, value) => MapEntry(key, _serializeHotKey(value))), + }; + } +} \ No newline at end of file diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index ac4adfcf..39b2f667 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -12,21 +12,33 @@ class DesktopWindowPadding { /// Right padding for macOS to prevent actions from being too close to edge static const double macOSRight = 16.0; + + /// Right padding for mobile devices to prevent actions from being too close to edge + static const double mobileRight = 6.0; } /// Helper class for adjusting app bar widgets to account for desktop window controls class DesktopAppBarHelper { - /// Builds actions list with appropriate right padding for macOS + /// Builds actions list with appropriate right padding for macOS and mobile static List? buildAdjustedActions(List? actions) { - if (!Platform.isMacOS) { + double? rightPadding; + + if (Platform.isMacOS) { + rightPadding = DesktopWindowPadding.macOSRight; + } else if (Platform.isIOS || Platform.isAndroid) { + rightPadding = DesktopWindowPadding.mobileRight; + } + + // If no platform-specific padding needed, return original actions + if (rightPadding == null) { return actions; } - // macOS: Add padding to keep actions away from edge + // Add padding to keep actions away from edge if (actions != null) { - return [...actions, SizedBox(width: DesktopWindowPadding.macOSRight)]; + return [...actions, SizedBox(width: rightPadding)]; } else { - return [SizedBox(width: DesktopWindowPadding.macOSRight)]; + return [SizedBox(width: rightPadding)]; } } diff --git a/lib/widgets/hotkey_recorder_widget.dart b/lib/widgets/hotkey_recorder_widget.dart new file mode 100644 index 00000000..8726bdc6 --- /dev/null +++ b/lib/widgets/hotkey_recorder_widget.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; + +class HotKeyRecorderWidget extends StatefulWidget { + final String actionName; + final HotKey? currentHotKey; + final Function(HotKey) onHotKeyRecorded; + final VoidCallback onCancel; + + const HotKeyRecorderWidget({ + super.key, + required this.actionName, + this.currentHotKey, + required this.onHotKeyRecorded, + required this.onCancel, + }); + + @override + State createState() => _HotKeyRecorderWidgetState(); +} + +class _HotKeyRecorderWidgetState extends State { + HotKey? _recordedHotKey; + + @override + void initState() { + super.initState(); + _recordedHotKey = widget.currentHotKey; + } + + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Set Shortcut for ${widget.actionName}'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current shortcut:', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Expanded( + child: HotKeyRecorder( + initalHotKey: _recordedHotKey, + onHotKeyRecorded: (hotKey) { + setState(() { + _recordedHotKey = hotKey; + }); + }, + ), + ), + if (_recordedHotKey != null) + IconButton( + icon: const Icon(Icons.backspace, size: 18), + onPressed: () { + setState(() { + _recordedHotKey = null; + }); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + tooltip: 'Clear shortcut', + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + 'Press any key combination to set a new shortcut', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + actions: [ + TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), + TextButton( + onPressed: _recordedHotKey != null + ? () => widget.onHotKeyRecorded(_recordedHotKey!) + : null, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index fb57c475..6585c1b8 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -10,6 +10,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; +import '../services/keyboard_shortcuts_service.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; @@ -56,6 +57,7 @@ class _PlexVideoControlsState extends State Timer? _hideTimer; bool _isFullscreen = false; late final FocusNode _focusNode; + KeyboardShortcutsService? _keyboardService; @override void initState() { @@ -63,12 +65,46 @@ class _PlexVideoControlsState extends State _focusNode = FocusNode(); _loadChapters(); _startHideTimer(); + _initKeyboardService(); // Add window listener for tracking fullscreen state (for button icon) if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.addListener(this); } } + Future _initKeyboardService() async { + _keyboardService = await KeyboardShortcutsService.getInstance(); + } + + void _toggleSubtitles() { + // Toggle subtitle visibility - this would need to be implemented based on your subtitle system + // For now, this is a placeholder + } + + void _nextAudioTrack() { + // Switch to next audio track - this would need to be implemented based on your track system + // For now, this is a placeholder + } + + void _nextSubtitleTrack() { + // Switch to next subtitle track - this would need to be implemented based on your subtitle system + // For now, this is a placeholder + } + + void _nextChapter() { + // Go to next chapter - this would use your existing chapter navigation + if (widget.onNext != null) { + widget.onNext!(); + } + } + + void _previousChapter() { + // Go to previous chapter - this would use your existing chapter navigation + if (widget.onPrevious != null) { + widget.onPrevious!(); + } + } + @override void dispose() { _hideTimer?.cancel(); @@ -348,110 +384,18 @@ class _PlexVideoControlsState extends State focusNode: _focusNode, autofocus: true, onKeyEvent: (node, event) { - // Only respond to key down events (not key up) - if (event is KeyDownEvent) { - final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; - final isCtrlPressed = HardwareKeyboard.instance.isControlPressed; + if (_keyboardService == null) return KeyEventResult.ignored; - // Play/Pause shortcuts - if (event.logicalKey == LogicalKeyboardKey.space || - event.logicalKey == LogicalKeyboardKey.keyK) { - _togglePlayPause(); - return KeyEventResult.handled; - } - - // Arrow key handling - check for modifiers first - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - if (isCtrlPressed) { - // Ctrl+Left: Seek backward 1 minute - _seek(const Duration(minutes: -1)); - } else if (isShiftPressed) { - // Shift+Left: Seek backward 5 seconds - _seek(const Duration(seconds: -5)); - } else { - // Left: Seek backward 10 seconds - _seek(const Duration(seconds: -10)); - } - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - if (isCtrlPressed) { - // Ctrl+Right: Seek forward 1 minute - _seek(const Duration(minutes: 1)); - } else if (isShiftPressed) { - // Shift+Right: Seek forward 5 seconds - _seek(const Duration(seconds: 5)); - } else { - // Right: Seek forward 10 seconds - _seek(const Duration(seconds: 10)); - } - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - // Up: Volume up 5% - _adjustVolume(5.0); - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - // Down: Volume down 5% - _adjustVolume(-5.0); - return KeyEventResult.handled; - } - - // J/L keys for seeking (alternative to arrows) - if (event.logicalKey == LogicalKeyboardKey.keyJ) { - // J: Seek backward 10 seconds - _seek(const Duration(seconds: -10)); - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.keyL) { - // L: Seek forward 10 seconds - _seek(const Duration(seconds: 10)); - return KeyEventResult.handled; - } - - // Fullscreen shortcuts - if (event.logicalKey == LogicalKeyboardKey.keyF) { - _toggleFullscreen(); - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.escape) { - // Escape: Exit fullscreen (only if currently fullscreen) - if (_isFullscreen) { - _toggleFullscreen(); - return KeyEventResult.handled; - } - } - - // Mute shortcut - if (event.logicalKey == LogicalKeyboardKey.keyM) { - _toggleMute(); - return KeyEventResult.handled; - } - - // Episode navigation shortcuts - if (event.logicalKey == LogicalKeyboardKey.keyN) { - // N: Next episode - if (widget.onNext != null) { - widget.onNext!(); - return KeyEventResult.handled; - } - } - - if (event.logicalKey == LogicalKeyboardKey.keyP) { - // P: Previous episode - if (widget.onPrevious != null) { - widget.onPrevious!(); - return KeyEventResult.handled; - } - } - } - return KeyEventResult.ignored; + return _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + ); }, child: MouseRegion( cursor: _showControls diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 1e491337..1bc31b4d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -14,6 +15,9 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin"); + hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar); g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2843f641..c9b34237 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + hotkey_manager_linux media_kit_libs_linux media_kit_video screen_retriever_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 88d08936..6488b3bd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import hotkey_manager_macos import macos_window_utils import media_kit_libs_macos_video import media_kit_video @@ -19,6 +20,7 @@ import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 24704c75..294f32e9 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,5 +1,9 @@ PODS: - FlutterMacOS (1.0.0) + - HotKey (0.2.1) + - hotkey_manager_macos (0.0.1): + - FlutterMacOS + - HotKey - macos_window_utils (1.0.0): - FlutterMacOS - media_kit_libs_macos_video (1.0.4): @@ -30,6 +34,7 @@ PODS: DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) @@ -43,9 +48,15 @@ DEPENDENCIES: - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) +SPEC REPOS: + trunk: + - HotKey + EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + hotkey_manager_macos: + :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos macos_window_utils: :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos media_kit_libs_macos_video: @@ -73,6 +84,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 + hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 diff --git a/pubspec.lock b/pubspec.lock index d184c2d7..27652cc9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hotkey_manager: + dependency: "direct main" + description: + name: hotkey_manager + sha256: "06f0655b76c8dd322fb7101dc615afbdbf39c3d3414df9e059c33892104479cd" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + hotkey_manager_linux: + dependency: transitive + description: + name: hotkey_manager_linux + sha256: "83676bda8210a3377bc6f1977f193bc1dbdd4c46f1bdd02875f44b6eff9a8473" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hotkey_manager_macos: + dependency: transitive + description: + name: hotkey_manager_macos + sha256: "03b5967e64357b9ac05188ea4a5df6fe4ed4205762cb80aaccf8916ee1713c96" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hotkey_manager_platform_interface: + dependency: transitive + description: + name: hotkey_manager_platform_interface + sha256: "98ffca25b8cc9081552902747b2942e3bc37855389a4218c9d50ca316b653b13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hotkey_manager_windows: + dependency: transitive + description: + name: hotkey_manager_windows + sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: transitive description: @@ -958,6 +998,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uni_platform: + dependency: transitive + description: + name: uni_platform + sha256: e02213a7ee5352212412ca026afd41d269eb00d982faa552f419ffc2debfad84 + url: "https://pub.dev" + source: hosted + version: "0.1.3" universal_platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a07bec6e..bfb6be36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: logger: ^2.0.2 package_info_plus: ^9.0.0 provider: ^6.1.2 + hotkey_manager: ^0.2.3 dependency_overrides: media_kit: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 122493b4..a3b8a65d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -14,6 +15,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a6d435b3..266a4444 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + hotkey_manager_windows media_kit_libs_windows_video media_kit_video screen_retriever_windows