From e666e25ffd80b25df65477a283bfd51356b5a4c2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 6 Feb 2022 10:01:29 +0600 Subject: [PATCH] Global custom hotkey support for playback control UserPreferences provider genius access token not loading on init bug fix --- lib/components/Player/Player.dart | 75 +++++--------- lib/components/Player/PlayerControls.dart | 63 ++++++++++-- lib/components/Settings.dart | 25 ++++- .../Settings/SettingsHotkeyTile.dart | 48 +++++++++ lib/components/Shared/RecordHotKeyDialog.dart | 91 +++++++++++++++++ lib/models/LocalStorageKeys.dart | 3 + lib/provider/UserPreferences.dart | 99 ++++++++++++++++--- 7 files changed, 332 insertions(+), 72 deletions(-) create mode 100644 lib/components/Settings/SettingsHotkeyTile.dart create mode 100644 lib/components/Shared/RecordHotKeyDialog.dart diff --git a/lib/components/Player/Player.dart b/lib/components/Player/Player.dart index b3d6f697..ef9e4d2b 100644 --- a/lib/components/Player/Player.dart +++ b/lib/components/Player/Player.dart @@ -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 with WidgetsBindingObserver { late YoutubeExplode youtube; - late List _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 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 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 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(); if (playback.currentTrack != null && playback.currentPlaylist != null) { @@ -198,6 +170,29 @@ class _PlayerState extends State 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 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(); diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart index 9d7bc6ca..adcefee9 100644 --- a/lib/components/Player/PlayerControls.dart +++ b/lib/components/Player/PlayerControls.dart @@ -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 positionStream; @@ -36,15 +40,57 @@ class PlayerControls extends StatefulWidget { class _PlayerControlsState extends State { StreamSubscription? _timePositionListener; + late List _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(); + _configureHotKeys(preferences); + return Container( constraints: const BoxConstraints(maxWidth: 700), child: Column( @@ -103,16 +149,13 @@ class _PlayerControlsState extends State { widget.onPrevious?.call(); }), IconButton( - icon: Icon( - widget.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - onPressed: () { - widget.isPlaying - ? widget.onPause?.call() - : widget.onPlay?.call(); - }), + icon: Icon( + widget.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + onPressed: () => _playOrPause(null), + ), IconButton( icon: const Icon(Icons.skip_next_rounded), onPressed: () => widget.onNext?.call()), diff --git a/lib/components/Settings.dart b/lib/components/Settings.dart index ee8c48de..a47323e1 100644 --- a/lib/components/Settings.dart +++ b/lib/components/Settings.dart @@ -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 { ], ), 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 { mainAxisAlignment: MainAxisAlignment.center, children: const [ Hyperlink( - "Sponsor/Donate", + "💚 Sponsor/Donate 💚", "https://opencollective.com/spotube", ), Text(" • "), diff --git a/lib/components/Settings/SettingsHotkeyTile.dart b/lib/components/Settings/SettingsHotkeyTile.dart new file mode 100644 index 00000000..d3ad0a02 --- /dev/null +++ b/lib/components/Settings/SettingsHotkeyTile.dart @@ -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 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, + ); + }, + ); + }, + ), + ], + ) + ], + ), + ); + } +} diff --git a/lib/components/Shared/RecordHotKeyDialog.dart b/lib/components/Shared/RecordHotKeyDialog.dart new file mode 100644 index 00000000..c7eba2b5 --- /dev/null +++ b/lib/components/Shared/RecordHotKeyDialog.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; + +class RecordHotKeyDialog extends StatefulWidget { + final ValueChanged onHotKeyRecorded; + + const RecordHotKeyDialog({ + Key? key, + required this.onHotKeyRecorded, + }) : super(key: key); + + @override + _RecordHotKeyDialogState createState() => _RecordHotKeyDialogState(); +} + +class _RecordHotKeyDialogState extends State { + HotKey _hotKey = HotKey(null); + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: SingleChildScrollView( + child: ListBody( + children: [ + 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: [ + 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(); + }, + ), + ], + ); + } +} diff --git a/lib/models/LocalStorageKeys.dart b/lib/models/LocalStorageKeys.dart index 7d9a6072..70b5936a 100644 --- a/lib/models/LocalStorageKeys.dart +++ b/lib/models/LocalStorageKeys.dart @@ -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"; } diff --git a/lib/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart index dc504710..71153035 100644 --- a/lib/provider/UserPreferences.dart +++ b/lib/provider/UserPreferences.dart @@ -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? accessToken = - localStorage.getString(LocalStorageKeys.geniusAccessToken); - _geniusAccessToken ??= accessToken; - }); - } else { - _geniusAccessToken = geniusAccessToken; + String? geniusAccessToken; + HotKey? nextTrackHotKey; + HotKey? prevTrackHotKey; + HotKey? playPauseHotKey; + UserPreferences({ + this.nextTrackHotKey, + this.prevTrackHotKey, + this.playPauseHotKey, + this.geniusAccessToken, + }) { + onInit(); + } + + Future _getHotKeyFromLocalStorage( + SharedPreferences preferences, String key) async { + String? str = preferences.getString(key); + if (str != null) { + Map json = await jsonDecode(str); + if (json.isEmpty) { + return null; + } + return HotKey.fromJson(json); } } - String? get geniusAccessToken => _geniusAccessToken; + onInit() async { + try { + SharedPreferences localStorage = await SharedPreferences.getInstance(); + String? accessToken = + localStorage.getString(LocalStorageKeys.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(); } }