From fa30e634b4e411f9807c29ccf712f3a9390d1b87 Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:36:50 +0200 Subject: [PATCH] feat: Customizable shortcuts/hotkeys (#439) This implements the logic for allowing hotkeys with modifiers. Implemented globalhotkeys and videocontrol hotkeys Also implements saving the forward backwards seconds to the user. Co-authored-by: PartyDonut --- lib/l10n/app_en.arb | 28 ++- lib/models/account_model.dart | 11 + lib/models/account_model.freezed.dart | 218 +++++++++++++++++- lib/models/account_model.g.dart | 21 ++ .../settings/client_settings_model.dart | 33 +++ .../client_settings_model.freezed.dart | 89 ++++--- .../settings/client_settings_model.g.dart | 15 ++ lib/models/settings/key_combinations.dart | 70 ++++++ .../settings/key_combinations.freezed.dart | 79 +++++++ lib/models/settings/key_combinations.g.dart | 34 +++ .../settings/video_player_settings.dart | 62 +++++ .../video_player_settings.freezed.dart | 41 +++- .../settings/video_player_settings.g.dart | 26 +++ lib/providers/connectivity_provider.g.dart | 2 +- lib/providers/service_provider.dart | 59 +++++ .../settings/client_settings_provider.dart | 12 + .../video_player_settings_provider.dart | 49 ++++ .../sync/sync_provider_helpers.g.dart | 4 +- lib/providers/user_provider.dart | 23 ++ lib/providers/user_provider.g.dart | 2 +- lib/screens/home_screen.dart | 49 +++- .../client_settings_shortcuts.dart | 50 ++++ .../settings/client_settings_page.dart | 5 + .../settings/player_settings_page.dart | 67 ++++++ .../settings/widgets/key_listener.dart | 172 ++++++++++++++ .../video_player_seek_indicator.dart | 59 ++--- .../components/video_volume_slider.dart | 11 +- .../video_player/video_player_controls.dart | 182 +++++++++------ lib/util/input_handler.dart | 49 +++- 29 files changed, 1360 insertions(+), 162 deletions(-) create mode 100644 lib/models/settings/key_combinations.dart create mode 100644 lib/models/settings/key_combinations.freezed.dart create mode 100644 lib/models/settings/key_combinations.g.dart create mode 100644 lib/screens/settings/client_sections/client_settings_shortcuts.dart create mode 100644 lib/screens/settings/widgets/key_listener.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 668b991..033e3a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1139,7 +1139,7 @@ }, "noVideoPlayerOptions": "The selected backend has no options", "mdkExperimental": "MDK is still in a experimental stage", - "skipButtonLabel": "(S)kip {segment}", + "skipButtonLabel": "Skip {segment}", "@skipButtonLabel": { "placeholders": { "segment": { @@ -1292,5 +1292,29 @@ "syncAllFiles": "Sync all files", "usePostersForLibraryIconsTitle": "Show posters for library icons", "usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries", - "offline": "Offline" + "offline": "Offline", + "shortCuts": "Shortcuts", + "skipForwardLength": "Skip forward length", + "skipBackLength": "Skip back length", + "playPause": "Play/Pause", + "seekForward": "Seek Forward", + "seekBack": "Seek Back", + "mute": "Mute", + "volumeUp": "Volume Up", + "volumeDown": "Volume Down", + "nextVideo": "Next Video", + "prevVideo": "Previous Video", + "nextChapter": "Next Chapter", + "prevChapter": "Previous Chapter", + "fullScreen": "Full Screen", + "skipMediaSegment": "Skip Media Segment", + "exit": "Exit", + "shortCutAlreadyAssigned": "Shortcut '{hotKey}' already assigned", + "@shortCutAlreadyAssigned": { + "placeholders": { + "hotKey": { + "type": "String" + } + } + } } \ No newline at end of file diff --git a/lib/models/account_model.dart b/lib/models/account_model.dart index 16bd46f..d9e558a 100644 --- a/lib/models/account_model.dart +++ b/lib/models/account_model.dart @@ -35,6 +35,7 @@ class AccountModel with _$AccountModel { @JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? userConfiguration, + UserSettings? userSettings, }) = _AccountModel; factory AccountModel.fromJson(Map json) => _$AccountModelFromJson(json); @@ -50,6 +51,16 @@ class AccountModel with _$AccountModel { } } +@Freezed(copyWith: true) +class UserSettings with _$UserSettings { + factory UserSettings({ + @Default(Duration(seconds: 30)) Duration skipForwardDuration, + @Default(Duration(seconds: 10)) Duration skipBackDuration, + }) = _UserSettings; + + factory UserSettings.fromJson(Map json) => _$UserSettingsFromJson(json); +} + enum Authentication { autoLogin(0), biometrics(1), diff --git a/lib/models/account_model.freezed.dart b/lib/models/account_model.freezed.dart index e251539..a29ac5b 100644 --- a/lib/models/account_model.freezed.dart +++ b/lib/models/account_model.freezed.dart @@ -40,6 +40,7 @@ mixin _$AccountModel { @JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? get userConfiguration => throw _privateConstructorUsedError; + UserSettings? get userSettings => throw _privateConstructorUsedError; /// Serializes this AccountModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -73,7 +74,10 @@ abstract class $AccountModelCopyWith<$Res> { @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) - UserConfiguration? userConfiguration}); + UserConfiguration? userConfiguration, + UserSettings? userSettings}); + + $UserSettingsCopyWith<$Res>? get userSettings; } /// @nodoc @@ -105,6 +109,7 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel> Object? policy = freezed, Object? serverConfiguration = freezed, Object? userConfiguration = freezed, + Object? userSettings = freezed, }) { return _then(_value.copyWith( name: null == name @@ -163,8 +168,26 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel> ? _value.userConfiguration : userConfiguration // ignore: cast_nullable_to_non_nullable as UserConfiguration?, + userSettings: freezed == userSettings + ? _value.userSettings + : userSettings // ignore: cast_nullable_to_non_nullable + as UserSettings?, ) as $Val); } + + /// Create a copy of AccountModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UserSettingsCopyWith<$Res>? get userSettings { + if (_value.userSettings == null) { + return null; + } + + return $UserSettingsCopyWith<$Res>(_value.userSettings!, (value) { + return _then(_value.copyWith(userSettings: value) as $Val); + }); + } } /// @nodoc @@ -191,7 +214,11 @@ abstract class _$$AccountModelImplCopyWith<$Res> @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) - UserConfiguration? userConfiguration}); + UserConfiguration? userConfiguration, + UserSettings? userSettings}); + + @override + $UserSettingsCopyWith<$Res>? get userSettings; } /// @nodoc @@ -221,6 +248,7 @@ class __$$AccountModelImplCopyWithImpl<$Res> Object? policy = freezed, Object? serverConfiguration = freezed, Object? userConfiguration = freezed, + Object? userSettings = freezed, }) { return _then(_$AccountModelImpl( name: null == name @@ -279,6 +307,10 @@ class __$$AccountModelImplCopyWithImpl<$Res> ? _value.userConfiguration : userConfiguration // ignore: cast_nullable_to_non_nullable as UserConfiguration?, + userSettings: freezed == userSettings + ? _value.userSettings + : userSettings // ignore: cast_nullable_to_non_nullable + as UserSettings?, )); } } @@ -302,7 +334,8 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { @JsonKey(includeFromJson: false, includeToJson: false) this.serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) - this.userConfiguration}) + this.userConfiguration, + this.userSettings}) : _latestItemsExcludes = latestItemsExcludes, _searchQueryHistory = searchQueryHistory, _savedFilters = savedFilters, @@ -368,10 +401,12 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { @override @JsonKey(includeFromJson: false, includeToJson: false) final UserConfiguration? userConfiguration; + @override + final UserSettings? userSettings; @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'AccountModel(name: $name, id: $id, avatar: $avatar, lastUsed: $lastUsed, authMethod: $authMethod, localPin: $localPin, credentials: $credentials, latestItemsExcludes: $latestItemsExcludes, searchQueryHistory: $searchQueryHistory, quickConnectState: $quickConnectState, savedFilters: $savedFilters, policy: $policy, serverConfiguration: $serverConfiguration, userConfiguration: $userConfiguration)'; + return 'AccountModel(name: $name, id: $id, avatar: $avatar, lastUsed: $lastUsed, authMethod: $authMethod, localPin: $localPin, credentials: $credentials, latestItemsExcludes: $latestItemsExcludes, searchQueryHistory: $searchQueryHistory, quickConnectState: $quickConnectState, savedFilters: $savedFilters, policy: $policy, serverConfiguration: $serverConfiguration, userConfiguration: $userConfiguration, userSettings: $userSettings)'; } @override @@ -392,7 +427,8 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { ..add(DiagnosticsProperty('savedFilters', savedFilters)) ..add(DiagnosticsProperty('policy', policy)) ..add(DiagnosticsProperty('serverConfiguration', serverConfiguration)) - ..add(DiagnosticsProperty('userConfiguration', userConfiguration)); + ..add(DiagnosticsProperty('userConfiguration', userConfiguration)) + ..add(DiagnosticsProperty('userSettings', userSettings)); } /// Create a copy of AccountModel @@ -429,7 +465,8 @@ abstract class _AccountModel extends AccountModel { @JsonKey(includeFromJson: false, includeToJson: false) final ServerConfiguration? serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) - final UserConfiguration? userConfiguration}) = _$AccountModelImpl; + final UserConfiguration? userConfiguration, + final UserSettings? userSettings}) = _$AccountModelImpl; const _AccountModel._() : super._(); factory _AccountModel.fromJson(Map json) = @@ -466,6 +503,8 @@ abstract class _AccountModel extends AccountModel { @override @JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? get userConfiguration; + @override + UserSettings? get userSettings; /// Create a copy of AccountModel /// with the given fields replaced by the non-null parameter values. @@ -474,3 +513,170 @@ abstract class _AccountModel extends AccountModel { _$$AccountModelImplCopyWith<_$AccountModelImpl> get copyWith => throw _privateConstructorUsedError; } + +UserSettings _$UserSettingsFromJson(Map json) { + return _UserSettings.fromJson(json); +} + +/// @nodoc +mixin _$UserSettings { + Duration get skipForwardDuration => throw _privateConstructorUsedError; + Duration get skipBackDuration => throw _privateConstructorUsedError; + + /// Serializes this UserSettings to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of UserSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $UserSettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UserSettingsCopyWith<$Res> { + factory $UserSettingsCopyWith( + UserSettings value, $Res Function(UserSettings) then) = + _$UserSettingsCopyWithImpl<$Res, UserSettings>; + @useResult + $Res call({Duration skipForwardDuration, Duration skipBackDuration}); +} + +/// @nodoc +class _$UserSettingsCopyWithImpl<$Res, $Val extends UserSettings> + implements $UserSettingsCopyWith<$Res> { + _$UserSettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of UserSettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? skipForwardDuration = null, + Object? skipBackDuration = null, + }) { + return _then(_value.copyWith( + skipForwardDuration: null == skipForwardDuration + ? _value.skipForwardDuration + : skipForwardDuration // ignore: cast_nullable_to_non_nullable + as Duration, + skipBackDuration: null == skipBackDuration + ? _value.skipBackDuration + : skipBackDuration // ignore: cast_nullable_to_non_nullable + as Duration, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$UserSettingsImplCopyWith<$Res> + implements $UserSettingsCopyWith<$Res> { + factory _$$UserSettingsImplCopyWith( + _$UserSettingsImpl value, $Res Function(_$UserSettingsImpl) then) = + __$$UserSettingsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({Duration skipForwardDuration, Duration skipBackDuration}); +} + +/// @nodoc +class __$$UserSettingsImplCopyWithImpl<$Res> + extends _$UserSettingsCopyWithImpl<$Res, _$UserSettingsImpl> + implements _$$UserSettingsImplCopyWith<$Res> { + __$$UserSettingsImplCopyWithImpl( + _$UserSettingsImpl _value, $Res Function(_$UserSettingsImpl) _then) + : super(_value, _then); + + /// Create a copy of UserSettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? skipForwardDuration = null, + Object? skipBackDuration = null, + }) { + return _then(_$UserSettingsImpl( + skipForwardDuration: null == skipForwardDuration + ? _value.skipForwardDuration + : skipForwardDuration // ignore: cast_nullable_to_non_nullable + as Duration, + skipBackDuration: null == skipBackDuration + ? _value.skipBackDuration + : skipBackDuration // ignore: cast_nullable_to_non_nullable + as Duration, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$UserSettingsImpl with DiagnosticableTreeMixin implements _UserSettings { + _$UserSettingsImpl( + {this.skipForwardDuration = const Duration(seconds: 30), + this.skipBackDuration = const Duration(seconds: 10)}); + + factory _$UserSettingsImpl.fromJson(Map json) => + _$$UserSettingsImplFromJson(json); + + @override + @JsonKey() + final Duration skipForwardDuration; + @override + @JsonKey() + final Duration skipBackDuration; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'UserSettings(skipForwardDuration: $skipForwardDuration, skipBackDuration: $skipBackDuration)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'UserSettings')) + ..add(DiagnosticsProperty('skipForwardDuration', skipForwardDuration)) + ..add(DiagnosticsProperty('skipBackDuration', skipBackDuration)); + } + + /// Create a copy of UserSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$UserSettingsImplCopyWith<_$UserSettingsImpl> get copyWith => + __$$UserSettingsImplCopyWithImpl<_$UserSettingsImpl>(this, _$identity); + + @override + Map toJson() { + return _$$UserSettingsImplToJson( + this, + ); + } +} + +abstract class _UserSettings implements UserSettings { + factory _UserSettings( + {final Duration skipForwardDuration, + final Duration skipBackDuration}) = _$UserSettingsImpl; + + factory _UserSettings.fromJson(Map json) = + _$UserSettingsImpl.fromJson; + + @override + Duration get skipForwardDuration; + @override + Duration get skipBackDuration; + + /// Create a copy of UserSettings + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$UserSettingsImplCopyWith<_$UserSettingsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/account_model.g.dart b/lib/models/account_model.g.dart index ac7c8f9..316e0c8 100644 --- a/lib/models/account_model.g.dart +++ b/lib/models/account_model.g.dart @@ -31,6 +31,9 @@ _$AccountModelImpl _$$AccountModelImplFromJson(Map json) => LibraryFiltersModel.fromJson(e as Map)) .toList() ?? const [], + userSettings: json['userSettings'] == null + ? null + : UserSettings.fromJson(json['userSettings'] as Map), ); Map _$$AccountModelImplToJson(_$AccountModelImpl instance) => @@ -46,6 +49,7 @@ Map _$$AccountModelImplToJson(_$AccountModelImpl instance) => 'searchQueryHistory': instance.searchQueryHistory, 'quickConnectState': instance.quickConnectState, 'savedFilters': instance.savedFilters, + 'userSettings': instance.userSettings, }; const _$AuthenticationEnumMap = { @@ -54,3 +58,20 @@ const _$AuthenticationEnumMap = { Authentication.passcode: 'passcode', Authentication.none: 'none', }; + +_$UserSettingsImpl _$$UserSettingsImplFromJson(Map json) => + _$UserSettingsImpl( + skipForwardDuration: json['skipForwardDuration'] == null + ? const Duration(seconds: 30) + : Duration( + microseconds: (json['skipForwardDuration'] as num).toInt()), + skipBackDuration: json['skipBackDuration'] == null + ? const Duration(seconds: 10) + : Duration(microseconds: (json['skipBackDuration'] as num).toInt()), + ); + +Map _$$UserSettingsImplToJson(_$UserSettingsImpl instance) => + { + 'skipForwardDuration': instance.skipForwardDuration.inMicroseconds, + 'skipBackDuration': instance.skipBackDuration.inMicroseconds, + }; diff --git a/lib/models/settings/client_settings_model.dart b/lib/models/settings/client_settings_model.dart index 15a2b3d..404d85d 100644 --- a/lib/models/settings/client_settings_model.dart +++ b/lib/models/settings/client_settings_model.dart @@ -3,17 +3,35 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/util/custom_color_themes.dart'; +import 'package:fladder/util/localization_helper.dart'; part 'client_settings_model.freezed.dart'; part 'client_settings_model.g.dart'; +enum GlobalHotKeys { + search, + exit; + + const GlobalHotKeys(); + + String label(BuildContext context) { + return switch (this) { + GlobalHotKeys.search => context.localized.search, + GlobalHotKeys.exit => context.localized.exitFladderTitle, + }; + } +} + @Freezed(copyWith: true) class ClientSettingsModel with _$ClientSettingsModel { const ClientSettingsModel._(); + factory ClientSettingsModel({ String? syncPath, @Default(Vector2(x: 0, y: 0)) Vector2 position, @@ -39,10 +57,16 @@ class ClientSettingsModel with _$ClientSettingsModel { @Default(false) bool usePosterForLibrary, String? lastViewedUpdate, int? libraryPageSize, + @Default({}) Map shortcuts, }) = _ClientSettingsModel; factory ClientSettingsModel.fromJson(Map json) => _$ClientSettingsModelFromJson(json); + Map get currentShortcuts => + _defaultGlobalHotKeys.map((key, value) => MapEntry(key, shortcuts[key] ?? value)); + + Map get defaultShortCuts => _defaultGlobalHotKeys; + Brightness statusBarBrightness(BuildContext context) { return switch (themeMode) { ThemeMode.dark => Brightness.light, @@ -132,3 +156,12 @@ class Vector2 { static Vector2 fromPosition(Offset windowPosition) => Vector2(x: windowPosition.dx, y: windowPosition.dy); } + +Map get _defaultGlobalHotKeys => { + for (var hotKey in GlobalHotKeys.values) + hotKey: switch (hotKey) { + GlobalHotKeys.search => + KeyCombination(key: LogicalKeyboardKey.keyK, modifier: LogicalKeyboardKey.controlLeft), + GlobalHotKeys.exit => KeyCombination(key: LogicalKeyboardKey.keyQ, modifier: LogicalKeyboardKey.controlLeft), + }, + }; diff --git a/lib/models/settings/client_settings_model.freezed.dart b/lib/models/settings/client_settings_model.freezed.dart index ffa166f..e1dfe6f 100644 --- a/lib/models/settings/client_settings_model.freezed.dart +++ b/lib/models/settings/client_settings_model.freezed.dart @@ -45,6 +45,8 @@ mixin _$ClientSettingsModel { bool get usePosterForLibrary => throw _privateConstructorUsedError; String? get lastViewedUpdate => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError; + Map get shortcuts => + throw _privateConstructorUsedError; /// Serializes this ClientSettingsModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -86,7 +88,8 @@ abstract class $ClientSettingsModelCopyWith<$Res> { bool checkForUpdates, bool usePosterForLibrary, String? lastViewedUpdate, - int? libraryPageSize}); + int? libraryPageSize, + Map shortcuts}); } /// @nodoc @@ -128,6 +131,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> Object? usePosterForLibrary = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, + Object? shortcuts = null, }) { return _then(_value.copyWith( syncPath: freezed == syncPath @@ -226,6 +230,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> ? _value.libraryPageSize : libraryPageSize // ignore: cast_nullable_to_non_nullable as int?, + shortcuts: null == shortcuts + ? _value.shortcuts + : shortcuts // ignore: cast_nullable_to_non_nullable + as Map, ) as $Val); } } @@ -262,7 +270,8 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res> bool checkForUpdates, bool usePosterForLibrary, String? lastViewedUpdate, - int? libraryPageSize}); + int? libraryPageSize, + Map shortcuts}); } /// @nodoc @@ -302,6 +311,7 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> Object? usePosterForLibrary = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, + Object? shortcuts = null, }) { return _then(_$ClientSettingsModelImpl( syncPath: freezed == syncPath @@ -400,6 +410,10 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> ? _value.libraryPageSize : libraryPageSize // ignore: cast_nullable_to_non_nullable as int?, + shortcuts: null == shortcuts + ? _value._shortcuts + : shortcuts // ignore: cast_nullable_to_non_nullable + as Map, )); } } @@ -432,8 +446,10 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel this.checkForUpdates = true, this.usePosterForLibrary = false, this.lastViewedUpdate, - this.libraryPageSize}) - : super._(); + this.libraryPageSize, + final Map shortcuts = const {}}) + : _shortcuts = shortcuts, + super._(); factory _$ClientSettingsModelImpl.fromJson(Map json) => _$$ClientSettingsModelImplFromJson(json); @@ -505,10 +521,18 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel final String? lastViewedUpdate; @override final int? libraryPageSize; + final Map _shortcuts; + @override + @JsonKey() + Map get shortcuts { + if (_shortcuts is EqualUnmodifiableMapView) return _shortcuts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_shortcuts); + } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundPosters: $backgroundPosters, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundPosters: $backgroundPosters, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; } @override @@ -541,7 +565,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) - ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)); + ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)) + ..add(DiagnosticsProperty('shortcuts', shortcuts)); } /// Create a copy of ClientSettingsModel @@ -563,30 +588,32 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel abstract class _ClientSettingsModel extends ClientSettingsModel { factory _ClientSettingsModel( - {final String? syncPath, - final Vector2 position, - final Vector2 size, - final Duration? timeOut, - final Duration? nextUpDateCutoff, - final ThemeMode themeMode, - final ColorThemes? themeColor, - final bool amoledBlack, - final bool blurPlaceHolders, - final bool blurUpcomingEpisodes, - @LocaleConvert() final Locale? selectedLocale, - final bool enableMediaKeys, - final double posterSize, - final bool pinchPosterZoom, - final bool mouseDragSupport, - final bool requireWifi, - final bool showAllCollectionTypes, - final int maxConcurrentDownloads, - final DynamicSchemeVariant schemeVariant, - final bool backgroundPosters, - final bool checkForUpdates, - final bool usePosterForLibrary, - final String? lastViewedUpdate, - final int? libraryPageSize}) = _$ClientSettingsModelImpl; + {final String? syncPath, + final Vector2 position, + final Vector2 size, + final Duration? timeOut, + final Duration? nextUpDateCutoff, + final ThemeMode themeMode, + final ColorThemes? themeColor, + final bool amoledBlack, + final bool blurPlaceHolders, + final bool blurUpcomingEpisodes, + @LocaleConvert() final Locale? selectedLocale, + final bool enableMediaKeys, + final double posterSize, + final bool pinchPosterZoom, + final bool mouseDragSupport, + final bool requireWifi, + final bool showAllCollectionTypes, + final int maxConcurrentDownloads, + final DynamicSchemeVariant schemeVariant, + final bool backgroundPosters, + final bool checkForUpdates, + final bool usePosterForLibrary, + final String? lastViewedUpdate, + final int? libraryPageSize, + final Map shortcuts}) = + _$ClientSettingsModelImpl; _ClientSettingsModel._() : super._(); factory _ClientSettingsModel.fromJson(Map json) = @@ -641,6 +668,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { String? get lastViewedUpdate; @override int? get libraryPageSize; + @override + Map get shortcuts; /// Create a copy of ClientSettingsModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/settings/client_settings_model.g.dart b/lib/models/settings/client_settings_model.g.dart index ed42ef6..484963f 100644 --- a/lib/models/settings/client_settings_model.g.dart +++ b/lib/models/settings/client_settings_model.g.dart @@ -46,6 +46,14 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson( usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false, lastViewedUpdate: json['lastViewedUpdate'] as String?, libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), + shortcuts: (json['shortcuts'] as Map?)?.map( + (k, e) => MapEntry( + $enumDecode(_$GlobalHotKeysEnumMap, k), + e == null + ? null + : KeyCombination.fromJson(e as Map)), + ) ?? + const {}, ); Map _$$ClientSettingsModelImplToJson( @@ -75,6 +83,8 @@ Map _$$ClientSettingsModelImplToJson( 'usePosterForLibrary': instance.usePosterForLibrary, 'lastViewedUpdate': instance.lastViewedUpdate, 'libraryPageSize': instance.libraryPageSize, + 'shortcuts': instance.shortcuts + .map((k, e) => MapEntry(_$GlobalHotKeysEnumMap[k]!, e)), }; const _$ThemeModeEnumMap = { @@ -112,3 +122,8 @@ const _$DynamicSchemeVariantEnumMap = { DynamicSchemeVariant.rainbow: 'rainbow', DynamicSchemeVariant.fruitSalad: 'fruitSalad', }; + +const _$GlobalHotKeysEnumMap = { + GlobalHotKeys.search: 'search', + GlobalHotKeys.exit: 'exit', +}; diff --git a/lib/models/settings/key_combinations.dart b/lib/models/settings/key_combinations.dart new file mode 100644 index 0000000..92bb871 --- /dev/null +++ b/lib/models/settings/key_combinations.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:fladder/screens/settings/widgets/key_listener.dart'; + +part 'key_combinations.freezed.dart'; +part 'key_combinations.g.dart'; + +@Freezed(toJson: true, fromJson: true) +class KeyCombination with _$KeyCombination { + const KeyCombination._(); + + factory KeyCombination({ + @LogicalKeyboardSerializer() LogicalKeyboardKey? modifier, + @LogicalKeyboardSerializer() required LogicalKeyboardKey key, + }) = _KeyCombination; + + factory KeyCombination.fromJson(Map json) => _$KeyCombinationFromJson(json); + + @override + bool operator ==(covariant other) { + return other is KeyCombination && other.key.keyId == key.keyId && other.modifier?.keyId == modifier?.keyId; + } + + @override + int get hashCode => key.hashCode ^ modifier.hashCode; + + String get label => [modifier?.label, key.label].nonNulls.join(" + "); + + static final Set shiftKeys = { + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }; + + static final altKeys = { + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.altLeft, + }; + + static final ctrlKeys = { + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + }; + + static final modifierKeys = { + ...shiftKeys, + ...altKeys, + ...ctrlKeys, + }; +} + +class LogicalKeyboardSerializer extends JsonConverter { + const LogicalKeyboardSerializer(); + + @override + LogicalKeyboardKey fromJson(String json) { + return LogicalKeyboardKey.findKeyByKeyId(int.parse(jsonDecode(json))) ?? LogicalKeyboardKey.abort; + } + + @override + String toJson(LogicalKeyboardKey object) { + return jsonEncode(object.keyId.toString()); + } +} diff --git a/lib/models/settings/key_combinations.freezed.dart b/lib/models/settings/key_combinations.freezed.dart new file mode 100644 index 0000000..9eea8a7 --- /dev/null +++ b/lib/models/settings/key_combinations.freezed.dart @@ -0,0 +1,79 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'key_combinations.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +KeyCombination _$KeyCombinationFromJson(Map json) { + return _KeyCombination.fromJson(json); +} + +/// @nodoc +mixin _$KeyCombination { + @LogicalKeyboardSerializer() + LogicalKeyboardKey? get modifier => throw _privateConstructorUsedError; + @LogicalKeyboardSerializer() + LogicalKeyboardKey get key => throw _privateConstructorUsedError; + + /// Serializes this KeyCombination to a JSON map. + Map toJson() => throw _privateConstructorUsedError; +} + +/// @nodoc +@JsonSerializable() +class _$KeyCombinationImpl extends _KeyCombination { + _$KeyCombinationImpl( + {@LogicalKeyboardSerializer() this.modifier, + @LogicalKeyboardSerializer() required this.key}) + : super._(); + + factory _$KeyCombinationImpl.fromJson(Map json) => + _$$KeyCombinationImplFromJson(json); + + @override + @LogicalKeyboardSerializer() + final LogicalKeyboardKey? modifier; + @override + @LogicalKeyboardSerializer() + final LogicalKeyboardKey key; + + @override + String toString() { + return 'KeyCombination(modifier: $modifier, key: $key)'; + } + + @override + Map toJson() { + return _$$KeyCombinationImplToJson( + this, + ); + } +} + +abstract class _KeyCombination extends KeyCombination { + factory _KeyCombination( + {@LogicalKeyboardSerializer() final LogicalKeyboardKey? modifier, + @LogicalKeyboardSerializer() required final LogicalKeyboardKey key}) = + _$KeyCombinationImpl; + _KeyCombination._() : super._(); + + factory _KeyCombination.fromJson(Map json) = + _$KeyCombinationImpl.fromJson; + + @override + @LogicalKeyboardSerializer() + LogicalKeyboardKey? get modifier; + @override + @LogicalKeyboardSerializer() + LogicalKeyboardKey get key; +} diff --git a/lib/models/settings/key_combinations.g.dart b/lib/models/settings/key_combinations.g.dart new file mode 100644 index 0000000..7cb3cd8 --- /dev/null +++ b/lib/models/settings/key_combinations.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'key_combinations.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$KeyCombinationImpl _$$KeyCombinationImplFromJson(Map json) => + _$KeyCombinationImpl( + modifier: _$JsonConverterFromJson( + json['modifier'], const LogicalKeyboardSerializer().fromJson), + key: const LogicalKeyboardSerializer().fromJson(json['key'] as String), + ); + +Map _$$KeyCombinationImplToJson( + _$KeyCombinationImpl instance) => + { + 'modifier': _$JsonConverterToJson( + instance.modifier, const LogicalKeyboardSerializer().toJson), + 'key': const LogicalKeyboardSerializer().toJson(instance.key), + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => + json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => + value == null ? null : toJson(value); diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 38d2f23..b22da94 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -6,12 +6,49 @@ import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:fladder/models/items/media_segments_model.dart'; +import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/localization_helper.dart'; part 'video_player_settings.freezed.dart'; part 'video_player_settings.g.dart'; +enum VideoHotKeys { + playPause, + seekForward, + seekBack, + mute, + volumeUp, + volumeDown, + nextVideo, + prevVideo, + nextChapter, + prevChapter, + fullScreen, + skipMediaSegment, + exit; + + const VideoHotKeys(); + + String label(BuildContext context) { + return switch (this) { + VideoHotKeys.playPause => context.localized.playPause, + VideoHotKeys.seekForward => context.localized.seekForward, + VideoHotKeys.seekBack => context.localized.seekBack, + VideoHotKeys.mute => context.localized.mute, + VideoHotKeys.volumeUp => context.localized.volumeUp, + VideoHotKeys.volumeDown => context.localized.volumeDown, + VideoHotKeys.nextVideo => context.localized.nextVideo, + VideoHotKeys.prevVideo => context.localized.prevVideo, + VideoHotKeys.nextChapter => context.localized.nextChapter, + VideoHotKeys.prevChapter => context.localized.prevChapter, + VideoHotKeys.fullScreen => context.localized.fullScreen, + VideoHotKeys.skipMediaSegment => context.localized.skipMediaSegment, + VideoHotKeys.exit => context.localized.exit, + }; + } +} + @Freezed(copyWith: true) class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { const VideoPlayerSettingsModel._(); @@ -31,6 +68,7 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(Bitrate.original) Bitrate maxInternetBitrate, String? audioDevice, @Default(defaultSegmentSkipValues) Map segmentSkipSettings, + @Default({}) Map hotKeys, }) = _VideoPlayerSettingsModel; double get volume => switch (defaultTargetPlatform) { @@ -42,6 +80,11 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { PlayerOptions get wantedPlayer => playerOptions ?? PlayerOptions.platformDefaults; + Map get currentShortcuts => + _defaultVideoHotKeys.map((key, value) => MapEntry(key, hotKeys[key] ?? value)); + + Map get defaultShortCuts => _defaultVideoHotKeys; + bool playerSame(VideoPlayerSettingsModel other) { return other.hardwareAccel == hardwareAccel && other.useLibass == useLibass && @@ -118,3 +161,22 @@ enum AutoNextType { AutoNextType.static => context.localized.autoNextOffStaticDesc, }; } + +Map get _defaultVideoHotKeys => { + for (var hotKey in VideoHotKeys.values) + hotKey: switch (hotKey) { + VideoHotKeys.playPause => KeyCombination(key: LogicalKeyboardKey.space), + VideoHotKeys.seekForward => KeyCombination(key: LogicalKeyboardKey.arrowRight), + VideoHotKeys.seekBack => KeyCombination(key: LogicalKeyboardKey.arrowLeft), + VideoHotKeys.mute => KeyCombination(key: LogicalKeyboardKey.keyM), + VideoHotKeys.volumeUp => KeyCombination(key: LogicalKeyboardKey.arrowUp), + VideoHotKeys.volumeDown => KeyCombination(key: LogicalKeyboardKey.arrowDown), + VideoHotKeys.prevVideo => KeyCombination(key: LogicalKeyboardKey.keyP, modifier: LogicalKeyboardKey.shift), + VideoHotKeys.nextVideo => KeyCombination(key: LogicalKeyboardKey.keyN, modifier: LogicalKeyboardKey.shift), + VideoHotKeys.nextChapter => KeyCombination(key: LogicalKeyboardKey.pageUp), + VideoHotKeys.prevChapter => KeyCombination(key: LogicalKeyboardKey.pageDown), + VideoHotKeys.fullScreen => KeyCombination(key: LogicalKeyboardKey.keyF), + VideoHotKeys.skipMediaSegment => KeyCombination(key: LogicalKeyboardKey.keyS), + VideoHotKeys.exit => KeyCombination(key: LogicalKeyboardKey.escape), + }, + }; diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 736ca45..a08e5b4 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -37,6 +37,8 @@ mixin _$VideoPlayerSettingsModel { String? get audioDevice => throw _privateConstructorUsedError; Map get segmentSkipSettings => throw _privateConstructorUsedError; + Map get hotKeys => + throw _privateConstructorUsedError; /// Serializes this VideoPlayerSettingsModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -68,7 +70,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> { Bitrate maxHomeBitrate, Bitrate maxInternetBitrate, String? audioDevice, - Map segmentSkipSettings}); + Map segmentSkipSettings, + Map hotKeys}); } /// @nodoc @@ -101,6 +104,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, Object? maxInternetBitrate = null, Object? audioDevice = freezed, Object? segmentSkipSettings = null, + Object? hotKeys = null, }) { return _then(_value.copyWith( screenBrightness: freezed == screenBrightness @@ -159,6 +163,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, ? _value.segmentSkipSettings : segmentSkipSettings // ignore: cast_nullable_to_non_nullable as Map, + hotKeys: null == hotKeys + ? _value.hotKeys + : hotKeys // ignore: cast_nullable_to_non_nullable + as Map, ) as $Val); } } @@ -186,7 +194,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res> Bitrate maxHomeBitrate, Bitrate maxInternetBitrate, String? audioDevice, - Map segmentSkipSettings}); + Map segmentSkipSettings, + Map hotKeys}); } /// @nodoc @@ -218,6 +227,7 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> Object? maxInternetBitrate = null, Object? audioDevice = freezed, Object? segmentSkipSettings = null, + Object? hotKeys = null, }) { return _then(_$VideoPlayerSettingsModelImpl( screenBrightness: freezed == screenBrightness @@ -276,6 +286,10 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> ? _value._segmentSkipSettings : segmentSkipSettings // ignore: cast_nullable_to_non_nullable as Map, + hotKeys: null == hotKeys + ? _value._hotKeys + : hotKeys // ignore: cast_nullable_to_non_nullable + as Map, )); } } @@ -299,9 +313,11 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel this.maxInternetBitrate = Bitrate.original, this.audioDevice, final Map segmentSkipSettings = - defaultSegmentSkipValues}) + defaultSegmentSkipValues, + final Map hotKeys = const {}}) : _allowedOrientations = allowedOrientations, _segmentSkipSettings = segmentSkipSettings, + _hotKeys = hotKeys, super._(); factory _$VideoPlayerSettingsModelImpl.fromJson(Map json) => @@ -361,9 +377,18 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel return EqualUnmodifiableMapView(_segmentSkipSettings); } + final Map _hotKeys; + @override + @JsonKey() + Map get hotKeys { + if (_hotKeys is EqualUnmodifiableMapView) return _hotKeys; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_hotKeys); + } + @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys)'; } @override @@ -384,7 +409,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel ..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate)) ..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate)) ..add(DiagnosticsProperty('audioDevice', audioDevice)) - ..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings)); + ..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings)) + ..add(DiagnosticsProperty('hotKeys', hotKeys)); } /// Create a copy of VideoPlayerSettingsModel @@ -419,7 +445,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { final Bitrate maxHomeBitrate, final Bitrate maxInternetBitrate, final String? audioDevice, - final Map segmentSkipSettings}) = + final Map segmentSkipSettings, + final Map hotKeys}) = _$VideoPlayerSettingsModelImpl; _VideoPlayerSettingsModel._() : super._(); @@ -454,6 +481,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { String? get audioDevice; @override Map get segmentSkipSettings; + @override + Map get hotKeys; /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index a00fbb2..6068a4f 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -38,6 +38,14 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson( $enumDecode(_$SegmentSkipEnumMap, e)), ) ?? defaultSegmentSkipValues, + hotKeys: (json['hotKeys'] as Map?)?.map( + (k, e) => MapEntry( + $enumDecode(_$VideoHotKeysEnumMap, k), + e == null + ? null + : KeyCombination.fromJson(e as Map)), + ) ?? + const {}, ); Map _$$VideoPlayerSettingsModelImplToJson( @@ -60,6 +68,8 @@ Map _$$VideoPlayerSettingsModelImplToJson( 'audioDevice': instance.audioDevice, 'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) => MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), + 'hotKeys': instance.hotKeys + .map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)), }; const _$BoxFitEnumMap = { @@ -123,3 +133,19 @@ const _$MediaSegmentTypeEnumMap = { MediaSegmentType.outro: 'outro', MediaSegmentType.intro: 'intro', }; + +const _$VideoHotKeysEnumMap = { + VideoHotKeys.playPause: 'playPause', + VideoHotKeys.seekForward: 'seekForward', + VideoHotKeys.seekBack: 'seekBack', + VideoHotKeys.mute: 'mute', + VideoHotKeys.volumeUp: 'volumeUp', + VideoHotKeys.volumeDown: 'volumeDown', + VideoHotKeys.nextVideo: 'nextVideo', + VideoHotKeys.prevVideo: 'prevVideo', + VideoHotKeys.nextChapter: 'nextChapter', + VideoHotKeys.prevChapter: 'prevChapter', + VideoHotKeys.fullScreen: 'fullScreen', + VideoHotKeys.skipMediaSegment: 'skipMediaSegment', + VideoHotKeys.exit: 'exit', +}; diff --git a/lib/providers/connectivity_provider.g.dart b/lib/providers/connectivity_provider.g.dart index 648f44d..e4178ba 100644 --- a/lib/providers/connectivity_provider.g.dart +++ b/lib/providers/connectivity_provider.g.dart @@ -7,7 +7,7 @@ part of 'connectivity_provider.dart'; // ************************************************************************** String _$connectivityStatusHash() => - r'7a4ac96d163a479bd34fc6a3efcd556755f8d5e9'; + r'8c58479db511f2431942655adf3f5021e8f0290c'; /// See also [ConnectivityStatus]. @ProviderFor(ConnectivityStatus) diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 3fd1be6..938fb9d 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -23,6 +23,9 @@ import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/jellyfin_extension.dart'; +const _userSettings = "usersettings"; +const _client = "fladder"; + class ServerQueryResult { final List original; final List items; @@ -745,6 +748,34 @@ class JellyService { Future> systemConfigurationGet() => api.systemConfigurationGet(); Future> systemInfoPublicGet() => api.systemInfoPublicGet(); + Future> getCustomConfig() async { + final response = await api.displayPreferencesDisplayPreferencesIdGet( + displayPreferencesId: _userSettings, + userId: account?.id ?? "", + $client: _client, + ); + final customPrefs = response.body?.customPrefs?.parseValues(); + final userPrefs = customPrefs != null ? UserSettings.fromJson(customPrefs) : UserSettings(); + return response.copyWith( + body: userPrefs, + ); + } + + Future> setCustomConfig(UserSettings currentSettings) async { + final currentDisplayPreferences = await api.displayPreferencesDisplayPreferencesIdGet( + displayPreferencesId: _userSettings, + $client: _client, + ); + return api.displayPreferencesDisplayPreferencesIdPost( + displayPreferencesId: 'usersettings', + userId: account?.id ?? "", + $client: _client, + body: currentDisplayPreferences.body?.copyWith( + customPrefs: currentSettings.toJson(), + ), + ); + } + Future sessionsLogoutPost() => api.sessionsLogoutPost(); Future> itemsItemIdDownloadGet({ @@ -1116,3 +1147,31 @@ class JellyService { return _updateUserConfiguration(updated); } } + +extension ParsedMap on Map { + Map parseValues() { + Map parsedMap = {}; + + for (var entry in entries) { + String key = entry.key; + dynamic value = entry.value; + + if (value is String) { + // Try to parse the string to a number or boolean + if (int.tryParse(value) != null) { + parsedMap[key] = int.tryParse(value); + } else if (double.tryParse(value) != null) { + parsedMap[key] = double.tryParse(value); + } else if (value.toLowerCase() == 'true' || value.toLowerCase() == 'false') { + parsedMap[key] = value.toLowerCase() == 'true'; + } else { + parsedMap[key] = value; + } + } else { + parsedMap[key] = value; + } + } + + return parsedMap; + } +} diff --git a/lib/providers/settings/client_settings_provider.dart b/lib/providers/settings/client_settings_provider.dart index 0ca6fbf..d841dad 100644 --- a/lib/providers/settings/client_settings_provider.dart +++ b/lib/providers/settings/client_settings_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/settings/client_settings_model.dart'; +import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/util/custom_color_themes.dart'; import 'package:fladder/util/debouncer.dart'; @@ -58,4 +59,15 @@ class ClientSettingsNotifier extends StateNotifier { state = state.copyWith(schemeVariant: type ?? state.schemeVariant); void setRequireWifi(bool value) => state = state.copyWith(requireWifi: value); + + void setShortcuts(MapEntry mapEntry) { + final newShortCuts = Map.fromEntries(state.shortcuts.entries); + newShortCuts.update( + mapEntry.key, + (value) => mapEntry.value, + ifAbsent: () => mapEntry.value, + ); + newShortCuts.removeWhere((key, value) => value == null); + state = state.copyWith(shortcuts: newShortCuts); + } } diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index 4e52f89..a98445b 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; @@ -67,4 +69,51 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier? orientation) => state = state.copyWith(allowedOrientations: orientation); + + void setShortcuts(MapEntry newEntry) { + final currentShortcuts = Map.fromEntries(state.hotKeys.entries); + currentShortcuts.update( + newEntry.key, + (value) => newEntry.value, + ifAbsent: () => newEntry.value, + ); + currentShortcuts.removeWhere((key, value) => value == null); + state = state.copyWith(hotKeys: currentShortcuts); + } + + void nextChapter() { + final chapters = ref.read(playBackModel)?.chapters ?? []; + final currentPosition = ref.read(videoPlayerProvider.select((value) => value.lastState?.position)); + + if (chapters.isNotEmpty && currentPosition != null) { + final currentChapter = chapters.lastWhereOrNull((element) => element.startPosition <= currentPosition); + + if (currentChapter != null) { + final nextChapterIndex = chapters.indexOf(currentChapter) + 1; + if (nextChapterIndex < chapters.length) { + ref.read(videoPlayerProvider).seek(chapters[nextChapterIndex].startPosition); + } else { + ref.read(videoPlayerProvider).seek(currentChapter.startPosition); + } + } + } + } + + void prevChapter() { + final chapters = ref.read(playBackModel)?.chapters ?? []; + final currentPosition = ref.read(videoPlayerProvider.select((value) => value.lastState?.position)); + + if (chapters.isNotEmpty && currentPosition != null) { + final currentChapter = chapters.lastWhereOrNull((element) => element.startPosition <= currentPosition); + + if (currentChapter != null) { + final prevChapterIndex = chapters.indexOf(currentChapter) - 1; + if (prevChapterIndex >= 0) { + ref.read(videoPlayerProvider).seek(chapters[prevChapterIndex].startPosition); + } else { + ref.read(videoPlayerProvider).seek(currentChapter.startPosition); + } + } + } + } } diff --git a/lib/providers/sync/sync_provider_helpers.g.dart b/lib/providers/sync/sync_provider_helpers.g.dart index 82d5456..243d991 100644 --- a/lib/providers/sync/sync_provider_helpers.g.dart +++ b/lib/providers/sync/sync_provider_helpers.g.dart @@ -6,7 +6,7 @@ part of 'sync_provider_helpers.dart'; // RiverpodGenerator // ************************************************************************** -String _$syncedItemHash() => r'7b1178ba78529ebf65425aa4cb8b239a28c7914b'; +String _$syncedItemHash() => r'8342c557accf52fd0a8561274ecf9b77b5cf7acd'; /// Copied from Dart SDK class _SystemHash { @@ -157,7 +157,7 @@ class _SyncedItemProviderElement ItemBaseModel? get item => (origin as SyncedItemProvider).item; } -String _$syncedChildrenHash() => r'2b6ce1611750785060df6317ce0ea25e2dc0aeb4'; +String _$syncedChildrenHash() => r'75e25432f33e0fe31708618b7ba744430523a4d3'; abstract class _$SyncedChildren extends BuildlessAutoDisposeAsyncNotifier> { diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index a09bd2b..b728c6a 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -40,6 +40,9 @@ class User extends _$User { var response = await api.usersMeGet(); var quickConnectStatus = await api.quickConnectEnabled(); var systemConfiguration = await api.systemConfigurationGet(); + + final customConfig = await api.getCustomConfig(); + if (response.isSuccessful && response.body != null) { userState = state?.copyWith( name: response.body?.name ?? state?.name ?? "", @@ -48,6 +51,7 @@ class User extends _$User { userConfiguration: response.body?.configuration, quickConnectState: quickConnectStatus.body ?? false, latestItemsExcludes: response.body?.configuration?.latestItemsExcludes ?? [], + userSettings: customConfig.body, ); return response.copyWith(body: state); } @@ -68,6 +72,25 @@ class User extends _$User { } } + void setBackwardSpeed(int value) { + final userSettings = state?.userSettings?.copyWith(skipBackDuration: Duration(seconds: value)); + if (userSettings != null) { + updateCustomConfig(userSettings); + } + } + + void setForwardSpeed(int value) { + final userSettings = state?.userSettings?.copyWith(skipForwardDuration: Duration(seconds: value)); + if (userSettings != null) { + updateCustomConfig(userSettings); + } + } + + Future> updateCustomConfig(UserSettings settings) async { + state = state?.copyWith(userSettings: settings); + return api.setCustomConfig(settings); + } + Future refreshMetaData( String itemId, { MetadataRefresh? metadataRefreshMode, diff --git a/lib/providers/user_provider.g.dart b/lib/providers/user_provider.g.dart index 07e79b3..da5edae 100644 --- a/lib/providers/user_provider.g.dart +++ b/lib/providers/user_provider.g.dart @@ -24,7 +24,7 @@ final showSyncButtonProviderProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ShowSyncButtonProviderRef = AutoDisposeProviderRef; -String _$userHash() => r'56fca6515c42347fa99dcdcf4f2d8a977335243a'; +String _$userHash() => r'24b34a88eae11aec1e377a82d1e507f293b7816a'; /// See also [User]. @ProviderFor(User) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d632371..f0aba63 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,9 +4,14 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:fladder/models/settings/client_settings_model.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; +import 'package:fladder/screens/shared/fladder_snackbar.dart'; +import 'package:fladder/util/input_handler.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; @@ -17,8 +22,7 @@ enum HomeTabs { dashboard, library, favorites, - sync, - ; + sync; const HomeTabs(); @@ -120,16 +124,37 @@ class HomeScreen extends ConsumerWidget { }) .nonNulls .toList(); - return HeroControllerScope( - controller: HeroController(), - child: AutoRouter( - builder: (context, child) { - return NavigationScaffold( - destinations: destinations.nonNulls.toList(), - currentRouteName: context.router.current.name, - nestedChild: child, - ); - }, + return InputHandler( + autoFocus: false, + keyMapResult: (result) { + switch (result) { + case GlobalHotKeys.search: + context.navigateTo(LibrarySearchRoute()); + return true; + case GlobalHotKeys.exit: + Future.microtask(() async { + final manager = WindowManager.instance; + if (await manager.isClosable()) { + manager.close(); + } else { + fladderSnackbar(context, title: context.localized.somethingWentWrong); + } + }); + return true; + } + }, + keyMap: ref.watch(clientSettingsProvider.select((value) => value.currentShortcuts)), + child: HeroControllerScope( + controller: HeroController(), + child: AutoRouter( + builder: (context, child) { + return NavigationScaffold( + destinations: destinations.nonNulls.toList(), + currentRouteName: context.router.current.name, + nestedChild: child, + ); + }, + ), ), ); } diff --git a/lib/screens/settings/client_sections/client_settings_shortcuts.dart b/lib/screens/settings/client_sections/client_settings_shortcuts.dart new file mode 100644 index 0000000..5a4a01b --- /dev/null +++ b/lib/screens/settings/client_sections/client_settings_shortcuts.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/settings/client_settings_model.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/screens/settings/widgets/key_listener.dart'; +import 'package:fladder/screens/settings/widgets/settings_label_divider.dart'; +import 'package:fladder/screens/settings/widgets/settings_list_group.dart'; +import 'package:fladder/util/localization_helper.dart'; + +List buildClientSettingsShortCuts( + BuildContext context, + WidgetRef ref, +) { + final clientSettings = ref.watch(clientSettingsProvider); + return settingsListGroup( + context, + SettingsLabelDivider(label: context.localized.shortCuts), + [ + ...GlobalHotKeys.values.map( + (entry) { + final currentEntry = clientSettings.shortcuts[entry]; + final defaultEntry = clientSettings.defaultShortCuts[entry]!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + entry.label(context), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Flexible( + child: KeyCombinationWidget( + currentKey: currentEntry, + defaultKey: defaultEntry, + onChanged: (value) => + ref.read(clientSettingsProvider.notifier).setShortcuts(MapEntry(entry, value)), + ), + ) + ], + ), + ); + }, + ) + ], + ); +} diff --git a/lib/screens/settings/client_settings_page.dart b/lib/screens/settings/client_settings_page.dart index 1282115..87f7be0 100644 --- a/lib/screens/settings/client_settings_page.dart +++ b/lib/screens/settings/client_settings_page.dart @@ -10,6 +10,7 @@ import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_advanced.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_dashboard.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_download.dart'; +import 'package:fladder/screens/settings/client_sections/client_settings_shortcuts.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_theme.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_visual.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; @@ -61,6 +62,10 @@ class _ClientSettingsPageState extends ConsumerState { }, ), ]), + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[ + const SizedBox(height: 12), + ...buildClientSettingsShortCuts(context, ref), + ], const SizedBox(height: 12), ...buildClientSettingsDashboard(context, ref), const SizedBox(height: 12), diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index f792caf..7e45b81 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -14,6 +14,7 @@ import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_scaffold.dart'; +import 'package:fladder/screens/settings/widgets/key_listener.dart'; import 'package:fladder/screens/settings/widgets/settings_label_divider.dart'; import 'package:fladder/screens/settings/widgets/settings_list_group.dart'; import 'package:fladder/screens/settings/widgets/settings_message_box.dart'; @@ -43,6 +44,8 @@ class _PlayerSettingsPageState extends ConsumerState { final connectionState = ref.watch(connectivityStatusProvider); + final userSettings = ref.watch(userProvider.select((value) => value?.userSettings)); + return SettingsScaffold( label: context.localized.settingsPlayerTitle, items: [ @@ -169,6 +172,70 @@ class _PlayerSettingsPageState extends ConsumerState { ), ]), const SizedBox(height: 12), + ...settingsListGroup( + context, + SettingsLabelDivider(label: context.localized.shortCuts), + [ + if (userSettings != null) + SettingsListTile( + label: Text(context.localized.skipBackLength), + trailing: SizedBox( + width: 125, + child: IntInputField( + suffix: context.localized.seconds(10), + controller: TextEditingController(text: userSettings.skipBackDuration.inSeconds.toString()), + onSubmitted: (value) { + if (value != null) { + ref.read(userProvider.notifier).setBackwardSpeed(value); + } + }, + )), + ), + SettingsListTile( + label: Text(context.localized.skipForwardLength), + trailing: SizedBox( + width: 125, + child: IntInputField( + suffix: context.localized.seconds(10), + controller: TextEditingController(text: userSettings!.skipForwardDuration.inSeconds.toString()), + onSubmitted: (value) { + if (value != null) { + ref.read(userProvider.notifier).setForwardSpeed(value); + } + }, + )), + ), + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) + ...VideoHotKeys.values.map( + (entry) { + final currentEntry = videoSettings.hotKeys[entry]; + final defaultEntry = videoSettings.defaultShortCuts[entry]!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + entry.label(context), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Flexible( + child: KeyCombinationWidget( + currentKey: currentEntry, + defaultKey: defaultEntry, + onChanged: (value) => + ref.read(videoPlayerSettingsProvider.notifier).setShortcuts(MapEntry(entry, value)), + ), + ) + ], + ), + ); + }, + ) + ], + ), + const SizedBox(height: 12), ...settingsListGroup(context, SettingsLabelDivider(label: context.localized.playbackTrackSelection), [ SettingsListTile( label: Text(context.localized.rememberAudioSelections), diff --git a/lib/screens/settings/widgets/key_listener.dart b/lib/screens/settings/widgets/key_listener.dart new file mode 100644 index 0000000..239bcda --- /dev/null +++ b/lib/screens/settings/widgets/key_listener.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/models/settings/key_combinations.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/screens/shared/fladder_snackbar.dart'; +import 'package:fladder/util/localization_helper.dart'; + +class KeyCombinationWidget extends ConsumerStatefulWidget { + final KeyCombination? currentKey; + final KeyCombination defaultKey; + final Function(KeyCombination? value) onChanged; + + KeyCombinationWidget({required this.currentKey, required this.defaultKey, required this.onChanged, super.key}); + + @override + KeyCombinationWidgetState createState() => KeyCombinationWidgetState(); +} + +class KeyCombinationWidgetState extends ConsumerState { + final focusNode = FocusNode(); + bool _isListening = false; + LogicalKeyboardKey? _pressedKey; + LogicalKeyboardKey? _pressedModifier; + + @override + void dispose() { + _stopListening(); + super.dispose(); + } + + void _startListening() { + setState(() { + _isListening = true; + _pressedKey = null; + _pressedModifier = null; + }); + } + + void _stopListening() { + setState(() { + _isListening = false; + if (_pressedKey != null) { + final newKeyComb = KeyCombination( + key: _pressedKey!, + modifier: _pressedModifier, + ); + if (newKeyComb == widget.defaultKey) { + widget.onChanged(null); + } else { + widget.onChanged(newKeyComb); + } + } + _pressedKey = null; + _pressedModifier = null; + }); + } + + void _handleKeyEvent(KeyEvent event) { + final videoHotKeys = ref.read(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)).values; + final clientHotKeys = ref.read(clientSettingsProvider.select((value) => value.currentShortcuts)).values; + final activeHotKeys = [...videoHotKeys, ...clientHotKeys].toList(); + + if (_isListening) { + focusNode.requestFocus(); + setState(() { + if (event is KeyDownEvent) { + if (KeyCombination.modifierKeys.contains(event.logicalKey)) { + _pressedModifier = event.logicalKey; + } else { + final currentHotKey = KeyCombination(key: event.logicalKey, modifier: _pressedModifier); + bool isExistingHotkey = activeHotKeys.any((element) { + return element == currentHotKey && currentHotKey != (widget.currentKey ?? widget.defaultKey); + }); + + if (!isExistingHotkey) { + _pressedKey = event.logicalKey; + _stopListening(); + } else { + if (context.mounted) { + fladderSnackbar(context, title: context.localized.shortCutAlreadyAssigned(currentHotKey.label)); + } + _stopListening(); + } + } + } else if (event is KeyUpEvent) { + if (KeyCombination.modifierKeys.contains(event.logicalKey) && _pressedModifier == event.logicalKey) { + _pressedModifier = null; + } else if (_pressedKey == event.logicalKey) { + _pressedKey = null; + } + } + }); + } else { + _pressedKey = null; + _pressedModifier = null; + } + } + + @override + Widget build(BuildContext context) { + final currentModifier = + _pressedModifier ?? (widget.currentKey != null ? widget.currentKey?.modifier : widget.defaultKey.modifier); + final currentKey = _pressedKey ?? (widget.currentKey?.key ?? widget.defaultKey.key); + final currentHotKey = KeyCombination(key: currentKey, modifier: currentModifier); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 50), + child: InkWell( + onTap: _isListening ? null : _startListening, + child: Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Text(currentHotKey.label), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _isListening + ? KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + height: 24, + width: 24, + child: Center( + child: CircularProgressIndicator(), + ), + ), + ), + ) + : IconButton( + onPressed: widget.currentKey == null + ? null + : () { + _pressedKey = null; + _pressedModifier = null; + widget.onChanged(null); + }, + iconSize: 24, + icon: const Icon(IconsaxPlusBold.broom), + ), + ) + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +extension LogicalKeyExtension on LogicalKeyboardKey { + String get label { + return switch (this) { LogicalKeyboardKey.space => "Space", _ => keyLabel }; + } +} diff --git a/lib/screens/video_player/components/video_player_seek_indicator.dart b/lib/screens/video_player/components/video_player_seek_indicator.dart index 82babbc..3200e53 100644 --- a/lib/screens/video_player/components/video_player_seek_indicator.dart +++ b/lib/screens/video_player/components/video_player_seek_indicator.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:async/async.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/models/account_model.dart'; +import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/util/input_handler.dart'; import 'package:fladder/util/localization_helper.dart'; @@ -48,35 +51,12 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, + keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)), + keyMapResult: (result) => _onKey(result), child: IgnorePointer( child: AnimatedOpacity( duration: const Duration(milliseconds: 500), @@ -108,6 +88,29 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState onSeekStart(seconds); - void seekForward({int seconds = 30}) => onSeekStart(seconds); + bool _onKey(VideoHotKeys value) { + switch (value) { + case VideoHotKeys.seekForward: + seekForward(); + return true; + case VideoHotKeys.seekBack: + seekBack(); + return true; + default: + break; + } + return false; + } + + void seekBack() { + final seconds = -ref.read(userProvider + .select((value) => (value?.userSettings?.skipBackDuration ?? UserSettings().skipBackDuration).inSeconds)); + onSeekStart(seconds); + } + + void seekForward() { + final seconds = ref.read(userProvider + .select((value) => (value?.userSettings?.skipForwardDuration ?? UserSettings().skipForwardDuration).inSeconds)); + onSeekStart(seconds); + } } diff --git a/lib/screens/video_player/components/video_volume_slider.dart b/lib/screens/video_player/components/video_volume_slider.dart index 81d1ce0..82a6a7a 100644 --- a/lib/screens/video_player/components/video_volume_slider.dart +++ b/lib/screens/video_player/components/video_volume_slider.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/util/list_padding.dart'; @@ -19,6 +19,8 @@ class VideoVolumeSlider extends ConsumerStatefulWidget { class _VideoVolumeSliderState extends ConsumerState { bool sliderActive = false; + double? previousVolume; + @override Widget build(BuildContext context) { final volume = ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)); @@ -27,7 +29,12 @@ class _VideoVolumeSliderState extends ConsumerState { children: [ IconButton( icon: Icon(volumeIcon(volume)), - onPressed: () => ref.read(videoPlayerSettingsProvider.notifier).setVolume(0), + onPressed: () { + if (volume != 0) { + previousVolume = volume; + } + ref.read(videoPlayerSettingsProvider.notifier).setVolume(volume == 0 ? (previousVolume ?? 100) : 0); + }, ), AnimatedSize( duration: const Duration(milliseconds: 250), diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index ce64ef2..2332f74 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -10,11 +10,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/screens/shared/media/components/item_logo.dart'; @@ -48,6 +51,8 @@ class _DesktopControlsState extends ConsumerState { () => mounted ? toggleOverlay(value: false) : null, ); + double? previousVolume; + final fadeDuration = const Duration(milliseconds: 350); bool showOverlay = true; bool wasPlaying = false; @@ -55,55 +60,6 @@ class _DesktopControlsState extends ConsumerState { late final double topPadding = MediaQuery.of(context).viewPadding.top; late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom; - bool _onKey(KeyEvent value) { - final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments)); - final position = ref.read(mediaPlaybackProvider).position; - MediaSegment? segment = mediaSegments?.atPosition(position); - if (value is KeyRepeatEvent) { - if (value.logicalKey == LogicalKeyboardKey.arrowUp) { - resetTimer(); - ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5); - return true; - } - if (value.logicalKey == LogicalKeyboardKey.arrowDown) { - resetTimer(); - ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5); - return true; - } - } - if (value is KeyDownEvent) { - if (value.logicalKey == LogicalKeyboardKey.keyS) { - if (segment != null) { - skipToSegmentEnd(segment); - } - return true; - } - if (value.logicalKey == LogicalKeyboardKey.escape) { - disableFullScreen(); - return true; - } - if (value.logicalKey == LogicalKeyboardKey.space) { - ref.read(videoPlayerProvider).playOrPause(); - return true; - } - if (value.logicalKey == LogicalKeyboardKey.keyF) { - fullScreenHelper.toggleFullScreen(ref); - return true; - } - if (value.logicalKey == LogicalKeyboardKey.arrowUp) { - resetTimer(); - ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5); - return true; - } - if (value.logicalKey == LogicalKeyboardKey.arrowDown) { - resetTimer(); - ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5); - return true; - } - } - return false; - } - @override void initState() { super.initState(); @@ -116,8 +72,9 @@ class _DesktopControlsState extends ConsumerState { final player = ref.watch(videoPlayerProvider); final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey); return InputHandler( - autoFocus: false, - onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, + autoFocus: true, + keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)), + keyMapResult: (result) => _onKey(result), child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { @@ -536,8 +493,6 @@ class _DesktopControlsState extends ConsumerState { return Consumer( builder: (context, ref, child) { final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo)); - final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering)); - return Tooltip( message: previousVideo?.detailedName(context) ?? "", textAlign: TextAlign.center, @@ -547,9 +502,7 @@ class _DesktopControlsState extends ConsumerState { ), textStyle: Theme.of(context).textTheme.labelLarge, child: IconButton( - onPressed: previousVideo != null && !buffering - ? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo) - : null, + onPressed: loadPreviousVideo(ref, video: previousVideo), iconSize: 30, icon: const Icon( IconsaxPlusLinear.backward, @@ -560,11 +513,16 @@ class _DesktopControlsState extends ConsumerState { ); } + Function()? loadPreviousVideo(WidgetRef ref, {ItemBaseModel? video}) { + final previousVideo = video ?? ref.read(playBackModel.select((value) => value?.previousVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + return previousVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo) : null; + } + Widget get nextVideoButton { return Consumer( builder: (context, ref, child) { final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo)); - final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering)); return Tooltip( message: nextVideo?.detailedName(context) ?? "", textAlign: TextAlign.center, @@ -574,8 +532,7 @@ class _DesktopControlsState extends ConsumerState { ), textStyle: Theme.of(context).textTheme.labelLarge, child: IconButton( - onPressed: - nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null, + onPressed: loadNextVideo(ref, video: nextVideo), iconSize: 30, icon: const Icon( IconsaxPlusLinear.forward, @@ -586,25 +543,62 @@ class _DesktopControlsState extends ConsumerState { ); } + Function()? loadNextVideo(WidgetRef ref, {ItemBaseModel? video}) { + final nextVideo = video ?? ref.read(playBackModel.select((value) => value?.nextVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + return nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null; + } + Widget seekBackwardButton(WidgetRef ref) { + final backwardSpeed = + ref.read(userProvider.select((value) => value?.userSettings?.skipBackDuration.inSeconds ?? 30)); return IconButton( - onPressed: () => seekBack(ref), - tooltip: "-10", + onPressed: () => seekBack(ref, seconds: backwardSpeed), + tooltip: "-$backwardSpeed", iconSize: 40, - icon: const Icon( - IconsaxPlusLinear.backward_10_seconds, + icon: Stack( + alignment: Alignment.center, + children: [ + const Icon( + IconsaxPlusBroken.refresh, + size: 45, + ), + Transform.translate( + offset: const Offset(0, 1), + child: Text( + "-$backwardSpeed", + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], ), ); } Widget seekForwardButton(WidgetRef ref) { + final forwardSpeed = + ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30)); return IconButton( - onPressed: () => seekForward(ref), - tooltip: "15", + onPressed: () => seekForward(ref, seconds: forwardSpeed), + tooltip: forwardSpeed.toString(), iconSize: 40, - icon: const Stack( + icon: Stack( + alignment: Alignment.center, children: [ - Icon(IconsaxPlusLinear.forward_15_seconds), + Transform.flip( + flipX: true, + child: const Icon( + IconsaxPlusBroken.refresh, + size: 45, + ), + ), + Transform.translate( + offset: const Offset(0, 1), + child: Text( + forwardSpeed.toString(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), ], ), ); @@ -678,4 +672,58 @@ class _DesktopControlsState extends ConsumerState { resetTimer(); fullScreenHelper.closeFullScreen(ref); } + + bool _onKey(VideoHotKeys value) { + final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments)); + final position = ref.read(mediaPlaybackProvider).position; + + MediaSegment? segment = mediaSegments?.atPosition(position); + + final volume = ref.read(videoPlayerSettingsProvider.select((value) => value.volume)); + + switch (value) { + case VideoHotKeys.playPause: + ref.read(videoPlayerProvider).playOrPause(); + return true; + case VideoHotKeys.volumeUp: + resetTimer(); + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5); + return true; + case VideoHotKeys.volumeDown: + resetTimer(); + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5); + return true; + case VideoHotKeys.fullScreen: + fullScreenHelper.toggleFullScreen(ref); + return true; + case VideoHotKeys.skipMediaSegment: + if (segment != null) { + skipToSegmentEnd(segment); + } + return true; + case VideoHotKeys.exit: + disableFullScreen(); + return true; + case VideoHotKeys.mute: + if (volume != 0) { + previousVolume = volume; + } + ref.read(videoPlayerSettingsProvider.notifier).setVolume(volume == 0 ? (previousVolume ?? 100) : 0); + return true; + case VideoHotKeys.nextVideo: + loadNextVideo(ref)?.call(); + return true; + case VideoHotKeys.prevVideo: + loadPreviousVideo(ref)?.call(); + return true; + case VideoHotKeys.nextChapter: + ref.read(videoPlayerSettingsProvider.notifier).nextChapter(); + return true; + case VideoHotKeys.prevChapter: + ref.read(videoPlayerSettingsProvider.notifier).prevChapter(); + return true; + default: + return false; + } + } } diff --git a/lib/util/input_handler.dart b/lib/util/input_handler.dart index 60d4b2b..a844d2d 100644 --- a/lib/util/input_handler.dart +++ b/lib/util/input_handler.dart @@ -1,27 +1,36 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -class InputHandler extends StatefulWidget { +import 'package:fladder/models/settings/key_combinations.dart'; + +class InputHandler extends StatefulWidget { final bool autoFocus; final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; + final bool Function(T result)? keyMapResult; + final Map? keyMap; final Widget child; const InputHandler({ required this.child, this.autoFocus = true, this.onKeyEvent, + this.keyMapResult, + this.keyMap, super.key, }); @override - State createState() => _InputHandlerState(); + State createState() => _InputHandlerState(); } -class _InputHandlerState extends State { +class _InputHandlerState extends State> { final focusNode = FocusNode(); + LogicalKeyboardKey? pressedModifier; + @override void initState() { super.initState(); - //Focus on start + // Focus on start focusNode.requestFocus(); } @@ -35,8 +44,38 @@ class _InputHandlerState extends State { focusNode.requestFocus(); } }, - onKeyEvent: widget.onKeyEvent, + onKeyEvent: (node, event) => _onKey(event), child: widget.child, ); } + + KeyEventResult _onKey(KeyEvent value) { + final keyMap = widget.keyMap?.entries.nonNulls.toList() ?? []; + if (value is KeyDownEvent || value is KeyRepeatEvent) { + if (KeyCombination.modifierKeys.contains(value.logicalKey)) { + pressedModifier = value.logicalKey; + } + + for (var entry in keyMap) { + final hotKey = entry.key; + final keyCombination = entry.value; + + bool isMainKeyPressed = value.logicalKey == keyCombination.key; + bool isModifierKeyPressed = pressedModifier == keyCombination.modifier; + + if (isMainKeyPressed && isModifierKeyPressed) { + if (widget.keyMapResult?.call(hotKey) ?? false) { + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + } + } else if (value is KeyUpEvent) { + if (KeyCombination.modifierKeys.contains(value.logicalKey)) { + pressedModifier = null; + } + } + return KeyEventResult.ignored; + } }