537 lines
17 KiB
Dart
537 lines
17 KiB
Dart
import 'dart:async' show unawaited;
|
|
import 'dart:io';
|
|
|
|
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_binding_owner.dart';
|
|
import 'settings_service.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/player_utils.dart';
|
|
|
|
class KeyboardShortcutsService extends ChangeNotifier {
|
|
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
|
|
|
|
static KeyboardShortcutsService? _instance;
|
|
static Future<void>? _initialization;
|
|
late final SettingsBindingOwner _settingsBinding;
|
|
Map<String, HotKey?> _hotkeys = {};
|
|
Future<void> _shortcutMutationTail = Future.value();
|
|
int _seekTimeSmall = 10; // Default, loaded from settings
|
|
int _seekTimeLarge = 30; // Default, loaded from settings
|
|
bool _disposed = false;
|
|
bool _settingsInitialized = false;
|
|
|
|
KeyboardShortcutsService._() {
|
|
_settingsBinding = SettingsBindingOwner(
|
|
prefs: [SettingsService.keyboardHotkeys, SettingsService.seekTimeSmall, SettingsService.seekTimeLarge],
|
|
onRefresh: _syncFromSettings,
|
|
);
|
|
}
|
|
|
|
SettingsService get _settingsService => _settingsBinding.settings!;
|
|
|
|
static Future<KeyboardShortcutsService> getInstance() async {
|
|
var instance = _instance;
|
|
if (instance == null) {
|
|
instance = KeyboardShortcutsService._();
|
|
_instance = instance;
|
|
final initialization = instance._init();
|
|
_initialization = initialization;
|
|
}
|
|
|
|
final initialization = _initialization;
|
|
if (initialization != null) {
|
|
try {
|
|
await initialization;
|
|
} catch (_) {
|
|
if (identical(_instance, instance)) {
|
|
instance._settingsBinding.dispose();
|
|
instance._disposed = true;
|
|
_instance = null;
|
|
}
|
|
rethrow;
|
|
} finally {
|
|
if (identical(_initialization, initialization)) _initialization = null;
|
|
}
|
|
}
|
|
if (instance._disposed) throw StateError('KeyboardShortcutsService was disposed during initialization');
|
|
return instance;
|
|
}
|
|
|
|
/// Keyboard shortcut customization is only supported on desktop platforms.
|
|
static bool isPlatformSupported() {
|
|
return PlatformDetector.isDesktopOS();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
await _settingsBinding.bind();
|
|
}
|
|
|
|
void _syncFromSettings(SettingsService service) {
|
|
final hotkeys = service.read(SettingsService.keyboardHotkeys);
|
|
final seekTimeSmall = service.read(SettingsService.seekTimeSmall);
|
|
final seekTimeLarge = service.read(SettingsService.seekTimeLarge);
|
|
|
|
final changed =
|
|
!_hotkeyMapsEqual(_hotkeys, hotkeys) || _seekTimeSmall != seekTimeSmall || _seekTimeLarge != seekTimeLarge;
|
|
|
|
_hotkeys = Map<String, HotKey?>.from(hotkeys);
|
|
_seekTimeSmall = seekTimeSmall;
|
|
_seekTimeLarge = seekTimeLarge;
|
|
|
|
final notify = _settingsInitialized;
|
|
_settingsInitialized = true;
|
|
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) {
|
|
if (!b.containsKey(entry.key)) return false;
|
|
final value = entry.value;
|
|
final other = b[entry.key];
|
|
if (value == null || other == null) {
|
|
if (value != other) return false;
|
|
} else if (!_hotkeyEquals(value, other)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Map<String, HotKey?> get hotkeys => Map.from(_hotkeys);
|
|
|
|
HotKey? getHotkey(String action) {
|
|
return _hotkeys[action];
|
|
}
|
|
|
|
Future<void> setHotkey(String action, HotKey? hotkey) {
|
|
return _serializeShortcutMutation(() async {
|
|
await _settingsService.write(SettingsService.keyboardHotkeys, <String, HotKey?>{..._hotkeys, action: hotkey});
|
|
});
|
|
}
|
|
|
|
Future<void> refreshFromStorage() async {
|
|
_settingsBinding.refresh();
|
|
}
|
|
|
|
Future<void> resetToDefaults() {
|
|
return _serializeShortcutMutation(() async {
|
|
await _settingsService.write(SettingsService.keyboardHotkeys, <String, HotKey?>{
|
|
...SettingsService.defaultKeyboardHotkeys(),
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<void> _serializeShortcutMutation(Future<void> Function() operation) {
|
|
final result = _shortcutMutationTail.then((_) => operation());
|
|
_shortcutMutationTail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_settingsBinding.dispose();
|
|
if (identical(_instance, this)) {
|
|
_instance = null;
|
|
_initialization = null;
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
String formatHotkey(HotKey? hotKey) {
|
|
if (hotKey == null) return t.hotkeys.noShortcutSet;
|
|
|
|
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';
|
|
}
|
|
|
|
KeyEventResult handleVideoPlayerKeyEvent(
|
|
KeyEvent event,
|
|
Player player,
|
|
VoidCallback? onToggleFullscreen,
|
|
VoidCallback? onToggleSubtitles,
|
|
VoidCallback? onNextAudioTrack,
|
|
VoidCallback? onNextSubtitleTrack,
|
|
VoidCallback? onNextChapter,
|
|
VoidCallback? onPreviousChapter, {
|
|
required bool canControlPlayback,
|
|
required bool canNavigateMediaItems,
|
|
VoidCallback? onPlayPause,
|
|
VoidCallback? onToggleShader,
|
|
VoidCallback? onSkipMarker,
|
|
VoidCallback? onNextEpisode,
|
|
VoidCallback? onPreviousEpisode,
|
|
VoidCallback? onScreenshot,
|
|
VoidCallback? onZoomIn,
|
|
VoidCallback? onZoomOut,
|
|
VoidCallback? onZoomReset,
|
|
VoidCallback? onVolumeUp,
|
|
VoidCallback? onVolumeDown,
|
|
VoidCallback? onToggleMute,
|
|
int? currentPositionEpoch,
|
|
ValueChanged<int>? onLiveSeek,
|
|
ValueChanged<int>? onLiveSeekBy,
|
|
Future<void> Function(Duration position)? onSeekRequested,
|
|
}) {
|
|
final isRepeat = event is KeyRepeatEvent;
|
|
if (event is! KeyDownEvent && !isRepeat) 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;
|
|
|
|
for (final entry in _hotkeys.entries) {
|
|
final action = entry.key;
|
|
final hotkey = entry.value;
|
|
if (hotkey == null) continue;
|
|
|
|
if (physicalKey != hotkey.key) continue;
|
|
|
|
final requiredModifiers = hotkey.modifiers ?? [];
|
|
bool modifiersMatch = true;
|
|
|
|
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;
|
|
}
|
|
|
|
if (isRepeat && !_repeatableVideoActions.contains(action)) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
const playbackControlledActions = <String>{
|
|
'play_pause',
|
|
'seek_forward',
|
|
'seek_backward',
|
|
'seek_forward_large',
|
|
'seek_backward_large',
|
|
'audio_track_next',
|
|
'subtitle_track_next',
|
|
'chapter_next',
|
|
'chapter_previous',
|
|
'speed_increase',
|
|
'speed_decrease',
|
|
'speed_reset',
|
|
'sub_seek_next',
|
|
'sub_seek_prev',
|
|
'skip_marker',
|
|
};
|
|
const mediaItemActions = <String>{'episode_next', 'episode_previous'};
|
|
if ((playbackControlledActions.contains(action) && !canControlPlayback) ||
|
|
(mediaItemActions.contains(action) && !canNavigateMediaItems)) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
_executeAction(
|
|
action,
|
|
player,
|
|
onToggleFullscreen,
|
|
onToggleSubtitles,
|
|
onNextAudioTrack,
|
|
onNextSubtitleTrack,
|
|
onNextChapter,
|
|
onPreviousChapter,
|
|
onPlayPause: onPlayPause,
|
|
onToggleShader: onToggleShader,
|
|
onSkipMarker: onSkipMarker,
|
|
onNextEpisode: onNextEpisode,
|
|
onPreviousEpisode: onPreviousEpisode,
|
|
onScreenshot: onScreenshot,
|
|
onZoomIn: onZoomIn,
|
|
onZoomOut: onZoomOut,
|
|
onZoomReset: onZoomReset,
|
|
onVolumeUp: onVolumeUp,
|
|
onVolumeDown: onVolumeDown,
|
|
onToggleMute: onToggleMute,
|
|
currentPositionEpoch: currentPositionEpoch,
|
|
onLiveSeek: onLiveSeek,
|
|
onLiveSeekBy: onLiveSeekBy,
|
|
onSeekRequested: onSeekRequested,
|
|
);
|
|
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? onPlayPause,
|
|
VoidCallback? onToggleShader,
|
|
VoidCallback? onSkipMarker,
|
|
VoidCallback? onNextEpisode,
|
|
VoidCallback? onPreviousEpisode,
|
|
VoidCallback? onScreenshot,
|
|
VoidCallback? onZoomIn,
|
|
VoidCallback? onZoomOut,
|
|
VoidCallback? onZoomReset,
|
|
VoidCallback? onVolumeUp,
|
|
VoidCallback? onVolumeDown,
|
|
VoidCallback? onToggleMute,
|
|
int? currentPositionEpoch,
|
|
ValueChanged<int>? onLiveSeek,
|
|
ValueChanged<int>? onLiveSeekBy,
|
|
Future<void> Function(Duration position)? onSeekRequested,
|
|
}) {
|
|
void performSeek(int offsetSeconds) {
|
|
// Relative live-TV skip: route through the parent accumulator, which
|
|
// coalesces a rapid burst into one transcode re-open (#1253).
|
|
if (onLiveSeekBy != null) {
|
|
onLiveSeekBy(offsetSeconds);
|
|
} else {
|
|
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
|
unawaited((onSeekRequested ?? player.seek)(target));
|
|
}
|
|
}
|
|
|
|
switch (action) {
|
|
case 'play_pause':
|
|
(onPlayPause ?? player.playOrPause).call();
|
|
break;
|
|
case 'volume_up':
|
|
onVolumeUp?.call();
|
|
break;
|
|
case 'volume_down':
|
|
onVolumeDown?.call();
|
|
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':
|
|
onToggleMute?.call();
|
|
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;
|
|
case 'screenshot':
|
|
unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call()));
|
|
break;
|
|
case 'zoom_in':
|
|
onZoomIn?.call();
|
|
break;
|
|
case 'zoom_out':
|
|
onZoomOut?.call();
|
|
break;
|
|
case 'zoom_reset':
|
|
onZoomReset?.call();
|
|
break;
|
|
}
|
|
}
|
|
|
|
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;
|
|
case 'screenshot':
|
|
return t.hotkeys.actions.screenshot;
|
|
case 'zoom_in':
|
|
return t.hotkeys.actions.zoomIn;
|
|
case 'zoom_out':
|
|
return t.hotkeys.actions.zoomOut;
|
|
case 'zoom_reset':
|
|
return t.hotkeys.actions.zoomReset;
|
|
default:
|
|
return action;
|
|
}
|
|
}
|
|
|
|
// Check if a hotkey is already assigned to another action
|
|
String? getActionForHotkey(HotKey hotkey) {
|
|
for (final entry in _hotkeys.entries) {
|
|
final assignedHotkey = entry.value;
|
|
if (assignedHotkey != null && _hotkeyEquals(assignedHotkey, 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));
|
|
}
|
|
}
|