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 <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-08-08 16:36:50 +02:00 committed by GitHub
parent 23385d8e62
commit fa30e634b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1360 additions and 162 deletions

View file

@ -1139,7 +1139,7 @@
}, },
"noVideoPlayerOptions": "The selected backend has no options", "noVideoPlayerOptions": "The selected backend has no options",
"mdkExperimental": "MDK is still in a experimental stage", "mdkExperimental": "MDK is still in a experimental stage",
"skipButtonLabel": "(S)kip {segment}", "skipButtonLabel": "Skip {segment}",
"@skipButtonLabel": { "@skipButtonLabel": {
"placeholders": { "placeholders": {
"segment": { "segment": {
@ -1292,5 +1292,29 @@
"syncAllFiles": "Sync all files", "syncAllFiles": "Sync all files",
"usePostersForLibraryIconsTitle": "Show posters for library icons", "usePostersForLibraryIconsTitle": "Show posters for library icons",
"usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries", "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"
}
}
}
} }

View file

@ -35,6 +35,7 @@ class AccountModel with _$AccountModel {
@JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy,
@JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration,
@JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? userConfiguration, @JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? userConfiguration,
UserSettings? userSettings,
}) = _AccountModel; }) = _AccountModel;
factory AccountModel.fromJson(Map<String, dynamic> json) => _$AccountModelFromJson(json); factory AccountModel.fromJson(Map<String, dynamic> 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<String, dynamic> json) => _$UserSettingsFromJson(json);
}
enum Authentication { enum Authentication {
autoLogin(0), autoLogin(0),
biometrics(1), biometrics(1),

View file

@ -40,6 +40,7 @@ mixin _$AccountModel {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
UserConfiguration? get userConfiguration => UserConfiguration? get userConfiguration =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
UserSettings? get userSettings => throw _privateConstructorUsedError;
/// Serializes this AccountModel to a JSON map. /// Serializes this AccountModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -73,7 +74,10 @@ abstract class $AccountModelCopyWith<$Res> {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
ServerConfiguration? serverConfiguration, ServerConfiguration? serverConfiguration,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
UserConfiguration? userConfiguration}); UserConfiguration? userConfiguration,
UserSettings? userSettings});
$UserSettingsCopyWith<$Res>? get userSettings;
} }
/// @nodoc /// @nodoc
@ -105,6 +109,7 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel>
Object? policy = freezed, Object? policy = freezed,
Object? serverConfiguration = freezed, Object? serverConfiguration = freezed,
Object? userConfiguration = freezed, Object? userConfiguration = freezed,
Object? userSettings = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
name: null == name name: null == name
@ -163,8 +168,26 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel>
? _value.userConfiguration ? _value.userConfiguration
: userConfiguration // ignore: cast_nullable_to_non_nullable : userConfiguration // ignore: cast_nullable_to_non_nullable
as UserConfiguration?, as UserConfiguration?,
userSettings: freezed == userSettings
? _value.userSettings
: userSettings // ignore: cast_nullable_to_non_nullable
as UserSettings?,
) as $Val); ) 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 /// @nodoc
@ -191,7 +214,11 @@ abstract class _$$AccountModelImplCopyWith<$Res>
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
ServerConfiguration? serverConfiguration, ServerConfiguration? serverConfiguration,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
UserConfiguration? userConfiguration}); UserConfiguration? userConfiguration,
UserSettings? userSettings});
@override
$UserSettingsCopyWith<$Res>? get userSettings;
} }
/// @nodoc /// @nodoc
@ -221,6 +248,7 @@ class __$$AccountModelImplCopyWithImpl<$Res>
Object? policy = freezed, Object? policy = freezed,
Object? serverConfiguration = freezed, Object? serverConfiguration = freezed,
Object? userConfiguration = freezed, Object? userConfiguration = freezed,
Object? userSettings = freezed,
}) { }) {
return _then(_$AccountModelImpl( return _then(_$AccountModelImpl(
name: null == name name: null == name
@ -279,6 +307,10 @@ class __$$AccountModelImplCopyWithImpl<$Res>
? _value.userConfiguration ? _value.userConfiguration
: userConfiguration // ignore: cast_nullable_to_non_nullable : userConfiguration // ignore: cast_nullable_to_non_nullable
as UserConfiguration?, 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) @JsonKey(includeFromJson: false, includeToJson: false)
this.serverConfiguration, this.serverConfiguration,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
this.userConfiguration}) this.userConfiguration,
this.userSettings})
: _latestItemsExcludes = latestItemsExcludes, : _latestItemsExcludes = latestItemsExcludes,
_searchQueryHistory = searchQueryHistory, _searchQueryHistory = searchQueryHistory,
_savedFilters = savedFilters, _savedFilters = savedFilters,
@ -368,10 +401,12 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin {
@override @override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final UserConfiguration? userConfiguration; final UserConfiguration? userConfiguration;
@override
final UserSettings? userSettings;
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 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 @override
@ -392,7 +427,8 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin {
..add(DiagnosticsProperty('savedFilters', savedFilters)) ..add(DiagnosticsProperty('savedFilters', savedFilters))
..add(DiagnosticsProperty('policy', policy)) ..add(DiagnosticsProperty('policy', policy))
..add(DiagnosticsProperty('serverConfiguration', serverConfiguration)) ..add(DiagnosticsProperty('serverConfiguration', serverConfiguration))
..add(DiagnosticsProperty('userConfiguration', userConfiguration)); ..add(DiagnosticsProperty('userConfiguration', userConfiguration))
..add(DiagnosticsProperty('userSettings', userSettings));
} }
/// Create a copy of AccountModel /// Create a copy of AccountModel
@ -429,7 +465,8 @@ abstract class _AccountModel extends AccountModel {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final ServerConfiguration? serverConfiguration, final ServerConfiguration? serverConfiguration,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final UserConfiguration? userConfiguration}) = _$AccountModelImpl; final UserConfiguration? userConfiguration,
final UserSettings? userSettings}) = _$AccountModelImpl;
const _AccountModel._() : super._(); const _AccountModel._() : super._();
factory _AccountModel.fromJson(Map<String, dynamic> json) = factory _AccountModel.fromJson(Map<String, dynamic> json) =
@ -466,6 +503,8 @@ abstract class _AccountModel extends AccountModel {
@override @override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
UserConfiguration? get userConfiguration; UserConfiguration? get userConfiguration;
@override
UserSettings? get userSettings;
/// Create a copy of AccountModel /// Create a copy of AccountModel
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -474,3 +513,170 @@ abstract class _AccountModel extends AccountModel {
_$$AccountModelImplCopyWith<_$AccountModelImpl> get copyWith => _$$AccountModelImplCopyWith<_$AccountModelImpl> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
UserSettings _$UserSettingsFromJson(Map<String, dynamic> 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<String, dynamic> 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<UserSettings> 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<String, dynamic> 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<String, dynamic> toJson() {
return _$$UserSettingsImplToJson(
this,
);
}
}
abstract class _UserSettings implements UserSettings {
factory _UserSettings(
{final Duration skipForwardDuration,
final Duration skipBackDuration}) = _$UserSettingsImpl;
factory _UserSettings.fromJson(Map<String, dynamic> 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;
}

View file

@ -31,6 +31,9 @@ _$AccountModelImpl _$$AccountModelImplFromJson(Map<String, dynamic> json) =>
LibraryFiltersModel.fromJson(e as Map<String, dynamic>)) LibraryFiltersModel.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
userSettings: json['userSettings'] == null
? null
: UserSettings.fromJson(json['userSettings'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$$AccountModelImplToJson(_$AccountModelImpl instance) => Map<String, dynamic> _$$AccountModelImplToJson(_$AccountModelImpl instance) =>
@ -46,6 +49,7 @@ Map<String, dynamic> _$$AccountModelImplToJson(_$AccountModelImpl instance) =>
'searchQueryHistory': instance.searchQueryHistory, 'searchQueryHistory': instance.searchQueryHistory,
'quickConnectState': instance.quickConnectState, 'quickConnectState': instance.quickConnectState,
'savedFilters': instance.savedFilters, 'savedFilters': instance.savedFilters,
'userSettings': instance.userSettings,
}; };
const _$AuthenticationEnumMap = { const _$AuthenticationEnumMap = {
@ -54,3 +58,20 @@ const _$AuthenticationEnumMap = {
Authentication.passcode: 'passcode', Authentication.passcode: 'passcode',
Authentication.none: 'none', Authentication.none: 'none',
}; };
_$UserSettingsImpl _$$UserSettingsImplFromJson(Map<String, dynamic> 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<String, dynamic> _$$UserSettingsImplToJson(_$UserSettingsImpl instance) =>
<String, dynamic>{
'skipForwardDuration': instance.skipForwardDuration.inMicroseconds,
'skipBackDuration': instance.skipBackDuration.inMicroseconds,
};

View file

@ -3,17 +3,35 @@ import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.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/custom_color_themes.dart';
import 'package:fladder/util/localization_helper.dart';
part 'client_settings_model.freezed.dart'; part 'client_settings_model.freezed.dart';
part 'client_settings_model.g.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) @Freezed(copyWith: true)
class ClientSettingsModel with _$ClientSettingsModel { class ClientSettingsModel with _$ClientSettingsModel {
const ClientSettingsModel._(); const ClientSettingsModel._();
factory ClientSettingsModel({ factory ClientSettingsModel({
String? syncPath, String? syncPath,
@Default(Vector2(x: 0, y: 0)) Vector2 position, @Default(Vector2(x: 0, y: 0)) Vector2 position,
@ -39,10 +57,16 @@ class ClientSettingsModel with _$ClientSettingsModel {
@Default(false) bool usePosterForLibrary, @Default(false) bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize, int? libraryPageSize,
@Default({}) Map<GlobalHotKeys, KeyCombination?> shortcuts,
}) = _ClientSettingsModel; }) = _ClientSettingsModel;
factory ClientSettingsModel.fromJson(Map<String, dynamic> json) => _$ClientSettingsModelFromJson(json); factory ClientSettingsModel.fromJson(Map<String, dynamic> json) => _$ClientSettingsModelFromJson(json);
Map<GlobalHotKeys, KeyCombination> get currentShortcuts =>
_defaultGlobalHotKeys.map((key, value) => MapEntry(key, shortcuts[key] ?? value));
Map<GlobalHotKeys, KeyCombination> get defaultShortCuts => _defaultGlobalHotKeys;
Brightness statusBarBrightness(BuildContext context) { Brightness statusBarBrightness(BuildContext context) {
return switch (themeMode) { return switch (themeMode) {
ThemeMode.dark => Brightness.light, ThemeMode.dark => Brightness.light,
@ -132,3 +156,12 @@ class Vector2 {
static Vector2 fromPosition(Offset windowPosition) => Vector2(x: windowPosition.dx, y: windowPosition.dy); static Vector2 fromPosition(Offset windowPosition) => Vector2(x: windowPosition.dx, y: windowPosition.dy);
} }
Map<GlobalHotKeys, KeyCombination> 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),
},
};

View file

@ -45,6 +45,8 @@ mixin _$ClientSettingsModel {
bool get usePosterForLibrary => throw _privateConstructorUsedError; bool get usePosterForLibrary => throw _privateConstructorUsedError;
String? get lastViewedUpdate => throw _privateConstructorUsedError; String? get lastViewedUpdate => throw _privateConstructorUsedError;
int? get libraryPageSize => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError;
Map<GlobalHotKeys, KeyCombination?> get shortcuts =>
throw _privateConstructorUsedError;
/// Serializes this ClientSettingsModel to a JSON map. /// Serializes this ClientSettingsModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -86,7 +88,8 @@ abstract class $ClientSettingsModelCopyWith<$Res> {
bool checkForUpdates, bool checkForUpdates,
bool usePosterForLibrary, bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize}); int? libraryPageSize,
Map<GlobalHotKeys, KeyCombination?> shortcuts});
} }
/// @nodoc /// @nodoc
@ -128,6 +131,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
Object? usePosterForLibrary = null, Object? usePosterForLibrary = null,
Object? lastViewedUpdate = freezed, Object? lastViewedUpdate = freezed,
Object? libraryPageSize = freezed, Object? libraryPageSize = freezed,
Object? shortcuts = null,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
syncPath: freezed == syncPath syncPath: freezed == syncPath
@ -226,6 +230,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
? _value.libraryPageSize ? _value.libraryPageSize
: libraryPageSize // ignore: cast_nullable_to_non_nullable : libraryPageSize // ignore: cast_nullable_to_non_nullable
as int?, as int?,
shortcuts: null == shortcuts
? _value.shortcuts
: shortcuts // ignore: cast_nullable_to_non_nullable
as Map<GlobalHotKeys, KeyCombination?>,
) as $Val); ) as $Val);
} }
} }
@ -262,7 +270,8 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res>
bool checkForUpdates, bool checkForUpdates,
bool usePosterForLibrary, bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize}); int? libraryPageSize,
Map<GlobalHotKeys, KeyCombination?> shortcuts});
} }
/// @nodoc /// @nodoc
@ -302,6 +311,7 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
Object? usePosterForLibrary = null, Object? usePosterForLibrary = null,
Object? lastViewedUpdate = freezed, Object? lastViewedUpdate = freezed,
Object? libraryPageSize = freezed, Object? libraryPageSize = freezed,
Object? shortcuts = null,
}) { }) {
return _then(_$ClientSettingsModelImpl( return _then(_$ClientSettingsModelImpl(
syncPath: freezed == syncPath syncPath: freezed == syncPath
@ -400,6 +410,10 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
? _value.libraryPageSize ? _value.libraryPageSize
: libraryPageSize // ignore: cast_nullable_to_non_nullable : libraryPageSize // ignore: cast_nullable_to_non_nullable
as int?, as int?,
shortcuts: null == shortcuts
? _value._shortcuts
: shortcuts // ignore: cast_nullable_to_non_nullable
as Map<GlobalHotKeys, KeyCombination?>,
)); ));
} }
} }
@ -432,8 +446,10 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
this.checkForUpdates = true, this.checkForUpdates = true,
this.usePosterForLibrary = false, this.usePosterForLibrary = false,
this.lastViewedUpdate, this.lastViewedUpdate,
this.libraryPageSize}) this.libraryPageSize,
: super._(); final Map<GlobalHotKeys, KeyCombination?> shortcuts = const {}})
: _shortcuts = shortcuts,
super._();
factory _$ClientSettingsModelImpl.fromJson(Map<String, dynamic> json) => factory _$ClientSettingsModelImpl.fromJson(Map<String, dynamic> json) =>
_$$ClientSettingsModelImplFromJson(json); _$$ClientSettingsModelImplFromJson(json);
@ -505,10 +521,18 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
final String? lastViewedUpdate; final String? lastViewedUpdate;
@override @override
final int? libraryPageSize; final int? libraryPageSize;
final Map<GlobalHotKeys, KeyCombination?> _shortcuts;
@override
@JsonKey()
Map<GlobalHotKeys, KeyCombination?> get shortcuts {
if (_shortcuts is EqualUnmodifiableMapView) return _shortcuts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_shortcuts);
}
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 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 @override
@ -541,7 +565,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates))
..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary))
..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate))
..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)); ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize))
..add(DiagnosticsProperty('shortcuts', shortcuts));
} }
/// Create a copy of ClientSettingsModel /// Create a copy of ClientSettingsModel
@ -563,30 +588,32 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
abstract class _ClientSettingsModel extends ClientSettingsModel { abstract class _ClientSettingsModel extends ClientSettingsModel {
factory _ClientSettingsModel( factory _ClientSettingsModel(
{final String? syncPath, {final String? syncPath,
final Vector2 position, final Vector2 position,
final Vector2 size, final Vector2 size,
final Duration? timeOut, final Duration? timeOut,
final Duration? nextUpDateCutoff, final Duration? nextUpDateCutoff,
final ThemeMode themeMode, final ThemeMode themeMode,
final ColorThemes? themeColor, final ColorThemes? themeColor,
final bool amoledBlack, final bool amoledBlack,
final bool blurPlaceHolders, final bool blurPlaceHolders,
final bool blurUpcomingEpisodes, final bool blurUpcomingEpisodes,
@LocaleConvert() final Locale? selectedLocale, @LocaleConvert() final Locale? selectedLocale,
final bool enableMediaKeys, final bool enableMediaKeys,
final double posterSize, final double posterSize,
final bool pinchPosterZoom, final bool pinchPosterZoom,
final bool mouseDragSupport, final bool mouseDragSupport,
final bool requireWifi, final bool requireWifi,
final bool showAllCollectionTypes, final bool showAllCollectionTypes,
final int maxConcurrentDownloads, final int maxConcurrentDownloads,
final DynamicSchemeVariant schemeVariant, final DynamicSchemeVariant schemeVariant,
final bool backgroundPosters, final bool backgroundPosters,
final bool checkForUpdates, final bool checkForUpdates,
final bool usePosterForLibrary, final bool usePosterForLibrary,
final String? lastViewedUpdate, final String? lastViewedUpdate,
final int? libraryPageSize}) = _$ClientSettingsModelImpl; final int? libraryPageSize,
final Map<GlobalHotKeys, KeyCombination?> shortcuts}) =
_$ClientSettingsModelImpl;
_ClientSettingsModel._() : super._(); _ClientSettingsModel._() : super._();
factory _ClientSettingsModel.fromJson(Map<String, dynamic> json) = factory _ClientSettingsModel.fromJson(Map<String, dynamic> json) =
@ -641,6 +668,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
String? get lastViewedUpdate; String? get lastViewedUpdate;
@override @override
int? get libraryPageSize; int? get libraryPageSize;
@override
Map<GlobalHotKeys, KeyCombination?> get shortcuts;
/// Create a copy of ClientSettingsModel /// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View file

@ -46,6 +46,14 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson(
usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false, usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false,
lastViewedUpdate: json['lastViewedUpdate'] as String?, lastViewedUpdate: json['lastViewedUpdate'] as String?,
libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(),
shortcuts: (json['shortcuts'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
$enumDecode(_$GlobalHotKeysEnumMap, k),
e == null
? null
: KeyCombination.fromJson(e as Map<String, dynamic>)),
) ??
const {},
); );
Map<String, dynamic> _$$ClientSettingsModelImplToJson( Map<String, dynamic> _$$ClientSettingsModelImplToJson(
@ -75,6 +83,8 @@ Map<String, dynamic> _$$ClientSettingsModelImplToJson(
'usePosterForLibrary': instance.usePosterForLibrary, 'usePosterForLibrary': instance.usePosterForLibrary,
'lastViewedUpdate': instance.lastViewedUpdate, 'lastViewedUpdate': instance.lastViewedUpdate,
'libraryPageSize': instance.libraryPageSize, 'libraryPageSize': instance.libraryPageSize,
'shortcuts': instance.shortcuts
.map((k, e) => MapEntry(_$GlobalHotKeysEnumMap[k]!, e)),
}; };
const _$ThemeModeEnumMap = { const _$ThemeModeEnumMap = {
@ -112,3 +122,8 @@ const _$DynamicSchemeVariantEnumMap = {
DynamicSchemeVariant.rainbow: 'rainbow', DynamicSchemeVariant.rainbow: 'rainbow',
DynamicSchemeVariant.fruitSalad: 'fruitSalad', DynamicSchemeVariant.fruitSalad: 'fruitSalad',
}; };
const _$GlobalHotKeysEnumMap = {
GlobalHotKeys.search: 'search',
GlobalHotKeys.exit: 'exit',
};

View file

@ -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<String, dynamic> 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<LogicalKeyboardKey> 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<LogicalKeyboardKey, String> {
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());
}
}

View file

@ -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>(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<String, dynamic> 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<String, dynamic> toJson() => throw _privateConstructorUsedError;
}
/// @nodoc
@JsonSerializable()
class _$KeyCombinationImpl extends _KeyCombination {
_$KeyCombinationImpl(
{@LogicalKeyboardSerializer() this.modifier,
@LogicalKeyboardSerializer() required this.key})
: super._();
factory _$KeyCombinationImpl.fromJson(Map<String, dynamic> json) =>
_$$KeyCombinationImplFromJson(json);
@override
@LogicalKeyboardSerializer()
final LogicalKeyboardKey? modifier;
@override
@LogicalKeyboardSerializer()
final LogicalKeyboardKey key;
@override
String toString() {
return 'KeyCombination(modifier: $modifier, key: $key)';
}
@override
Map<String, dynamic> 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<String, dynamic> json) =
_$KeyCombinationImpl.fromJson;
@override
@LogicalKeyboardSerializer()
LogicalKeyboardKey? get modifier;
@override
@LogicalKeyboardSerializer()
LogicalKeyboardKey get key;
}

View file

@ -0,0 +1,34 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'key_combinations.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$KeyCombinationImpl _$$KeyCombinationImplFromJson(Map<String, dynamic> json) =>
_$KeyCombinationImpl(
modifier: _$JsonConverterFromJson<String, LogicalKeyboardKey>(
json['modifier'], const LogicalKeyboardSerializer().fromJson),
key: const LogicalKeyboardSerializer().fromJson(json['key'] as String),
);
Map<String, dynamic> _$$KeyCombinationImplToJson(
_$KeyCombinationImpl instance) =>
<String, dynamic>{
'modifier': _$JsonConverterToJson<String, LogicalKeyboardKey>(
instance.modifier, const LogicalKeyboardSerializer().toJson),
'key': const LogicalKeyboardSerializer().toJson(instance.key),
};
Value? _$JsonConverterFromJson<Json, Value>(
Object? json,
Value? Function(Json json) fromJson,
) =>
json == null ? null : fromJson(json as Json);
Json? _$JsonConverterToJson<Json, Value>(
Value? value,
Json? Function(Value value) toJson,
) =>
value == null ? null : toJson(value);

View file

@ -6,12 +6,49 @@ import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:fladder/models/items/media_segments_model.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/bitrate_helper.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
part 'video_player_settings.freezed.dart'; part 'video_player_settings.freezed.dart';
part 'video_player_settings.g.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) @Freezed(copyWith: true)
class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
const VideoPlayerSettingsModel._(); const VideoPlayerSettingsModel._();
@ -31,6 +68,7 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
@Default(Bitrate.original) Bitrate maxInternetBitrate, @Default(Bitrate.original) Bitrate maxInternetBitrate,
String? audioDevice, String? audioDevice,
@Default(defaultSegmentSkipValues) Map<MediaSegmentType, SegmentSkip> segmentSkipSettings, @Default(defaultSegmentSkipValues) Map<MediaSegmentType, SegmentSkip> segmentSkipSettings,
@Default({}) Map<VideoHotKeys, KeyCombination?> hotKeys,
}) = _VideoPlayerSettingsModel; }) = _VideoPlayerSettingsModel;
double get volume => switch (defaultTargetPlatform) { double get volume => switch (defaultTargetPlatform) {
@ -42,6 +80,11 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
PlayerOptions get wantedPlayer => playerOptions ?? PlayerOptions.platformDefaults; PlayerOptions get wantedPlayer => playerOptions ?? PlayerOptions.platformDefaults;
Map<VideoHotKeys, KeyCombination> get currentShortcuts =>
_defaultVideoHotKeys.map((key, value) => MapEntry(key, hotKeys[key] ?? value));
Map<VideoHotKeys, KeyCombination> get defaultShortCuts => _defaultVideoHotKeys;
bool playerSame(VideoPlayerSettingsModel other) { bool playerSame(VideoPlayerSettingsModel other) {
return other.hardwareAccel == hardwareAccel && return other.hardwareAccel == hardwareAccel &&
other.useLibass == useLibass && other.useLibass == useLibass &&
@ -118,3 +161,22 @@ enum AutoNextType {
AutoNextType.static => context.localized.autoNextOffStaticDesc, AutoNextType.static => context.localized.autoNextOffStaticDesc,
}; };
} }
Map<VideoHotKeys, KeyCombination> 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),
},
};

View file

@ -37,6 +37,8 @@ mixin _$VideoPlayerSettingsModel {
String? get audioDevice => throw _privateConstructorUsedError; String? get audioDevice => throw _privateConstructorUsedError;
Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings => Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
Map<VideoHotKeys, KeyCombination?> get hotKeys =>
throw _privateConstructorUsedError;
/// Serializes this VideoPlayerSettingsModel to a JSON map. /// Serializes this VideoPlayerSettingsModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -68,7 +70,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> {
Bitrate maxHomeBitrate, Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate, Bitrate maxInternetBitrate,
String? audioDevice, String? audioDevice,
Map<MediaSegmentType, SegmentSkip> segmentSkipSettings}); Map<MediaSegmentType, SegmentSkip> segmentSkipSettings,
Map<VideoHotKeys, KeyCombination?> hotKeys});
} }
/// @nodoc /// @nodoc
@ -101,6 +104,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
Object? maxInternetBitrate = null, Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
Object? segmentSkipSettings = null, Object? segmentSkipSettings = null,
Object? hotKeys = null,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
screenBrightness: freezed == screenBrightness screenBrightness: freezed == screenBrightness
@ -159,6 +163,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
? _value.segmentSkipSettings ? _value.segmentSkipSettings
: segmentSkipSettings // ignore: cast_nullable_to_non_nullable : segmentSkipSettings // ignore: cast_nullable_to_non_nullable
as Map<MediaSegmentType, SegmentSkip>, as Map<MediaSegmentType, SegmentSkip>,
hotKeys: null == hotKeys
? _value.hotKeys
: hotKeys // ignore: cast_nullable_to_non_nullable
as Map<VideoHotKeys, KeyCombination?>,
) as $Val); ) as $Val);
} }
} }
@ -186,7 +194,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res>
Bitrate maxHomeBitrate, Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate, Bitrate maxInternetBitrate,
String? audioDevice, String? audioDevice,
Map<MediaSegmentType, SegmentSkip> segmentSkipSettings}); Map<MediaSegmentType, SegmentSkip> segmentSkipSettings,
Map<VideoHotKeys, KeyCombination?> hotKeys});
} }
/// @nodoc /// @nodoc
@ -218,6 +227,7 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
Object? maxInternetBitrate = null, Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
Object? segmentSkipSettings = null, Object? segmentSkipSettings = null,
Object? hotKeys = null,
}) { }) {
return _then(_$VideoPlayerSettingsModelImpl( return _then(_$VideoPlayerSettingsModelImpl(
screenBrightness: freezed == screenBrightness screenBrightness: freezed == screenBrightness
@ -276,6 +286,10 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
? _value._segmentSkipSettings ? _value._segmentSkipSettings
: segmentSkipSettings // ignore: cast_nullable_to_non_nullable : segmentSkipSettings // ignore: cast_nullable_to_non_nullable
as Map<MediaSegmentType, SegmentSkip>, as Map<MediaSegmentType, SegmentSkip>,
hotKeys: null == hotKeys
? _value._hotKeys
: hotKeys // ignore: cast_nullable_to_non_nullable
as Map<VideoHotKeys, KeyCombination?>,
)); ));
} }
} }
@ -299,9 +313,11 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
this.maxInternetBitrate = Bitrate.original, this.maxInternetBitrate = Bitrate.original,
this.audioDevice, this.audioDevice,
final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings = final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings =
defaultSegmentSkipValues}) defaultSegmentSkipValues,
final Map<VideoHotKeys, KeyCombination?> hotKeys = const {}})
: _allowedOrientations = allowedOrientations, : _allowedOrientations = allowedOrientations,
_segmentSkipSettings = segmentSkipSettings, _segmentSkipSettings = segmentSkipSettings,
_hotKeys = hotKeys,
super._(); super._();
factory _$VideoPlayerSettingsModelImpl.fromJson(Map<String, dynamic> json) => factory _$VideoPlayerSettingsModelImpl.fromJson(Map<String, dynamic> json) =>
@ -361,9 +377,18 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
return EqualUnmodifiableMapView(_segmentSkipSettings); return EqualUnmodifiableMapView(_segmentSkipSettings);
} }
final Map<VideoHotKeys, KeyCombination?> _hotKeys;
@override
@JsonKey()
Map<VideoHotKeys, KeyCombination?> get hotKeys {
if (_hotKeys is EqualUnmodifiableMapView) return _hotKeys;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_hotKeys);
}
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 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 @override
@ -384,7 +409,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate)) ..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate))
..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate)) ..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate))
..add(DiagnosticsProperty('audioDevice', audioDevice)) ..add(DiagnosticsProperty('audioDevice', audioDevice))
..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings)); ..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings))
..add(DiagnosticsProperty('hotKeys', hotKeys));
} }
/// Create a copy of VideoPlayerSettingsModel /// Create a copy of VideoPlayerSettingsModel
@ -419,7 +445,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
final Bitrate maxHomeBitrate, final Bitrate maxHomeBitrate,
final Bitrate maxInternetBitrate, final Bitrate maxInternetBitrate,
final String? audioDevice, final String? audioDevice,
final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings}) = final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings,
final Map<VideoHotKeys, KeyCombination?> hotKeys}) =
_$VideoPlayerSettingsModelImpl; _$VideoPlayerSettingsModelImpl;
_VideoPlayerSettingsModel._() : super._(); _VideoPlayerSettingsModel._() : super._();
@ -454,6 +481,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
String? get audioDevice; String? get audioDevice;
@override @override
Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings; Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings;
@override
Map<VideoHotKeys, KeyCombination?> get hotKeys;
/// Create a copy of VideoPlayerSettingsModel /// Create a copy of VideoPlayerSettingsModel
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View file

@ -38,6 +38,14 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson(
$enumDecode(_$SegmentSkipEnumMap, e)), $enumDecode(_$SegmentSkipEnumMap, e)),
) ?? ) ??
defaultSegmentSkipValues, defaultSegmentSkipValues,
hotKeys: (json['hotKeys'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
$enumDecode(_$VideoHotKeysEnumMap, k),
e == null
? null
: KeyCombination.fromJson(e as Map<String, dynamic>)),
) ??
const {},
); );
Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson( Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
@ -60,6 +68,8 @@ Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
'audioDevice': instance.audioDevice, 'audioDevice': instance.audioDevice,
'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) => 'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) =>
MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)),
'hotKeys': instance.hotKeys
.map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)),
}; };
const _$BoxFitEnumMap = { const _$BoxFitEnumMap = {
@ -123,3 +133,19 @@ const _$MediaSegmentTypeEnumMap = {
MediaSegmentType.outro: 'outro', MediaSegmentType.outro: 'outro',
MediaSegmentType.intro: 'intro', 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',
};

View file

@ -7,7 +7,7 @@ part of 'connectivity_provider.dart';
// ************************************************************************** // **************************************************************************
String _$connectivityStatusHash() => String _$connectivityStatusHash() =>
r'7a4ac96d163a479bd34fc6a3efcd556755f8d5e9'; r'8c58479db511f2431942655adf3f5021e8f0290c';
/// See also [ConnectivityStatus]. /// See also [ConnectivityStatus].
@ProviderFor(ConnectivityStatus) @ProviderFor(ConnectivityStatus)

View file

@ -23,6 +23,9 @@ import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/util/jellyfin_extension.dart'; import 'package:fladder/util/jellyfin_extension.dart';
const _userSettings = "usersettings";
const _client = "fladder";
class ServerQueryResult { class ServerQueryResult {
final List<BaseItemDto> original; final List<BaseItemDto> original;
final List<ItemBaseModel> items; final List<ItemBaseModel> items;
@ -745,6 +748,34 @@ class JellyService {
Future<Response<ServerConfiguration>> systemConfigurationGet() => api.systemConfigurationGet(); Future<Response<ServerConfiguration>> systemConfigurationGet() => api.systemConfigurationGet();
Future<Response<PublicSystemInfo>> systemInfoPublicGet() => api.systemInfoPublicGet(); Future<Response<PublicSystemInfo>> systemInfoPublicGet() => api.systemInfoPublicGet();
Future<Response<UserSettings>> 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<Response<dynamic>> 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<Response> sessionsLogoutPost() => api.sessionsLogoutPost(); Future<Response> sessionsLogoutPost() => api.sessionsLogoutPost();
Future<Response<String>> itemsItemIdDownloadGet({ Future<Response<String>> itemsItemIdDownloadGet({
@ -1116,3 +1147,31 @@ class JellyService {
return _updateUserConfiguration(updated); return _updateUserConfiguration(updated);
} }
} }
extension ParsedMap on Map<String, dynamic> {
Map<String, dynamic> parseValues() {
Map<String, dynamic> 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;
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/client_settings_model.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/providers/shared_provider.dart';
import 'package:fladder/util/custom_color_themes.dart'; import 'package:fladder/util/custom_color_themes.dart';
import 'package:fladder/util/debouncer.dart'; import 'package:fladder/util/debouncer.dart';
@ -58,4 +59,15 @@ class ClientSettingsNotifier extends StateNotifier<ClientSettingsModel> {
state = state.copyWith(schemeVariant: type ?? state.schemeVariant); state = state.copyWith(schemeVariant: type ?? state.schemeVariant);
void setRequireWifi(bool value) => state = state.copyWith(requireWifi: value); void setRequireWifi(bool value) => state = state.copyWith(requireWifi: value);
void setShortcuts(MapEntry<GlobalHotKeys, KeyCombination?> 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);
}
} }

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.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/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
@ -67,4 +69,51 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
void toggleOrientation(Set<DeviceOrientation>? orientation) => void toggleOrientation(Set<DeviceOrientation>? orientation) =>
state = state.copyWith(allowedOrientations: orientation); state = state.copyWith(allowedOrientations: orientation);
void setShortcuts(MapEntry<VideoHotKeys, KeyCombination?> 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);
}
}
}
}
} }

View file

@ -6,7 +6,7 @@ part of 'sync_provider_helpers.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$syncedItemHash() => r'7b1178ba78529ebf65425aa4cb8b239a28c7914b'; String _$syncedItemHash() => r'8342c557accf52fd0a8561274ecf9b77b5cf7acd';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {
@ -157,7 +157,7 @@ class _SyncedItemProviderElement
ItemBaseModel? get item => (origin as SyncedItemProvider).item; ItemBaseModel? get item => (origin as SyncedItemProvider).item;
} }
String _$syncedChildrenHash() => r'2b6ce1611750785060df6317ce0ea25e2dc0aeb4'; String _$syncedChildrenHash() => r'75e25432f33e0fe31708618b7ba744430523a4d3';
abstract class _$SyncedChildren abstract class _$SyncedChildren
extends BuildlessAutoDisposeAsyncNotifier<List<SyncedItem>> { extends BuildlessAutoDisposeAsyncNotifier<List<SyncedItem>> {

View file

@ -40,6 +40,9 @@ class User extends _$User {
var response = await api.usersMeGet(); var response = await api.usersMeGet();
var quickConnectStatus = await api.quickConnectEnabled(); var quickConnectStatus = await api.quickConnectEnabled();
var systemConfiguration = await api.systemConfigurationGet(); var systemConfiguration = await api.systemConfigurationGet();
final customConfig = await api.getCustomConfig();
if (response.isSuccessful && response.body != null) { if (response.isSuccessful && response.body != null) {
userState = state?.copyWith( userState = state?.copyWith(
name: response.body?.name ?? state?.name ?? "", name: response.body?.name ?? state?.name ?? "",
@ -48,6 +51,7 @@ class User extends _$User {
userConfiguration: response.body?.configuration, userConfiguration: response.body?.configuration,
quickConnectState: quickConnectStatus.body ?? false, quickConnectState: quickConnectStatus.body ?? false,
latestItemsExcludes: response.body?.configuration?.latestItemsExcludes ?? [], latestItemsExcludes: response.body?.configuration?.latestItemsExcludes ?? [],
userSettings: customConfig.body,
); );
return response.copyWith(body: state); 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<Response<dynamic>> updateCustomConfig(UserSettings settings) async {
state = state?.copyWith(userSettings: settings);
return api.setCustomConfig(settings);
}
Future<Response> refreshMetaData( Future<Response> refreshMetaData(
String itemId, { String itemId, {
MetadataRefresh? metadataRefreshMode, MetadataRefresh? metadataRefreshMode,

View file

@ -24,7 +24,7 @@ final showSyncButtonProviderProvider = AutoDisposeProvider<bool>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef ShowSyncButtonProviderRef = AutoDisposeProviderRef<bool>; typedef ShowSyncButtonProviderRef = AutoDisposeProviderRef<bool>;
String _$userHash() => r'56fca6515c42347fa99dcdcf4f2d8a977335243a'; String _$userHash() => r'24b34a88eae11aec1e377a82d1e507f293b7816a';
/// See also [User]. /// See also [User].
@ProviderFor(User) @ProviderFor(User)

View file

@ -4,9 +4,14 @@ import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.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/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.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/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
@ -17,8 +22,7 @@ enum HomeTabs {
dashboard, dashboard,
library, library,
favorites, favorites,
sync, sync;
;
const HomeTabs(); const HomeTabs();
@ -120,16 +124,37 @@ class HomeScreen extends ConsumerWidget {
}) })
.nonNulls .nonNulls
.toList(); .toList();
return HeroControllerScope( return InputHandler<GlobalHotKeys>(
controller: HeroController(), autoFocus: false,
child: AutoRouter( keyMapResult: (result) {
builder: (context, child) { switch (result) {
return NavigationScaffold( case GlobalHotKeys.search:
destinations: destinations.nonNulls.toList(), context.navigateTo(LibrarySearchRoute());
currentRouteName: context.router.current.name, return true;
nestedChild: child, 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,
);
},
),
), ),
); );
} }

View file

@ -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<Widget> 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)),
),
)
],
),
);
},
)
],
);
}

View file

@ -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_advanced.dart';
import 'package:fladder/screens/settings/client_sections/client_settings_dashboard.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_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_theme.dart';
import 'package:fladder/screens/settings/client_sections/client_settings_visual.dart'; import 'package:fladder/screens/settings/client_sections/client_settings_visual.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart';
@ -61,6 +62,10 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
}, },
), ),
]), ]),
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[
const SizedBox(height: 12),
...buildClientSettingsShortCuts(context, ref),
],
const SizedBox(height: 12), const SizedBox(height: 12),
...buildClientSettingsDashboard(context, ref), ...buildClientSettingsDashboard(context, ref),
const SizedBox(height: 12), const SizedBox(height: 12),

View file

@ -14,6 +14,7 @@ import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.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_label_divider.dart';
import 'package:fladder/screens/settings/widgets/settings_list_group.dart'; import 'package:fladder/screens/settings/widgets/settings_list_group.dart';
import 'package:fladder/screens/settings/widgets/settings_message_box.dart'; import 'package:fladder/screens/settings/widgets/settings_message_box.dart';
@ -43,6 +44,8 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
final connectionState = ref.watch(connectivityStatusProvider); final connectionState = ref.watch(connectivityStatusProvider);
final userSettings = ref.watch(userProvider.select((value) => value?.userSettings));
return SettingsScaffold( return SettingsScaffold(
label: context.localized.settingsPlayerTitle, label: context.localized.settingsPlayerTitle,
items: [ items: [
@ -169,6 +172,70 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
), ),
]), ]),
const SizedBox(height: 12), 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), [ ...settingsListGroup(context, SettingsLabelDivider(label: context.localized.playbackTrackSelection), [
SettingsListTile( SettingsListTile(
label: Text(context.localized.rememberAudioSelections), label: Text(context.localized.rememberAudioSelections),

View file

@ -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<KeyCombinationWidget> {
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 };
}
}

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/providers/video_player_provider.dart';
import 'package:fladder/util/input_handler.dart'; import 'package:fladder/util/input_handler.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
@ -48,35 +51,12 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState<VideoPlayerSeekIndica
}); });
} }
bool _onKey(KeyEvent value) {
if (value is KeyRepeatEvent) {
if (value.logicalKey == LogicalKeyboardKey.arrowLeft) {
seekBack();
return true;
}
if (value.logicalKey == LogicalKeyboardKey.arrowRight) {
seekForward();
return true;
}
}
if (value is KeyDownEvent) {
if (value.logicalKey == LogicalKeyboardKey.arrowLeft) {
seekBack();
return true;
}
if (value.logicalKey == LogicalKeyboardKey.arrowRight) {
seekForward();
return true;
}
}
return false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InputHandler( return InputHandler(
autoFocus: true, autoFocus: true,
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)),
keyMapResult: (result) => _onKey(result),
child: IgnorePointer( child: IgnorePointer(
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
@ -108,6 +88,29 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState<VideoPlayerSeekIndica
); );
} }
void seekBack({int seconds = -10}) => onSeekStart(seconds); bool _onKey(VideoHotKeys value) {
void seekForward({int seconds = 30}) => onSeekStart(seconds); 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);
}
} }

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
@ -19,6 +19,8 @@ class VideoVolumeSlider extends ConsumerStatefulWidget {
class _VideoVolumeSliderState extends ConsumerState<VideoVolumeSlider> { class _VideoVolumeSliderState extends ConsumerState<VideoVolumeSlider> {
bool sliderActive = false; bool sliderActive = false;
double? previousVolume;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final volume = ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)); final volume = ref.watch(videoPlayerSettingsProvider.select((value) => value.volume));
@ -27,7 +29,12 @@ class _VideoVolumeSliderState extends ConsumerState<VideoVolumeSlider> {
children: [ children: [
IconButton( IconButton(
icon: Icon(volumeIcon(volume)), 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( AnimatedSize(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),

View file

@ -10,11 +10,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:screen_brightness/screen_brightness.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/items/media_segments_model.dart';
import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/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/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_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/providers/video_player_provider.dart';
import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/screens/shared/default_title_bar.dart';
import 'package:fladder/screens/shared/media/components/item_logo.dart'; import 'package:fladder/screens/shared/media/components/item_logo.dart';
@ -48,6 +51,8 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
() => mounted ? toggleOverlay(value: false) : null, () => mounted ? toggleOverlay(value: false) : null,
); );
double? previousVolume;
final fadeDuration = const Duration(milliseconds: 350); final fadeDuration = const Duration(milliseconds: 350);
bool showOverlay = true; bool showOverlay = true;
bool wasPlaying = false; bool wasPlaying = false;
@ -55,55 +60,6 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
late final double topPadding = MediaQuery.of(context).viewPadding.top; late final double topPadding = MediaQuery.of(context).viewPadding.top;
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -116,8 +72,9 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
final player = ref.watch(videoPlayerProvider); final player = ref.watch(videoPlayerProvider);
final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey); final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey);
return InputHandler( return InputHandler(
autoFocus: false, autoFocus: true,
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)),
keyMapResult: (result) => _onKey(result),
child: PopScope( child: PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@ -536,8 +493,6 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo)); final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo));
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
return Tooltip( return Tooltip(
message: previousVideo?.detailedName(context) ?? "", message: previousVideo?.detailedName(context) ?? "",
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -547,9 +502,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
), ),
textStyle: Theme.of(context).textTheme.labelLarge, textStyle: Theme.of(context).textTheme.labelLarge,
child: IconButton( child: IconButton(
onPressed: previousVideo != null && !buffering onPressed: loadPreviousVideo(ref, video: previousVideo),
? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo)
: null,
iconSize: 30, iconSize: 30,
icon: const Icon( icon: const Icon(
IconsaxPlusLinear.backward, IconsaxPlusLinear.backward,
@ -560,11 +513,16 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
); );
} }
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 { Widget get nextVideoButton {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo)); final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo));
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
return Tooltip( return Tooltip(
message: nextVideo?.detailedName(context) ?? "", message: nextVideo?.detailedName(context) ?? "",
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -574,8 +532,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
), ),
textStyle: Theme.of(context).textTheme.labelLarge, textStyle: Theme.of(context).textTheme.labelLarge,
child: IconButton( child: IconButton(
onPressed: onPressed: loadNextVideo(ref, video: nextVideo),
nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null,
iconSize: 30, iconSize: 30,
icon: const Icon( icon: const Icon(
IconsaxPlusLinear.forward, IconsaxPlusLinear.forward,
@ -586,25 +543,62 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
); );
} }
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) { Widget seekBackwardButton(WidgetRef ref) {
final backwardSpeed =
ref.read(userProvider.select((value) => value?.userSettings?.skipBackDuration.inSeconds ?? 30));
return IconButton( return IconButton(
onPressed: () => seekBack(ref), onPressed: () => seekBack(ref, seconds: backwardSpeed),
tooltip: "-10", tooltip: "-$backwardSpeed",
iconSize: 40, iconSize: 40,
icon: const Icon( icon: Stack(
IconsaxPlusLinear.backward_10_seconds, 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) { Widget seekForwardButton(WidgetRef ref) {
final forwardSpeed =
ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30));
return IconButton( return IconButton(
onPressed: () => seekForward(ref), onPressed: () => seekForward(ref, seconds: forwardSpeed),
tooltip: "15", tooltip: forwardSpeed.toString(),
iconSize: 40, iconSize: 40,
icon: const Stack( icon: Stack(
alignment: Alignment.center,
children: [ 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<DesktopControls> {
resetTimer(); resetTimer();
fullScreenHelper.closeFullScreen(ref); 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;
}
}
} }

View file

@ -1,27 +1,36 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class InputHandler extends StatefulWidget { import 'package:fladder/models/settings/key_combinations.dart';
class InputHandler<T> extends StatefulWidget {
final bool autoFocus; final bool autoFocus;
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
final bool Function(T result)? keyMapResult;
final Map<T, KeyCombination>? keyMap;
final Widget child; final Widget child;
const InputHandler({ const InputHandler({
required this.child, required this.child,
this.autoFocus = true, this.autoFocus = true,
this.onKeyEvent, this.onKeyEvent,
this.keyMapResult,
this.keyMap,
super.key, super.key,
}); });
@override @override
State<InputHandler> createState() => _InputHandlerState(); State<InputHandler> createState() => _InputHandlerState<T>();
} }
class _InputHandlerState extends State<InputHandler> { class _InputHandlerState<T> extends State<InputHandler<T>> {
final focusNode = FocusNode(); final focusNode = FocusNode();
LogicalKeyboardKey? pressedModifier;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
//Focus on start // Focus on start
focusNode.requestFocus(); focusNode.requestFocus();
} }
@ -35,8 +44,38 @@ class _InputHandlerState extends State<InputHandler> {
focusNode.requestFocus(); focusNode.requestFocus();
} }
}, },
onKeyEvent: widget.onKeyEvent, onKeyEvent: (node, event) => _onKey(event),
child: widget.child, 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;
}
} }