461 lines
15 KiB
Dart
461 lines
15 KiB
Dart
import 'dart:async' show unawaited;
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../models/hotkey_model.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../mpv/mpv.dart';
|
|
import 'settings_service.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/player_utils.dart';
|
|
|
|
class KeyboardShortcutsService extends ChangeNotifier {
|
|
static KeyboardShortcutsService? _instance;
|
|
late SettingsService _settingsService;
|
|
final List<VoidCallback> _settingsDisposers = [];
|
|
Map<String, String> _shortcuts = {}; // Legacy string shortcuts for backward compatibility
|
|
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
|
|
int _seekTimeSmall = 10; // Default, loaded from settings
|
|
int _seekTimeLarge = 30; // Default, loaded from settings
|
|
int _maxVolume = 100; // Default, loaded from settings (100-300%)
|
|
|
|
KeyboardShortcutsService._();
|
|
|
|
static Future<KeyboardShortcutsService> getInstance() async {
|
|
if (_instance == null) {
|
|
_instance = KeyboardShortcutsService._();
|
|
await _instance!._init();
|
|
}
|
|
return _instance!;
|
|
}
|
|
|
|
/// Keyboard shortcut customization is only supported on desktop platforms.
|
|
static bool isPlatformSupported() {
|
|
return PlatformDetector.isDesktopOS();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
_settingsService = await SettingsService.getInstance();
|
|
_bindSettings();
|
|
_syncFromSettings(notify: false);
|
|
}
|
|
|
|
void _bindSettings() {
|
|
if (_settingsDisposers.isNotEmpty) return;
|
|
void bind<T>(Pref<T> pref) {
|
|
final notifier = _settingsService.listenable(pref);
|
|
notifier.addListener(_onSettingsChanged);
|
|
_settingsDisposers.add(() => notifier.removeListener(_onSettingsChanged));
|
|
}
|
|
|
|
bind(SettingsService.keyboardShortcuts);
|
|
bind(SettingsService.keyboardHotkeys);
|
|
bind(SettingsService.seekTimeSmall);
|
|
bind(SettingsService.seekTimeLarge);
|
|
bind(SettingsService.maxVolume);
|
|
}
|
|
|
|
void _onSettingsChanged() => _syncFromSettings();
|
|
|
|
void _syncFromSettings({bool notify = true}) {
|
|
final shortcuts = _settingsService.read(SettingsService.keyboardShortcuts);
|
|
final hotkeys = _settingsService.read(SettingsService.keyboardHotkeys);
|
|
final seekTimeSmall = _settingsService.read(SettingsService.seekTimeSmall);
|
|
final seekTimeLarge = _settingsService.read(SettingsService.seekTimeLarge);
|
|
final maxVolume = _settingsService.read(SettingsService.maxVolume);
|
|
|
|
final changed =
|
|
!mapEquals(_shortcuts, shortcuts) ||
|
|
!_hotkeyMapsEqual(_hotkeys, hotkeys) ||
|
|
_seekTimeSmall != seekTimeSmall ||
|
|
_seekTimeLarge != seekTimeLarge ||
|
|
_maxVolume != maxVolume;
|
|
|
|
_shortcuts = Map<String, String>.from(shortcuts);
|
|
_hotkeys = Map<String, HotKey>.from(hotkeys);
|
|
_seekTimeSmall = seekTimeSmall;
|
|
_seekTimeLarge = seekTimeLarge;
|
|
_maxVolume = maxVolume;
|
|
|
|
if (notify && changed) notifyListeners();
|
|
}
|
|
|
|
bool _hotkeyMapsEqual(Map<String, HotKey> a, Map<String, HotKey> b) {
|
|
if (a.length != b.length) return false;
|
|
for (final entry in a.entries) {
|
|
final other = b[entry.key];
|
|
if (other == null || !_hotkeyEquals(entry.value, other)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Map<String, String> get shortcuts => Map.from(_shortcuts);
|
|
Map<String, HotKey> get hotkeys => Map.from(_hotkeys);
|
|
int get maxVolume => _maxVolume;
|
|
|
|
String getShortcut(String action) {
|
|
return _shortcuts[action] ?? '';
|
|
}
|
|
|
|
HotKey? getHotkey(String action) {
|
|
return _hotkeys[action];
|
|
}
|
|
|
|
Future<void> setShortcut(String action, String key) async {
|
|
await _settingsService.write(SettingsService.keyboardShortcuts, {..._shortcuts, action: key});
|
|
}
|
|
|
|
Future<void> setHotkey(String action, HotKey hotkey) async {
|
|
await _settingsService.write(SettingsService.keyboardHotkeys, {..._hotkeys, action: hotkey});
|
|
}
|
|
|
|
Future<void> refreshFromStorage() async {
|
|
_syncFromSettings();
|
|
}
|
|
|
|
Future<void> resetToDefaults() async {
|
|
final shortcuts = SettingsService.defaultKeyboardShortcuts();
|
|
final hotkeys = SettingsService.defaultKeyboardHotkeys();
|
|
await _settingsService.write(SettingsService.keyboardShortcuts, shortcuts);
|
|
await _settingsService.write(SettingsService.keyboardHotkeys, hotkeys);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final dispose in _settingsDisposers) {
|
|
dispose();
|
|
}
|
|
_settingsDisposers.clear();
|
|
if (identical(_instance, this)) _instance = null;
|
|
super.dispose();
|
|
}
|
|
|
|
// Format HotKey for display
|
|
String formatHotkey(HotKey? hotKey) {
|
|
if (hotKey == null) return 'No shortcut set';
|
|
|
|
final isMac = Platform.isMacOS;
|
|
|
|
// macOS standard modifier order: ⌃ ⌥ ⇧ ⌘
|
|
const macModifierLabels = <HotKeyModifier, String>{
|
|
HotKeyModifier.control: '\u2303',
|
|
HotKeyModifier.alt: '\u2325',
|
|
HotKeyModifier.shift: '\u21e7',
|
|
HotKeyModifier.meta: '\u2318',
|
|
HotKeyModifier.capsLock: '\u21ea',
|
|
HotKeyModifier.fn: 'fn',
|
|
};
|
|
|
|
const defaultModifierLabels = <HotKeyModifier, String>{
|
|
HotKeyModifier.alt: 'Alt',
|
|
HotKeyModifier.control: 'Ctrl',
|
|
HotKeyModifier.shift: 'Shift',
|
|
HotKeyModifier.meta: 'Meta',
|
|
HotKeyModifier.capsLock: 'CapsLock',
|
|
HotKeyModifier.fn: 'Fn',
|
|
};
|
|
|
|
final labels = isMac ? macModifierLabels : defaultModifierLabels;
|
|
final modifiers = (hotKey.modifiers ?? []).map((m) => labels[m] ?? m.name).toList();
|
|
|
|
// The key label already uses macOS symbols via physicalKeyLabel()
|
|
final keyName = physicalKeyLabel(hotKey.key);
|
|
|
|
if (isMac) {
|
|
return [...modifiers, keyName].join();
|
|
}
|
|
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, {
|
|
VoidCallback? onBack,
|
|
VoidCallback? onToggleShader,
|
|
VoidCallback? onSkipMarker,
|
|
VoidCallback? onNextEpisode,
|
|
VoidCallback? onPreviousEpisode,
|
|
int? currentPositionEpoch,
|
|
ValueChanged<int>? onLiveSeek,
|
|
}) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
|
|
// Handle back navigation keys (Escape)
|
|
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
|
onBack?.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
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,
|
|
onToggleShader: onToggleShader,
|
|
onSkipMarker: onSkipMarker,
|
|
onNextEpisode: onNextEpisode,
|
|
onPreviousEpisode: onPreviousEpisode,
|
|
currentPositionEpoch: currentPositionEpoch,
|
|
onLiveSeek: onLiveSeek,
|
|
);
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
void _executeAction(
|
|
String action,
|
|
Player player,
|
|
VoidCallback? onToggleFullscreen,
|
|
VoidCallback? onToggleSubtitles,
|
|
VoidCallback? onNextAudioTrack,
|
|
VoidCallback? onNextSubtitleTrack,
|
|
VoidCallback? onNextChapter,
|
|
VoidCallback? onPreviousChapter, {
|
|
VoidCallback? onToggleShader,
|
|
VoidCallback? onSkipMarker,
|
|
VoidCallback? onNextEpisode,
|
|
VoidCallback? onPreviousEpisode,
|
|
int? currentPositionEpoch,
|
|
ValueChanged<int>? onLiveSeek,
|
|
}) {
|
|
void performSeek(int offsetSeconds) {
|
|
if (onLiveSeek != null && currentPositionEpoch != null) {
|
|
onLiveSeek(currentPositionEpoch + offsetSeconds);
|
|
} else {
|
|
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
|
unawaited(player.seek(target));
|
|
}
|
|
}
|
|
|
|
switch (action) {
|
|
case 'play_pause':
|
|
player.playOrPause();
|
|
break;
|
|
case 'volume_up':
|
|
final newVolume = (player.state.volume + 10).clamp(0.0, _maxVolume.toDouble());
|
|
player.setVolume(newVolume);
|
|
_settingsService.write(SettingsService.volume, newVolume);
|
|
break;
|
|
case 'volume_down':
|
|
final newVolume = (player.state.volume - 10).clamp(0.0, _maxVolume.toDouble());
|
|
player.setVolume(newVolume);
|
|
_settingsService.write(SettingsService.volume, newVolume);
|
|
break;
|
|
case 'seek_forward':
|
|
performSeek(_seekTimeSmall);
|
|
break;
|
|
case 'seek_backward':
|
|
performSeek(-_seekTimeSmall);
|
|
break;
|
|
case 'seek_forward_large':
|
|
performSeek(_seekTimeLarge);
|
|
break;
|
|
case 'seek_backward_large':
|
|
performSeek(-_seekTimeLarge);
|
|
break;
|
|
case 'fullscreen_toggle':
|
|
onToggleFullscreen?.call();
|
|
break;
|
|
case 'mute_toggle':
|
|
final newVolume = player.state.volume > 0 ? 0.0 : 100.0;
|
|
player.setVolume(newVolume);
|
|
_settingsService.write(SettingsService.volume, newVolume);
|
|
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 'episode_next':
|
|
onNextEpisode?.call();
|
|
break;
|
|
case 'episode_previous':
|
|
onPreviousEpisode?.call();
|
|
break;
|
|
case 'speed_increase':
|
|
final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0);
|
|
player.setRate(newRateUp);
|
|
_settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp);
|
|
break;
|
|
case 'speed_decrease':
|
|
final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0);
|
|
player.setRate(newRateDown);
|
|
_settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown);
|
|
break;
|
|
case 'speed_reset':
|
|
player.setRate(1.0);
|
|
_settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0);
|
|
break;
|
|
case 'sub_seek_next':
|
|
player.command(['sub-seek', '1']);
|
|
break;
|
|
case 'sub_seek_prev':
|
|
player.command(['sub-seek', '-1']);
|
|
break;
|
|
case 'shader_toggle':
|
|
onToggleShader?.call();
|
|
break;
|
|
case 'skip_marker':
|
|
onSkipMarker?.call();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Get human-readable action names
|
|
String getActionDisplayName(String action) {
|
|
switch (action) {
|
|
case 'play_pause':
|
|
return t.hotkeys.actions.playPause;
|
|
case 'volume_up':
|
|
return t.hotkeys.actions.volumeUp;
|
|
case 'volume_down':
|
|
return t.hotkeys.actions.volumeDown;
|
|
case 'seek_forward':
|
|
return t.hotkeys.actions.seekForward(seconds: _seekTimeSmall);
|
|
case 'seek_backward':
|
|
return t.hotkeys.actions.seekBackward(seconds: _seekTimeSmall);
|
|
case 'seek_forward_large':
|
|
return t.hotkeys.actions.seekForward(seconds: _seekTimeLarge);
|
|
case 'seek_backward_large':
|
|
return t.hotkeys.actions.seekBackward(seconds: _seekTimeLarge);
|
|
case 'fullscreen_toggle':
|
|
return t.hotkeys.actions.fullscreenToggle;
|
|
case 'mute_toggle':
|
|
return t.hotkeys.actions.muteToggle;
|
|
case 'subtitle_toggle':
|
|
return t.hotkeys.actions.subtitleToggle;
|
|
case 'audio_track_next':
|
|
return t.hotkeys.actions.audioTrackNext;
|
|
case 'subtitle_track_next':
|
|
return t.hotkeys.actions.subtitleTrackNext;
|
|
case 'chapter_next':
|
|
return t.hotkeys.actions.chapterNext;
|
|
case 'chapter_previous':
|
|
return t.hotkeys.actions.chapterPrevious;
|
|
case 'episode_next':
|
|
return t.hotkeys.actions.episodeNext;
|
|
case 'episode_previous':
|
|
return t.hotkeys.actions.episodePrevious;
|
|
case 'speed_increase':
|
|
return t.hotkeys.actions.speedIncrease;
|
|
case 'speed_decrease':
|
|
return t.hotkeys.actions.speedDecrease;
|
|
case 'speed_reset':
|
|
return t.hotkeys.actions.speedReset;
|
|
case 'sub_seek_next':
|
|
return t.hotkeys.actions.subSeekNext;
|
|
case 'sub_seek_prev':
|
|
return t.hotkeys.actions.subSeekPrev;
|
|
case 'shader_toggle':
|
|
return t.hotkeys.actions.shaderToggle;
|
|
case 'skip_marker':
|
|
return t.hotkeys.actions.skipMarker;
|
|
default:
|
|
return action;
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}
|