feat(keyboard shortcuts): play/pause on space, seek position on left/right

This commit is contained in:
Kingkor Roy Tirtho 2022-09-29 21:50:05 +06:00
parent dbb81de763
commit 2734454717
6 changed files with 269 additions and 155 deletions

View File

@ -79,11 +79,11 @@ class PlayerActions extends HookConsumerWidget {
if (!kIsWeb) if (!kIsWeb)
if (isInQueue) if (isInQueue)
const SizedBox( const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
strokeWidth: 2, strokeWidth: 2,
), ),
height: 20,
width: 20,
) )
else else
IconButton( IconButton(

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/hooks/playback.dart'; import 'package:spotube/hooks/playback.dart';
import 'package:spotube/models/Intents.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
@ -16,152 +18,185 @@ class PlayerControls extends HookConsumerWidget {
final logger = getLogger(PlayerControls); final logger = getLogger(PlayerControls);
static FocusNode focusNode = FocusNode();
@override @override
Widget build(BuildContext context, ref) { 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 Playback playback = ref.watch(playbackProvider);
final onNext = useNextTrack(ref); final onNext = useNextTrack(ref);
final onPrevious = usePreviousTrack(ref); final onPrevious = usePreviousTrack(ref);
final _playOrPause = useTogglePlayPause(ref);
final duration = playback.currentDuration; final duration = playback.currentDuration;
return Container( return GestureDetector(
constraints: const BoxConstraints(maxWidth: 600), behavior: HitTestBehavior.translucent,
child: Column( onTap: () {
children: [ if (focusNode.canRequestFocus) {
StreamBuilder<Duration>( focusNode.requestFocus();
stream: playback.player.onPositionChanged, }
builder: (context, snapshot) { },
final totalMinutes = PrimitiveUtils.zeroPadNumStr( child: FocusableActionDetector(
duration.inMinutes.remainder(60)); focusNode: focusNode,
final totalSeconds = PrimitiveUtils.zeroPadNumStr( shortcuts: shortcuts,
duration.inSeconds.remainder(60)); actions: actions,
final currentMinutes = snapshot.hasData child: Container(
? PrimitiveUtils.zeroPadNumStr( constraints: const BoxConstraints(maxWidth: 600),
snapshot.data!.inMinutes.remainder(60)) child: Column(
: "00"; children: [
final currentSeconds = snapshot.hasData StreamBuilder<Duration>(
? PrimitiveUtils.zeroPadNumStr( stream: playback.player.onPositionChanged,
snapshot.data!.inSeconds.remainder(60)) builder: (context, snapshot) {
: "00"; 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 sliderMax = duration.inSeconds;
final sliderValue = snapshot.data?.inSeconds ?? 0; final sliderValue = snapshot.data?.inSeconds ?? 0;
return HookBuilder( return HookBuilder(
builder: (context) { builder: (context) {
final progressStatic = final progressStatic =
(sliderMax == 0 || sliderValue > sliderMax) (sliderMax == 0 || sliderValue > sliderMax)
? 0 ? 0
: sliderValue / sliderMax; : sliderValue / sliderMax;
final progress = useState<num>( final progress = useState<num>(
useMemoized(() => progressStatic, []), useMemoized(() => progressStatic, []),
); );
useEffect(() { useEffect(() {
progress.value = progressStatic; progress.value = progressStatic;
return null; return null;
}, [progressStatic]); }, [progressStatic]);
return Column( return Column(
children: [ children: [
Slider.adaptive( Slider.adaptive(
// cannot divide by zero focusNode: FocusNode(),
// there's an edge case for value being bigger // cannot divide by zero
// than total duration. Keeping it resolved // there's an edge case for value being bigger
value: progress.value.toDouble(), // than total duration. Keeping it resolved
onChanged: (v) { value: progress.value.toDouble(),
progress.value = v; onChanged: (v) {
}, progress.value = v;
onChangeEnd: (value) async { },
await playback.seekPosition( onChangeEnd: (value) async {
Duration( await playback.seekPosition(
seconds: (value * sliderMax).toInt(), Duration(
), seconds: (value * sliderMax).toInt(),
); ),
}, );
activeColor: iconColor, },
), activeColor: iconColor,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
), ),
child: Row( Padding(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: const EdgeInsets.symmetric(
children: [ horizontal: 8.0,
Text( ),
"$currentMinutes:$currentSeconds", child: Row(
), mainAxisAlignment: MainAxisAlignment.spaceBetween,
Text("$totalMinutes:$totalSeconds"), children: [
], Text(
"$currentMinutes:$currentSeconds",
),
Text("$totalMinutes:$totalSeconds"),
],
),
), ),
), ],
], );
); },
}, );
); },
}, ),
), Row(
Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
children: [ IconButton(
IconButton( icon: Icon(
icon: Icon( playback.isLoop
playback.isLoop ? Icons.repeat_one_rounded
? Icons.repeat_one_rounded : playback.isShuffled
: playback.isShuffled ? Icons.shuffle_rounded
? Icons.shuffle_rounded : Icons.repeat_rounded,
: Icons.repeat_rounded, ),
onPressed:
playback.track == null || playback.playlist == null
? null
: playback.cyclePlaybackMode,
), ),
onPressed: playback.track == null || playback.playlist == null IconButton(
? null icon: const Icon(Icons.skip_previous_rounded),
: playback.cyclePlaybackMode, color: iconColor,
), onPressed: () {
IconButton( onPrevious();
icon: const Icon(Icons.skip_previous_rounded), }),
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, color: iconColor,
onPressed: () { onPressed: Actions.handler<PlayPauseIntent>(
onPrevious(); context,
}), PlayPauseIntent(ref),
IconButton( ),
icon: playback.status == PlaybackStatus.loading ),
? const SizedBox( IconButton(
height: 20, icon: const Icon(Icons.skip_next_rounded),
width: 20, onPressed: () => onNext(),
child: CircularProgressIndicator(), color: iconColor,
) ),
: Icon( IconButton(
playback.isPlaying icon: const Icon(Icons.stop_rounded),
? Icons.pause_rounded color: iconColor,
: Icons.play_arrow_rounded, onPressed: playback.track != null
), ? () async {
color: iconColor, try {
onPressed: _playOrPause, await playback.stop();
), } catch (e, stack) {
IconButton( logger.e("onStop", e, stack);
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);
} }
} : null,
: null, )
) ],
], ),
), const SizedBox(height: 5)
const SizedBox(height: 5) ],
], ),
)); ),
),
);
} }
} }

View File

@ -7,6 +7,7 @@ import 'package:spotube/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/hooks/playback.dart'; import 'package:spotube/hooks/playback.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/models/Intents.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
@ -33,7 +34,6 @@ class PlayerOverlay extends HookConsumerWidget {
final onNext = useNextTrack(ref); final onNext = useNextTrack(ref);
final onPrevious = usePreviousTrack(ref); final onPrevious = usePreviousTrack(ref);
final _playOrPause = useTogglePlayPause(ref);
if (!isHome && !isAllowedPage) return Container(); if (!isHome && !isAllowedPage) return Container();
@ -109,7 +109,10 @@ class PlayerOverlay extends HookConsumerWidget {
: Icons.play_arrow_rounded, : Icons.play_arrow_rounded,
), ),
color: paletteColor.bodyTextColor, color: paletteColor.bodyTextColor,
onPressed: _playOrPause, onPressed: Actions.handler<PlayPauseIntent>(
context,
PlayPauseIntent(ref),
),
); );
}, },
), ),

View File

@ -1,5 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
@ -30,24 +29,3 @@ Future<void> Function() usePreviousTrack(WidgetRef ref) {
} }
}; };
} }
Future<void> 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);
}
};
}

View File

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.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/components/Shared/ReplaceDownloadedFileDialog.dart';
import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/entities/CacheTrack.dart';
import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/models/Intents.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/AudioPlayer.dart'; import 'package:spotube/provider/AudioPlayer.dart';
@ -52,7 +54,6 @@ void main() async {
Builder( Builder(
builder: (context) { builder: (context) {
return ProviderScope( return ProviderScope(
child: const Spotube(),
overrides: [ overrides: [
playbackProvider.overrideWithProvider( playbackProvider.overrideWithProvider(
ChangeNotifierProvider( ChangeNotifierProvider(
@ -123,6 +124,7 @@ void main() async {
), ),
) )
], ],
child: const Spotube(),
); );
}, },
), ),
@ -205,6 +207,17 @@ class _SpotubeState extends ConsumerState<Spotube> with WidgetsBindingObserver {
backgroundMaterialColor: backgroundMaterialColor, backgroundMaterialColor: backgroundMaterialColor,
), ),
themeMode: themeMode, 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(),
},
); );
} }
} }

85
lib/models/Intents.dart Normal file
View File

@ -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<PlayPauseIntent> {
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<OpenSettingsIntent> {
@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<SeekIntent> {
@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;
}
}