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

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:async/async.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/input_handler.dart';
import 'package:fladder/util/localization_helper.dart';
@ -48,35 +51,12 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState<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
Widget build(BuildContext context) {
return InputHandler(
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: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
@ -108,6 +88,29 @@ class _VideoPlayerSeekIndicatorState extends ConsumerState<VideoPlayerSeekIndica
);
}
void seekBack({int seconds = -10}) => onSeekStart(seconds);
void seekForward({int seconds = 30}) => onSeekStart(seconds);
bool _onKey(VideoHotKeys value) {
switch (value) {
case VideoHotKeys.seekForward:
seekForward();
return true;
case VideoHotKeys.seekBack:
seekBack();
return true;
default:
break;
}
return false;
}
void seekBack() {
final seconds = -ref.read(userProvider
.select((value) => (value?.userSettings?.skipBackDuration ?? UserSettings().skipBackDuration).inSeconds));
onSeekStart(seconds);
}
void seekForward() {
final seconds = ref.read(userProvider
.select((value) => (value?.userSettings?.skipForwardDuration ?? UserSettings().skipForwardDuration).inSeconds));
onSeekStart(seconds);
}
}

View file

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

View file

@ -10,11 +10,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/shared/default_title_bar.dart';
import 'package:fladder/screens/shared/media/components/item_logo.dart';
@ -48,6 +51,8 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
() => mounted ? toggleOverlay(value: false) : null,
);
double? previousVolume;
final fadeDuration = const Duration(milliseconds: 350);
bool showOverlay = true;
bool wasPlaying = false;
@ -55,55 +60,6 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
late final double topPadding = MediaQuery.of(context).viewPadding.top;
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
bool _onKey(KeyEvent value) {
final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments));
final position = ref.read(mediaPlaybackProvider).position;
MediaSegment? segment = mediaSegments?.atPosition(position);
if (value is KeyRepeatEvent) {
if (value.logicalKey == LogicalKeyboardKey.arrowUp) {
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5);
return true;
}
if (value.logicalKey == LogicalKeyboardKey.arrowDown) {
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5);
return true;
}
}
if (value is KeyDownEvent) {
if (value.logicalKey == LogicalKeyboardKey.keyS) {
if (segment != null) {
skipToSegmentEnd(segment);
}
return true;
}
if (value.logicalKey == LogicalKeyboardKey.escape) {
disableFullScreen();
return true;
}
if (value.logicalKey == LogicalKeyboardKey.space) {
ref.read(videoPlayerProvider).playOrPause();
return true;
}
if (value.logicalKey == LogicalKeyboardKey.keyF) {
fullScreenHelper.toggleFullScreen(ref);
return true;
}
if (value.logicalKey == LogicalKeyboardKey.arrowUp) {
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5);
return true;
}
if (value.logicalKey == LogicalKeyboardKey.arrowDown) {
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5);
return true;
}
}
return false;
}
@override
void initState() {
super.initState();
@ -116,8 +72,9 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
final player = ref.watch(videoPlayerProvider);
final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey);
return InputHandler(
autoFocus: false,
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored,
autoFocus: true,
keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)),
keyMapResult: (result) => _onKey(result),
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
@ -536,8 +493,6 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
return Consumer(
builder: (context, ref, child) {
final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo));
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
return Tooltip(
message: previousVideo?.detailedName(context) ?? "",
textAlign: TextAlign.center,
@ -547,9 +502,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
),
textStyle: Theme.of(context).textTheme.labelLarge,
child: IconButton(
onPressed: previousVideo != null && !buffering
? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo)
: null,
onPressed: loadPreviousVideo(ref, video: previousVideo),
iconSize: 30,
icon: const Icon(
IconsaxPlusLinear.backward,
@ -560,11 +513,16 @@ class _DesktopControlsState extends ConsumerState<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 {
return Consumer(
builder: (context, ref, child) {
final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo));
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
return Tooltip(
message: nextVideo?.detailedName(context) ?? "",
textAlign: TextAlign.center,
@ -574,8 +532,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
),
textStyle: Theme.of(context).textTheme.labelLarge,
child: IconButton(
onPressed:
nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null,
onPressed: loadNextVideo(ref, video: nextVideo),
iconSize: 30,
icon: const Icon(
IconsaxPlusLinear.forward,
@ -586,25 +543,62 @@ class _DesktopControlsState extends ConsumerState<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) {
final backwardSpeed =
ref.read(userProvider.select((value) => value?.userSettings?.skipBackDuration.inSeconds ?? 30));
return IconButton(
onPressed: () => seekBack(ref),
tooltip: "-10",
onPressed: () => seekBack(ref, seconds: backwardSpeed),
tooltip: "-$backwardSpeed",
iconSize: 40,
icon: const Icon(
IconsaxPlusLinear.backward_10_seconds,
icon: Stack(
alignment: Alignment.center,
children: [
const Icon(
IconsaxPlusBroken.refresh,
size: 45,
),
Transform.translate(
offset: const Offset(0, 1),
child: Text(
"-$backwardSpeed",
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
Widget seekForwardButton(WidgetRef ref) {
final forwardSpeed =
ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30));
return IconButton(
onPressed: () => seekForward(ref),
tooltip: "15",
onPressed: () => seekForward(ref, seconds: forwardSpeed),
tooltip: forwardSpeed.toString(),
iconSize: 40,
icon: const Stack(
icon: Stack(
alignment: Alignment.center,
children: [
Icon(IconsaxPlusLinear.forward_15_seconds),
Transform.flip(
flipX: true,
child: const Icon(
IconsaxPlusBroken.refresh,
size: 45,
),
),
Transform.translate(
offset: const Offset(0, 1),
child: Text(
forwardSpeed.toString(),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
@ -678,4 +672,58 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
resetTimer();
fullScreenHelper.closeFullScreen(ref);
}
bool _onKey(VideoHotKeys value) {
final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments));
final position = ref.read(mediaPlaybackProvider).position;
MediaSegment? segment = mediaSegments?.atPosition(position);
final volume = ref.read(videoPlayerSettingsProvider.select((value) => value.volume));
switch (value) {
case VideoHotKeys.playPause:
ref.read(videoPlayerProvider).playOrPause();
return true;
case VideoHotKeys.volumeUp:
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5);
return true;
case VideoHotKeys.volumeDown:
resetTimer();
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5);
return true;
case VideoHotKeys.fullScreen:
fullScreenHelper.toggleFullScreen(ref);
return true;
case VideoHotKeys.skipMediaSegment:
if (segment != null) {
skipToSegmentEnd(segment);
}
return true;
case VideoHotKeys.exit:
disableFullScreen();
return true;
case VideoHotKeys.mute:
if (volume != 0) {
previousVolume = volume;
}
ref.read(videoPlayerSettingsProvider.notifier).setVolume(volume == 0 ? (previousVolume ?? 100) : 0);
return true;
case VideoHotKeys.nextVideo:
loadNextVideo(ref)?.call();
return true;
case VideoHotKeys.prevVideo:
loadPreviousVideo(ref)?.call();
return true;
case VideoHotKeys.nextChapter:
ref.read(videoPlayerSettingsProvider.notifier).nextChapter();
return true;
case VideoHotKeys.prevChapter:
ref.read(videoPlayerSettingsProvider.notifier).prevChapter();
return true;
default:
return false;
}
}
}