Skip to content

Commit

Permalink
feat(keyboard shortcuts): play/pause on space, seek position on left/…
Browse files Browse the repository at this point in the history
…right
  • Loading branch information
KRTirtho committed Sep 29, 2022
1 parent dbb81de commit 2734454
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 155 deletions.
4 changes: 2 additions & 2 deletions lib/components/Player/PlayerActions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
291 changes: 163 additions & 128 deletions lib/components/Player/PlayerControls.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Duration>(
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<Duration>(
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<num>(
useMemoized(() => progressStatic, []),
);
final progress = useState<num>(
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<PlayPauseIntent>(
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)
],
),
),
),
);
}
}
7 changes: 5 additions & 2 deletions lib/components/Player/PlayerOverlay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();

Expand Down Expand Up @@ -109,7 +109,10 @@ class PlayerOverlay extends HookConsumerWidget {
: Icons.play_arrow_rounded,
),
color: paletteColor.bodyTextColor,
onPressed: _playOrPause,
onPressed: Actions.handler<PlayPauseIntent>(
context,
PlayPauseIntent(ref),
),
);
},
),
Expand Down
22 changes: 0 additions & 22 deletions lib/hooks/playback.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
}
};
}
Loading

0 comments on commit 2734454

Please sign in to comment.