Global custom hotkey support for playback control

UserPreferences provider genius access token not loading on init bug fix
This commit is contained in:
Kingkor Roy Tirtho 2022-02-06 10:01:29 +06:00
parent b378375b19
commit e666e25ffd
7 changed files with 332 additions and 72 deletions

View File

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:just_audio/just_audio.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
@ -9,7 +8,6 @@ import 'package:spotube/components/Player/PlayerControls.dart';
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/search-youtube.dart';
import 'package:spotube/models/GlobalKeyActions.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -35,20 +33,13 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
late YoutubeExplode youtube;
late List<GlobalKeyActions> _hotKeys;
@override
void initState() {
try {
super.initState();
player = AudioPlayer();
youtube = YoutubeExplode();
_hotKeys = [
GlobalKeyActions(
HotKey(KeyCode.space, scope: HotKeyScope.inapp),
_playOrPause,
),
];
WidgetsBinding.instance?.addObserver(this);
WidgetsBinding.instance?.addPostFrameCallback(_init);
} catch (e, stack) {
@ -96,15 +87,6 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
print(stack);
}
});
await Future.wait(
_hotKeys.map((e) {
return hotKeyManager.register(
e.hotKey,
keyDownHandler: e.onKeyDown,
);
}),
);
} catch (e) {
print("[Player._init()]: $e");
}
@ -115,7 +97,6 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
WidgetsBinding.instance?.removeObserver(this);
player.dispose();
youtube.close();
Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
super.dispose();
}
@ -129,15 +110,6 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
}
}
_playOrPause(key) async {
try {
_isPlaying ? await player.pause() : await player.play();
} catch (e, stack) {
print("[PlayPauseShortcut] $e");
print(stack);
}
}
void _movePlaylistPositionBy(int pos) {
Playback playback = context.read<Playback>();
if (playback.currentTrack != null && playback.currentPlaylist != null) {
@ -198,6 +170,29 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
}
}
_onNext() async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(1);
print("ON NEXT");
} catch (e, stack) {
print("[PlayerControls.onNext()] $e");
print(stack);
}
}
_onPrevious() async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(-1);
} catch (e, stack) {
print("[PlayerControls.onPrevious()] $e");
print(stack);
}
}
@override
Widget build(BuildContext context) {
return Container(
@ -256,26 +251,8 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
isPlaying: _isPlaying,
duration: _duration ?? Duration.zero,
shuffled: _shuffled,
onNext: () async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(1);
} catch (e, stack) {
print("[PlayerControls.onNext()] $e");
print(stack);
}
},
onPrevious: () async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(-1);
} catch (e, stack) {
print("[PlayerControls.onPrevious()] $e");
print(stack);
}
},
onNext: _onNext,
onPrevious: _onPrevious,
onPause: () async {
try {
await player.pause();

View File

@ -1,7 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/models/GlobalKeyActions.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:provider/provider.dart';
class PlayerControls extends StatefulWidget {
final Stream<Duration> positionStream;
@ -36,15 +40,57 @@ class PlayerControls extends StatefulWidget {
class _PlayerControlsState extends State<PlayerControls> {
StreamSubscription? _timePositionListener;
late List<GlobalKeyActions> _hotKeys = [];
@override
void dispose() async {
await _timePositionListener?.cancel();
Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
super.dispose();
}
_playOrPause(key) async {
try {
widget.isPlaying ? widget.onPause?.call() : await widget.onPlay?.call();
} catch (e, stack) {
print("[PlayPauseShortcut] $e");
print(stack);
}
}
_configureHotKeys(UserPreferences preferences) async {
await Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)))
.then((val) async {
_hotKeys = [
GlobalKeyActions(
HotKey(KeyCode.space, scope: HotKeyScope.inapp),
_playOrPause,
),
if (preferences.nextTrackHotKey != null)
GlobalKeyActions(
preferences.nextTrackHotKey!, (key) => widget.onNext?.call()),
if (preferences.prevTrackHotKey != null)
GlobalKeyActions(
preferences.prevTrackHotKey!, (key) => widget.onPrevious?.call()),
if (preferences.playPauseHotKey != null)
GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause)
];
await Future.wait(
_hotKeys.map((e) {
return hotKeyManager.register(
e.hotKey,
keyDownHandler: e.onKeyDown,
);
}),
);
});
}
@override
Widget build(BuildContext context) {
UserPreferences preferences = context.watch<UserPreferences>();
_configureHotKeys(preferences);
return Container(
constraints: const BoxConstraints(maxWidth: 700),
child: Column(
@ -108,11 +154,8 @@ class _PlayerControlsState extends State<PlayerControls> {
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
onPressed: () {
widget.isPlaying
? widget.onPause?.call()
: widget.onPlay?.call();
}),
onPressed: () => _playOrPause(null),
),
IconButton(
icon: const Icon(Icons.skip_next_rounded),
onPressed: () => widget.onNext?.call()),

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/main.dart';
@ -94,6 +96,27 @@ class _SettingsState extends State<Settings> {
],
),
const SizedBox(height: 10),
SettingsHotKeyTile(
title: "Next track global shortcut",
currentHotKey: preferences.nextTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setNextTrackHotKey(value);
},
),
SettingsHotKeyTile(
title: "Prev track global shortcut",
currentHotKey: preferences.prevTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setPrevTrackHotKey(value);
},
),
SettingsHotKeyTile(
title: "Play/Pause global shortcut",
currentHotKey: preferences.playPauseHotKey,
onHotKeyRecorded: (value) {
preferences.setPlayPauseHotKey(value);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -167,7 +190,7 @@ class _SettingsState extends State<Settings> {
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Hyperlink(
"Sponsor/Donate",
"💚 Sponsor/Donate 💚",
"https://opencollective.com/spotube",
),
Text(""),

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:spotube/components/Shared/RecordHotKeyDialog.dart';
class SettingsHotKeyTile extends StatelessWidget {
final String title;
final HotKey? currentHotKey;
final ValueChanged<HotKey> onHotKeyRecorded;
const SettingsHotKeyTile({
required this.onHotKeyRecorded,
required this.title,
Key? key,
this.currentHotKey,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title),
Row(
children: [
if (currentHotKey != null)
HotKeyVirtualView(hotKey: currentHotKey!),
const SizedBox(width: 10),
ElevatedButton(
child: const Text("Set Shortcut"),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return RecordHotKeyDialog(
onHotKeyRecorded: onHotKeyRecorded,
);
},
);
},
),
],
)
],
),
);
}
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
class RecordHotKeyDialog extends StatefulWidget {
final ValueChanged<HotKey> onHotKeyRecorded;
const RecordHotKeyDialog({
Key? key,
required this.onHotKeyRecorded,
}) : super(key: key);
@override
_RecordHotKeyDialogState createState() => _RecordHotKeyDialogState();
}
class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
HotKey _hotKey = HotKey(null);
@override
Widget build(BuildContext context) {
return AlertDialog(
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text(
'Press the keys you want to use',
style: Theme.of(context).textTheme.headline5,
),
const SizedBox(height: 10),
const Text(
"DO NOT Use only letters (e.g. k, g etc..)\nUse in combination with these"),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
KeyCode.control,
KeyCode.shift,
KeyCode.alt,
KeyCode.superKey,
KeyCode.meta,
]
.map((key) => HotKeyVirtualView(
hotKey: HotKey(key),
))
.toList(),
),
Container(
width: 100,
height: 60,
margin: const EdgeInsets.only(top: 20),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).primaryColor,
),
),
child: Stack(
alignment: Alignment.center,
children: [
HotKeyRecorder(
onHotKeyRecorded: (hotKey) {
setState(() {
_hotKey = hotKey;
});
},
),
],
),
),
],
),
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('OK'),
onPressed: !_hotKey.isSetted
? null
: () {
widget.onHotKeyRecorded(_hotKey);
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@ -7,4 +7,7 @@ abstract class LocalStorageKeys {
static String geniusAccessToken = "genius_access_token";
static String themeMode = "theme_mode";
static String nextTrackHotKey = "next_track_hot_key";
static String prevTrackHotKey = "prev_track_hot_key";
static String playPauseHotKey = "play_pause_hot_key";
}

View File

@ -1,25 +1,100 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
class UserPreferences extends ChangeNotifier {
String? _geniusAccessToken;
UserPreferences({String? geniusAccessToken}) {
if (geniusAccessToken == null) {
SharedPreferences.getInstance().then((localStorage) {
String? geniusAccessToken;
HotKey? nextTrackHotKey;
HotKey? prevTrackHotKey;
HotKey? playPauseHotKey;
UserPreferences({
this.nextTrackHotKey,
this.prevTrackHotKey,
this.playPauseHotKey,
this.geniusAccessToken,
}) {
onInit();
}
Future<HotKey?> _getHotKeyFromLocalStorage(
SharedPreferences preferences, String key) async {
String? str = preferences.getString(key);
if (str != null) {
Map<String, dynamic> json = await jsonDecode(str);
if (json.isEmpty) {
return null;
}
return HotKey.fromJson(json);
}
}
onInit() async {
try {
SharedPreferences localStorage = await SharedPreferences.getInstance();
String? accessToken =
localStorage.getString(LocalStorageKeys.geniusAccessToken);
_geniusAccessToken ??= accessToken;
});
} else {
_geniusAccessToken = geniusAccessToken;
}
}
String? get geniusAccessToken => _geniusAccessToken;
geniusAccessToken ??= accessToken;
nextTrackHotKey ??= await _getHotKeyFromLocalStorage(
localStorage,
LocalStorageKeys.nextTrackHotKey,
);
prevTrackHotKey ??= await _getHotKeyFromLocalStorage(
localStorage,
LocalStorageKeys.prevTrackHotKey,
);
playPauseHotKey ??= await _getHotKeyFromLocalStorage(
localStorage,
LocalStorageKeys.playPauseHotKey,
);
notifyListeners();
} catch (e, stack) {
print("[UserPreferences.onInit]: $e");
print(stack);
}
}
setGeniusAccessToken(String? token) {
_geniusAccessToken = token;
geniusAccessToken = token;
notifyListeners();
}
setNextTrackHotKey(HotKey? value) {
nextTrackHotKey = value;
SharedPreferences.getInstance().then((preferences) {
preferences.setString(
LocalStorageKeys.nextTrackHotKey,
jsonEncode(value?.toJson() ?? {}),
);
});
notifyListeners();
}
setPrevTrackHotKey(HotKey? value) {
prevTrackHotKey = value;
SharedPreferences.getInstance().then((preferences) {
preferences.setString(
LocalStorageKeys.prevTrackHotKey,
jsonEncode(value?.toJson() ?? {}),
);
});
notifyListeners();
}
setPlayPauseHotKey(HotKey? value) {
playPauseHotKey = value;
SharedPreferences.getInstance().then((preferences) {
preferences.setString(
LocalStorageKeys.playPauseHotKey,
jsonEncode(value?.toJson() ?? {}),
);
});
notifyListeners();
}
}