feat: add shake detection functionality (#36)

* feat: add shake detection functionality and integrate vibration support

* feat: add shake detector settings page
This commit is contained in:
Dr.Blank 2024-09-28 01:27:56 -04:00 committed by GitHub
parent 2e3b1de529
commit b229c4f2f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1423 additions and 158 deletions

View file

@ -281,7 +281,7 @@ extension FormatNotificationTitle on String {
(match) {
final type = match.group(1);
return NotificationTitleType.values
.firstWhere((element) => element.stringValue == type)
.firstWhere((element) => element.name == type)
.extractFrom(book) ??
match.group(0) ??
'';

View file

@ -11,6 +11,7 @@ import 'package:vaani/features/sleep_timer/core/sleep_timer.dart';
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
show sleepTimerProvider;
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/duration_format.dart';
import 'package:vaani/shared/extensions/inverse_lerp.dart';
import 'package:vaani/shared/widgets/not_implemented.dart';
@ -376,8 +377,8 @@ class RemainingSleepTimeDisplay extends HookConsumerWidget {
),
child: Text(
timer.timer == null
? timer.duration.formatSingleLargestUnit()
: remainingTime?.formatSingleLargestUnit() ?? '',
? timer.duration.smartBinaryFormat
: remainingTime?.smartBinaryFormat ?? '',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
@ -385,17 +386,3 @@ class RemainingSleepTimeDisplay extends HookConsumerWidget {
);
}
}
extension DurationFormat on Duration {
/// will return a number followed by h, m, or s depending on the duration
/// only the largest unit will be shown
String formatSingleLargestUnit() {
if (inHours > 0) {
return '${inHours}h';
} else if (inMinutes > 0) {
return '${inMinutes}m';
} else {
return '${inSeconds}s';
}
}
}

View file

@ -0,0 +1,79 @@
import 'dart:async';
import 'dart:math';
import 'package:logging/logging.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vaani/settings/models/app_settings.dart';
final _logger = Logger('ShakeDetector');
class ShakeDetector {
final ShakeDetectionSettings _settings;
final Function()? onShakeDetected;
ShakeDetector(
this._settings,
this.onShakeDetected, {
startImmediately = true,
}) {
_logger.fine('ShakeDetector created with settings: $_settings');
if (startImmediately) {
start();
}
}
StreamSubscription? _accelerometerSubscription;
int _currentShakeCount = 0;
DateTime _lastShakeTime = DateTime.now();
void start() {
if (_accelerometerSubscription != null) {
_logger.warning('ShakeDetector is already running');
return;
}
_accelerometerSubscription =
userAccelerometerEventStream(samplingPeriod: _settings.samplingPeriod)
.listen((event) {
if (event.rms > _settings.threshold) {
_currentShakeCount++;
if (_currentShakeCount >= _settings.shakeTriggerCount &&
!isCoolDownNeeded()) {
_logger.fine('Shake detected $_currentShakeCount times');
onShakeDetected?.call();
_lastShakeTime = DateTime.now();
_currentShakeCount = 0;
}
} else {
_currentShakeCount = 0;
}
});
_logger.fine('ShakeDetector started');
}
void stop() {
_currentShakeCount = 0;
_accelerometerSubscription?.cancel();
_accelerometerSubscription = null;
_logger.fine('ShakeDetector stopped');
}
void dispose() {
stop();
}
bool isCoolDownNeeded() {
return _lastShakeTime
.add(_settings.shakeTriggerCoolDown)
.isAfter(DateTime.now());
}
}
extension UserAccelerometerEventRMS on UserAccelerometerEvent {
double get rms => sqrt(x * x + y * y + z * z);
}

View file

@ -0,0 +1,138 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart'
show audiobookPlayerProvider, simpleAudiobookPlayerProvider;
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
show sleepTimerProvider;
import 'package:vaani/settings/app_settings_provider.dart'
show appSettingsProvider;
import 'package:vaani/settings/models/app_settings.dart';
import 'package:vibration/vibration.dart';
import '../core/shake_detector.dart' as core;
part 'shake_detector.g.dart';
Logger _logger = Logger('ShakeDetector');
@riverpod
class ShakeDetector extends _$ShakeDetector {
@override
core.ShakeDetector? build() {
final appSettings = ref.watch(appSettingsProvider);
final shakeDetectionSettings = appSettings.shakeDetectionSettings;
if (!shakeDetectionSettings.isEnabled) {
_logger.fine('Shake detection is disabled');
return null;
}
final player = ref.watch(audiobookPlayerProvider);
if (!player.playing && !shakeDetectionSettings.isActiveWhenPaused) {
_logger.fine(
'Shake detection is disabled when paused and player is not playing',
);
return null;
}
// if sleep timer is not enabled, shake detection should not be enabled
final sleepTimer = ref.watch(sleepTimerProvider);
if (!shakeDetectionSettings.isPlaybackManagementEnabled &&
sleepTimer == null) {
_logger.fine('No playback management is enabled and sleep timer is off, '
'so shake detection is disabled');
return null;
}
_logger.fine('Creating shake detector');
final detector = core.ShakeDetector(
shakeDetectionSettings,
() {
doShakeAction(
shakeDetectionSettings.shakeAction,
ref: ref,
);
shakeDetectionSettings.feedback.forEach(postShakeFeedback);
},
);
ref.onDispose(detector.dispose);
return detector;
}
void doShakeAction(
ShakeAction shakeAction, {
required Ref ref,
}) {
final player = ref.watch(simpleAudiobookPlayerProvider);
switch (shakeAction) {
case ShakeAction.resetSleepTimer:
_logger.fine('Resetting sleep timer');
ref.read(sleepTimerProvider.notifier).restartTimer();
break;
case ShakeAction.fastForward:
_logger.fine('Fast forwarding');
player.seek(player.position + const Duration(seconds: 30));
break;
case ShakeAction.rewind:
_logger.fine('Rewinding');
player.seek(player.position - const Duration(seconds: 30));
break;
case ShakeAction.playPause:
if (player.book == null) {
_logger.warning('No book is loaded');
break;
}
player.togglePlayPause();
break;
default:
break;
}
}
Future<void> postShakeFeedback(ShakeDetectedFeedback feedback) async {
switch (feedback) {
case ShakeDetectedFeedback.vibrate:
_logger.fine('Vibrating');
if (await Vibration.hasAmplitudeControl() ?? false) {
Vibration.vibrate(amplitude: 128, duration: 200);
break;
}
if (await Vibration.hasVibrator() ?? false) {
Vibration.vibrate();
break;
}
_logger.warning('No vibration support');
break;
case ShakeDetectedFeedback.beep:
_logger.fine('Beeping');
final player = AudioPlayer();
await player.setAsset('assets/sounds/beep.mp3');
await player.setVolume(0.5);
await player.play();
await player.dispose();
break;
default:
break;
}
}
}
extension on ShakeDetectionSettings {
bool get isActiveWhenPaused {
// If the shake action is play/pause, it should be required when not playing
return shakeAction == ShakeAction.playPause;
}
bool get isPlaybackManagementEnabled {
return {ShakeAction.playPause, ShakeAction.fastForward, ShakeAction.rewind}
.contains(shakeAction);
}
bool get shouldActOnSleepTimer {
return {ShakeAction.resetSleepTimer}.contains(shakeAction);
}
}

View file

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'shake_detector.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$shakeDetectorHash() => r'7bfbdd22f2f43ef3e3858d226d1eb78923e8114d';
/// See also [ShakeDetector].
@ProviderFor(ShakeDetector)
final shakeDetectorProvider =
AutoDisposeNotifierProvider<ShakeDetector, core.ShakeDetector?>.internal(
ShakeDetector.new,
name: r'shakeDetectorProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$shakeDetectorHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ShakeDetector = AutoDisposeNotifier<core.ShakeDetector?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -19,7 +19,7 @@ class SleepTimer {
set duration(Duration value) {
_duration = value;
reset();
clearCountDownTimer();
}
/// The player to be paused
@ -40,7 +40,7 @@ class SleepTimer {
player.playbackEventStream.listen((event) {
if (event.processingState == ProcessingState.completed ||
event.processingState == ProcessingState.idle) {
reset();
clearCountDownTimer();
}
}),
);
@ -49,9 +49,9 @@ class SleepTimer {
_subscriptions.add(
player.playerStateStream.listen((state) {
if (state.playing && timer == null) {
startTimer();
startCountDown();
} else if (!state.playing) {
reset();
clearCountDownTimer();
}
}),
);
@ -59,8 +59,8 @@ class SleepTimer {
_logger.fine('created with duration: $duration');
}
/// resets the timer
void reset() {
/// resets the timer and stops it
void clearCountDownTimer() {
if (timer != null) {
timer!.cancel();
_logger.fine(
@ -70,15 +70,25 @@ class SleepTimer {
}
}
/// refills the timer with the default duration and starts it if the player is playing
/// if the player is not playing, the timer is stopped
void restartTimer() {
clearCountDownTimer();
if (player.playing) {
startCountDown();
}
_logger.fine('restarted timer');
}
/// starts the timer with the given duration or the default duration
void startTimer([
void startCountDown([
Duration? forDuration,
]) {
reset();
clearCountDownTimer();
duration = forDuration ?? duration;
timer = Timer(duration, () {
player.pause();
reset();
clearCountDownTimer();
_logger.fine('paused player after $duration');
});
startedAt = DateTime.now();
@ -103,7 +113,7 @@ class SleepTimer {
/// dispose the timer
void dispose() {
reset();
clearCountDownTimer();
for (var sub in _subscriptions) {
sub.cancel();
}

View file

@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/features/sleep_timer/core/sleep_timer.dart'
as core;
import 'package:vaani/features/sleep_timer/core/sleep_timer.dart' as core;
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/time_of_day.dart';
@ -48,9 +47,16 @@ class SleepTimer extends _$SleepTimer {
);
ref.onDispose(timer.dispose);
state = timer;
state!.startCountDown();
}
}
void restartTimer() {
state?.restartTimer();
ref.notifyListeners();
}
void cancelTimer() {
state?.dispose();
state = null;

View file

@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$sleepTimerHash() => r'ad77e82c1b513bbc62815c64ce1ed403f92fc055';
String _$sleepTimerHash() => r'9d9f20267da91e5483151b58b7d4d7c0762c3ca7';
/// See also [SleepTimer].
@ProviderFor(SleepTimer)