From 2734454717bbfb5d0621c6ea72fa755ef4fc8602 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Sep 2022 21:50:05 +0600 Subject: [PATCH] feat(keyboard shortcuts): play/pause on space, seek position on left/right --- lib/components/Player/PlayerActions.dart | 4 +- lib/components/Player/PlayerControls.dart | 291 ++++++++++++---------- lib/components/Player/PlayerOverlay.dart | 7 +- lib/hooks/playback.dart | 22 -- lib/main.dart | 15 +- lib/models/Intents.dart | 85 +++++++ 6 files changed, 269 insertions(+), 155 deletions(-) create mode 100644 lib/models/Intents.dart diff --git a/lib/components/Player/PlayerActions.dart b/lib/components/Player/PlayerActions.dart index 08d03e15e..b8e76b3d7 100644 --- a/lib/components/Player/PlayerActions.dart +++ b/lib/components/Player/PlayerActions.dart @@ -79,11 +79,11 @@ class PlayerActions extends HookConsumerWidget { if (!kIsWeb) if (isInQueue) const SizedBox( + height: 20, + width: 20, child: CircularProgressIndicator.adaptive( strokeWidth: 2, ), - height: 20, - width: 20, ) else IconButton( diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index b6096e869..e3c5c7198 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/hooks/playback.dart'; +import 'package:spotube/models/Intents.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/utils/primitive_utils.dart'; @@ -16,152 +18,185 @@ class PlayerControls extends HookConsumerWidget { final logger = getLogger(PlayerControls); + static FocusNode focusNode = FocusNode(); + @override Widget build(BuildContext context, ref) { + final shortcuts = useMemoized( + () => { + const SingleActivator(LogicalKeyboardKey.arrowRight): + SeekIntent(ref, true), + const SingleActivator(LogicalKeyboardKey.arrowLeft): + SeekIntent(ref, false), + }, + [ref]); + final actions = useMemoized( + () => { + SeekIntent: SeekAction(), + }, + []); final Playback playback = ref.watch(playbackProvider); final onNext = useNextTrack(ref); final onPrevious = usePreviousTrack(ref); - final _playOrPause = useTogglePlayPause(ref); final duration = playback.currentDuration; - return Container( - constraints: const BoxConstraints(maxWidth: 600), - child: Column( - children: [ - StreamBuilder( - stream: playback.player.onPositionChanged, - builder: (context, snapshot) { - final totalMinutes = PrimitiveUtils.zeroPadNumStr( - duration.inMinutes.remainder(60)); - final totalSeconds = PrimitiveUtils.zeroPadNumStr( - duration.inSeconds.remainder(60)); - final currentMinutes = snapshot.hasData - ? PrimitiveUtils.zeroPadNumStr( - snapshot.data!.inMinutes.remainder(60)) - : "00"; - final currentSeconds = snapshot.hasData - ? PrimitiveUtils.zeroPadNumStr( - snapshot.data!.inSeconds.remainder(60)) - : "00"; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (focusNode.canRequestFocus) { + focusNode.requestFocus(); + } + }, + child: FocusableActionDetector( + focusNode: focusNode, + shortcuts: shortcuts, + actions: actions, + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + child: Column( + children: [ + StreamBuilder( + stream: playback.player.onPositionChanged, + builder: (context, snapshot) { + final totalMinutes = PrimitiveUtils.zeroPadNumStr( + duration.inMinutes.remainder(60)); + final totalSeconds = PrimitiveUtils.zeroPadNumStr( + duration.inSeconds.remainder(60)); + final currentMinutes = snapshot.hasData + ? PrimitiveUtils.zeroPadNumStr( + snapshot.data!.inMinutes.remainder(60)) + : "00"; + final currentSeconds = snapshot.hasData + ? PrimitiveUtils.zeroPadNumStr( + snapshot.data!.inSeconds.remainder(60)) + : "00"; - final sliderMax = duration.inSeconds; - final sliderValue = snapshot.data?.inSeconds ?? 0; + final sliderMax = duration.inSeconds; + final sliderValue = snapshot.data?.inSeconds ?? 0; - return HookBuilder( - builder: (context) { - final progressStatic = - (sliderMax == 0 || sliderValue > sliderMax) - ? 0 - : sliderValue / sliderMax; + return HookBuilder( + builder: (context) { + final progressStatic = + (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : sliderValue / sliderMax; - final progress = useState( - useMemoized(() => progressStatic, []), - ); + final progress = useState( + useMemoized(() => progressStatic, []), + ); - useEffect(() { - progress.value = progressStatic; - return null; - }, [progressStatic]); + useEffect(() { + progress.value = progressStatic; + return null; + }, [progressStatic]); - return Column( - children: [ - Slider.adaptive( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: progress.value.toDouble(), - onChanged: (v) { - progress.value = v; - }, - onChangeEnd: (value) async { - await playback.seekPosition( - Duration( - seconds: (value * sliderMax).toInt(), - ), - ); - }, - activeColor: iconColor, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, + return Column( + children: [ + Slider.adaptive( + focusNode: FocusNode(), + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: progress.value.toDouble(), + onChanged: (v) { + progress.value = v; + }, + onChangeEnd: (value) async { + await playback.seekPosition( + Duration( + seconds: (value * sliderMax).toInt(), + ), + ); + }, + activeColor: iconColor, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "$currentMinutes:$currentSeconds", + ), + Text("$totalMinutes:$totalSeconds"), + ], + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "$currentMinutes:$currentSeconds", - ), - Text("$totalMinutes:$totalSeconds"), - ], + ], + ); + }, + ); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: Icon( + playback.isLoop + ? Icons.repeat_one_rounded + : playback.isShuffled + ? Icons.shuffle_rounded + : Icons.repeat_rounded, + ), + onPressed: + playback.track == null || playback.playlist == null + ? null + : playback.cyclePlaybackMode, + ), + IconButton( + icon: const Icon(Icons.skip_previous_rounded), + color: iconColor, + onPressed: () { + onPrevious(); + }), + IconButton( + icon: playback.status == PlaybackStatus.loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon( + playback.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, ), - ), - ], - ); - }, - ); - }, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: Icon( - playback.isLoop - ? Icons.repeat_one_rounded - : playback.isShuffled - ? Icons.shuffle_rounded - : Icons.repeat_rounded, + color: iconColor, + onPressed: Actions.handler( + context, + PlayPauseIntent(ref), + ), + ), + IconButton( + icon: const Icon(Icons.skip_next_rounded), + onPressed: () => onNext(), + color: iconColor, ), - onPressed: playback.track == null || playback.playlist == null - ? null - : playback.cyclePlaybackMode, - ), - IconButton( - icon: const Icon(Icons.skip_previous_rounded), + IconButton( + icon: const Icon(Icons.stop_rounded), color: iconColor, - onPressed: () { - onPrevious(); - }), - IconButton( - icon: playback.status == PlaybackStatus.loading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - playback.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - color: iconColor, - onPressed: _playOrPause, - ), - IconButton( - icon: const Icon(Icons.skip_next_rounded), - onPressed: () => onNext(), - color: iconColor, - ), - IconButton( - icon: const Icon(Icons.stop_rounded), - color: iconColor, - onPressed: playback.track != null - ? () async { - try { - await playback.stop(); - } catch (e, stack) { - logger.e("onStop", e, stack); + onPressed: playback.track != null + ? () async { + try { + await playback.stop(); + } catch (e, stack) { + logger.e("onStop", e, stack); + } } - } - : null, - ) - ], - ), - const SizedBox(height: 5) - ], - )); + : null, + ) + ], + ), + const SizedBox(height: 5) + ], + ), + ), + ), + ); } } diff --git a/lib/components/Player/PlayerOverlay.dart b/lib/components/Player/PlayerOverlay.dart index 302ea109b..ee9e2c3b9 100644 --- a/lib/components/Player/PlayerOverlay.dart +++ b/lib/components/Player/PlayerOverlay.dart @@ -7,6 +7,7 @@ import 'package:spotube/components/Player/PlayerTrackDetails.dart'; import 'package:spotube/hooks/playback.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/usePaletteColor.dart'; +import 'package:spotube/models/Intents.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; @@ -33,7 +34,6 @@ class PlayerOverlay extends HookConsumerWidget { final onNext = useNextTrack(ref); final onPrevious = usePreviousTrack(ref); - final _playOrPause = useTogglePlayPause(ref); if (!isHome && !isAllowedPage) return Container(); @@ -109,7 +109,10 @@ class PlayerOverlay extends HookConsumerWidget { : Icons.play_arrow_rounded, ), color: paletteColor.bodyTextColor, - onPressed: _playOrPause, + onPressed: Actions.handler( + context, + PlayPauseIntent(ref), + ), ); }, ), diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart index 69a12cbfc..23f16c855 100644 --- a/lib/hooks/playback.dart +++ b/lib/hooks/playback.dart @@ -1,5 +1,4 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; @@ -30,24 +29,3 @@ Future Function() usePreviousTrack(WidgetRef ref) { } }; } - -Future Function([dynamic]) useTogglePlayPause(WidgetRef ref) { - return ([key]) async { - try { - final playback = ref.read(playbackProvider); - if (playback.track == null) { - return; - } else if (playback.track != null && - playback.currentDuration == Duration.zero && - await playback.player.getCurrentPosition() == Duration.zero) { - final track = Track.fromJson(playback.track!.toJson()); - playback.track = null; - await playback.play(track); - } else { - await playback.togglePlayPause(); - } - } catch (e, stack) { - logger.e("useTogglePlayPause", e, stack); - } - }; -} diff --git a/lib/main.dart b/lib/main.dart index b2169978c..19f2539a5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:audio_service/audio_service.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -11,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/components/Shared/ReplaceDownloadedFileDialog.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; +import 'package:spotube/models/Intents.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/AudioPlayer.dart'; @@ -52,7 +54,6 @@ void main() async { Builder( builder: (context) { return ProviderScope( - child: const Spotube(), overrides: [ playbackProvider.overrideWithProvider( ChangeNotifierProvider( @@ -123,6 +124,7 @@ void main() async { ), ) ], + child: const Spotube(), ); }, ), @@ -205,6 +207,17 @@ class _SpotubeState extends ConsumerState with WidgetsBindingObserver { backgroundMaterialColor: backgroundMaterialColor, ), themeMode: themeMode, + shortcuts: { + ...WidgetsApp.defaultShortcuts, + const SingleActivator(LogicalKeyboardKey.space): PlayPauseIntent(ref), + const SingleActivator(LogicalKeyboardKey.comma, control: true): + OpenSettingsIntent(_router), + }, + actions: { + ...WidgetsApp.defaultActions, + PlayPauseIntent: PlayPauseAction(), + OpenSettingsIntent: OpenSettingsAction(), + }, ); } } diff --git a/lib/models/Intents.dart b/lib/models/Intents.dart new file mode 100644 index 000000000..3bd7fe7f1 --- /dev/null +++ b/lib/models/Intents.dart @@ -0,0 +1,85 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Player/PlayerControls.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:spotube/provider/Playback.dart'; + +class PlayPauseIntent extends Intent { + final WidgetRef ref; + const PlayPauseIntent(this.ref); +} + +class PlayPauseAction extends Action { + final logger = getLogger(PlayPauseAction); + + @override + invoke(intent) async { + try { + if (PlayerControls.focusNode.canRequestFocus) { + PlayerControls.focusNode.requestFocus(); + } + final playback = intent.ref.read(playbackProvider); + if (playback.track == null) { + return null; + } else if (playback.track != null && + playback.currentDuration == Duration.zero && + await playback.player.getCurrentPosition() == Duration.zero) { + final track = Track.fromJson(playback.track!.toJson()); + playback.track = null; + await playback.play(track); + } else { + await playback.togglePlayPause(); + } + return null; + } catch (e, stack) { + logger.e("useTogglePlayPause", e, stack); + return null; + } + } +} + +class OpenSettingsIntent extends Intent { + final GoRouter router; + const OpenSettingsIntent(this.router); +} + +class OpenSettingsAction extends Action { + @override + invoke(intent) async { + intent.router.push("/settings"); + FocusManager.instance.primaryFocus?.unfocus(); + return null; + } +} + +class SeekIntent extends Intent { + final WidgetRef ref; + final bool forward; + const SeekIntent(this.ref, this.forward); +} + +class SeekAction extends Action { + @override + invoke(intent) async { + final playback = intent.ref.read(playbackProvider); + if ((playback.playlist == null && playback.track == null) || + playback.status == PlaybackStatus.loading) { + DirectionalFocusAction().invoke( + DirectionalFocusIntent( + intent.forward ? TraversalDirection.right : TraversalDirection.left, + ), + ); + return null; + } + final position = + (await playback.player.getCurrentPosition() ?? Duration.zero).inSeconds; + await playback.seekPosition( + Duration( + seconds: intent.forward ? position + 5 : position - 5, + ), + ); + return null; + } +}