feat: settings page

This commit is contained in:
edde746
2025-10-31 03:55:01 +01:00
parent e0fc990b0e
commit 19ab6a21b9
19 changed files with 2071 additions and 193 deletions
+14 -8
View File
@@ -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<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Plezy',
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const SetupScreen(),
);
},
),
);
}
+99
View File
@@ -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<void> _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<void> 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;
}
}
}
+5 -77
View File
@@ -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<AboutScreen> {
),
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<AboutScreen> {
);
}
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),
),
),
),
],
),
);
}
}
+223
View File
@@ -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<LicenseEntry> licenseEntries;
final Set<String> allPackageNames;
MergedLicenseEntry({
required this.packageName,
required this.licenseEntries,
required this.allPackageNames,
});
}
class LicensesScreen extends StatefulWidget {
const LicensesScreen({super.key});
@override
State<LicensesScreen> createState() => _LicensesScreenState();
}
class _LicensesScreenState extends State<LicensesScreen> {
List<MergedLicenseEntry> _mergedLicenses = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadLicenses();
}
Future<void> _loadLicenses() async {
final licenseMap = <String, List<LicenseEntry>>{};
final allPackageNames = <String, Set<String>>{};
await for (final license in LicenseRegistry.licenses) {
for (final packageName in license.packages) {
if (!licenseMap.containsKey(packageName)) {
licenseMap[packageName] = [];
allPackageNames[packageName] = <String>{};
}
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),
],
);
}),
]),
),
),
],
),
);
}
}
+8
View File
@@ -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<MainScreen> with RouteAware {
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
@override
void initState() {
@@ -39,6 +41,7 @@ class _MainScreenState extends State<MainScreen> 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<MainScreen> with RouteAware {
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
+563
View File
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
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<void> _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<ThemeProvider>(
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<settings.ThemeMode>(
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<settings.ThemeMode>(
title: const Text('Light'),
value: settings.ThemeMode.light,
groupValue: themeProvider.themeMode,
onChanged: (value) {
if (value != null) {
themeProvider.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<settings.ThemeMode>(
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<int>(
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<String, HotKey> _hotkeys = {};
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHotkeys();
}
Future<void> _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),
);
},
);
}
}
@@ -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<String, String> _shortcuts = {}; // Legacy string shortcuts for backward compatibility
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
KeyboardShortcutsService._();
static Future<KeyboardShortcutsService> getInstance() async {
if (_instance == null) {
_instance = KeyboardShortcutsService._();
await _instance!._init();
}
return _instance!;
}
Future<void> _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<String, String> get shortcuts => Map.from(_shortcuts);
Map<String, HotKey> get hotkeys => Map.from(_hotkeys);
String getShortcut(String action) {
return _shortcuts[action] ?? '';
}
HotKey? getHotkey(String action) {
return _hotkeys[action];
}
Future<void> setShortcut(String action, String key) async {
_shortcuts[action] = key;
await _settingsService.setKeyboardShortcuts(_shortcuts);
}
Future<void> 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<void> refreshFromStorage() async {
_hotkeys = await _settingsService.getKeyboardHotkeys();
}
Future<void> 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 = <String>[];
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));
}
}
+536
View File
@@ -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<SettingsService> getInstance() async {
if (_instance == null) {
_instance = SettingsService._();
await _instance!._init();
}
return _instance!;
}
Future<void> _init() async {
_prefs = await SharedPreferences.getInstance();
}
// Theme Mode
Future<void> 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<void> setEnableDebugLogging(bool enabled) async {
await _prefs.setBool(_keyEnableDebugLogging, enabled);
}
bool getEnableDebugLogging() {
return _prefs.getBool(_keyEnableDebugLogging) ?? false;
}
// Video Buffer Size (in MB)
Future<void> setVideoBufferSize(int sizeInMB) async {
await _prefs.setInt(_keyVideoBufferSize, sizeInMB);
}
int getVideoBufferSize() {
return _prefs.getInt(_keyVideoBufferSize) ?? 64; // Default 64MB
}
// Audio Buffer Size (in MB)
Future<void> setAudioBufferSize(int sizeInMB) async {
await _prefs.setInt(_keyAudioBufferSize, sizeInMB);
}
int getAudioBufferSize() {
return _prefs.getInt(_keyAudioBufferSize) ?? 8; // Default 8MB
}
// Hardware Decoding
Future<void> setEnableHardwareDecoding(bool enabled) async {
await _prefs.setBool(_keyEnableHardwareDecoding, enabled);
}
bool getEnableHardwareDecoding() {
return _prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled
}
// Preferred Video Codec
Future<void> setPreferredVideoCodec(String codec) async {
await _prefs.setString(_keyPreferredVideoCodec, codec);
}
String getPreferredVideoCodec() {
return _prefs.getString(_keyPreferredVideoCodec) ?? 'auto';
}
// Preferred Audio Codec
Future<void> setPreferredAudioCodec(String codec) async {
await _prefs.setString(_keyPreferredAudioCodec, codec);
}
String getPreferredAudioCodec() {
return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto';
}
// Keyboard Shortcuts (Legacy String-based)
Map<String, String> 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<String, HotKey> 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<void> setKeyboardShortcuts(Map<String, String> shortcuts) async {
final jsonString = json.encode(shortcuts);
await _prefs.setString(_keyKeyboardShortcuts, jsonString);
}
Map<String, String> getKeyboardShortcuts() {
final jsonString = _prefs.getString(_keyKeyboardShortcuts);
if (jsonString == null) return getDefaultKeyboardShortcuts();
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
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<void> 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<void> resetKeyboardShortcuts() async {
await setKeyboardShortcuts(getDefaultKeyboardShortcuts());
}
// HotKey Objects Methods
Future<void> setKeyboardHotkeys(Map<String, HotKey> hotkeys) async {
final Map<String, Map<String, dynamic>> serializedHotkeys = {};
for (final entry in hotkeys.entries) {
serializedHotkeys[entry.key] = _serializeHotKey(entry.value);
}
final jsonString = json.encode(serializedHotkeys);
await _prefs.setString(_keyKeyboardHotkeys, jsonString);
}
Future<Map<String, HotKey>> getKeyboardHotkeys() async {
final jsonString = _prefs.getString(_keyKeyboardHotkeys);
if (jsonString == null) {
return getDefaultKeyboardHotkeys();
}
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
final Map<String, HotKey> hotkeys = {};
for (final entry in decoded.entries) {
final hotKey = _deserializeHotKey(entry.value as Map<String, dynamic>);
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 = <String, HotKey>{};
// Start with defaults
result.addAll(defaults);
// Override with saved hotkeys (this preserves user customizations)
result.addAll(hotkeys);
return result;
} catch (e) {
return getDefaultKeyboardHotkeys();
}
}
Future<void> setKeyboardHotkey(String action, HotKey hotKey) async {
final hotkeys = await getKeyboardHotkeys();
hotkeys[action] = hotKey;
await setKeyboardHotkeys(hotkeys);
}
Future<HotKey?> getKeyboardHotkey(String action) async {
final hotkeys = await getKeyboardHotkeys();
return hotkeys[action];
}
Future<void> resetKeyboardHotkeys() async {
await setKeyboardHotkeys(getDefaultKeyboardHotkeys());
}
// Helper methods for HotKey serialization
Map<String, dynamic> _serializeHotKey(HotKey hotKey) {
return {
'key': hotKey.key.toString(),
'modifiers': hotKey.modifiers?.map((m) => m.name).toList() ?? [],
};
}
HotKey? _deserializeHotKey(Map<String, dynamic> data) {
try {
final keyString = data['key'] as String;
final modifierNames = (data['modifiers'] as List<dynamic>).cast<String>();
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<HotKeyModifier>().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<void> 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<void> 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<Map<String, dynamic>> 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))),
};
}
}
+17 -5
View File
@@ -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<Widget>? buildAdjustedActions(List<Widget>? 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)];
}
}
+115
View File
@@ -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<HotKeyRecorderWidget> createState() => _HotKeyRecorderWidgetState();
}
class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
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'),
),
],
);
}
}
+47 -103
View File
@@ -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<PlexVideoControls>
Timer? _hideTimer;
bool _isFullscreen = false;
late final FocusNode _focusNode;
KeyboardShortcutsService? _keyboardService;
@override
void initState() {
@@ -63,12 +65,46 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_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<void> _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<PlexVideoControls>
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