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

@ -7,7 +7,7 @@ part of 'connectivity_provider.dart';
// **************************************************************************
String _$connectivityStatusHash() =>
r'7a4ac96d163a479bd34fc6a3efcd556755f8d5e9';
r'8c58479db511f2431942655adf3f5021e8f0290c';
/// See also [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/util/jellyfin_extension.dart';
const _userSettings = "usersettings";
const _client = "fladder";
class ServerQueryResult {
final List<BaseItemDto> original;
final List<ItemBaseModel> items;
@ -745,6 +748,34 @@ class JellyService {
Future<Response<ServerConfiguration>> systemConfigurationGet() => api.systemConfigurationGet();
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<String>> itemsItemIdDownloadGet({
@ -1116,3 +1147,31 @@ class JellyService {
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:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/models/settings/key_combinations.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/util/custom_color_themes.dart';
import 'package:fladder/util/debouncer.dart';
@ -58,4 +59,15 @@ class ClientSettingsNotifier extends StateNotifier<ClientSettingsModel> {
state = state.copyWith(schemeVariant: type ?? state.schemeVariant);
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/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/models/settings/key_combinations.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
@ -67,4 +69,51 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
void toggleOrientation(Set<DeviceOrientation>? 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
// **************************************************************************
String _$syncedItemHash() => r'7b1178ba78529ebf65425aa4cb8b239a28c7914b';
String _$syncedItemHash() => r'8342c557accf52fd0a8561274ecf9b77b5cf7acd';
/// Copied from Dart SDK
class _SystemHash {
@ -157,7 +157,7 @@ class _SyncedItemProviderElement
ItemBaseModel? get item => (origin as SyncedItemProvider).item;
}
String _$syncedChildrenHash() => r'2b6ce1611750785060df6317ce0ea25e2dc0aeb4';
String _$syncedChildrenHash() => r'75e25432f33e0fe31708618b7ba744430523a4d3';
abstract class _$SyncedChildren
extends BuildlessAutoDisposeAsyncNotifier<List<SyncedItem>> {

View file

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

View file

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