mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-26 04:49:31 +00:00
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:
parent
2e3b1de529
commit
b229c4f2f5
25 changed files with 1423 additions and 158 deletions
|
|
@ -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) ??
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
lib/features/shake_detection/core/shake_detector.dart
Normal file
79
lib/features/shake_detection/core/shake_detector.dart
Normal 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);
|
||||
}
|
||||
138
lib/features/shake_detection/providers/shake_detector.dart
Normal file
138
lib/features/shake_detection/providers/shake_detector.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
26
lib/features/shake_detection/providers/shake_detector.g.dart
Normal file
26
lib/features/shake_detection/providers/shake_detector.g.dart
Normal 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
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sleepTimerHash() => r'ad77e82c1b513bbc62815c64ce1ed403f92fc055';
|
||||
String _$sleepTimerHash() => r'9d9f20267da91e5483151b58b7d4d7c0762c3ca7';
|
||||
|
||||
/// See also [SleepTimer].
|
||||
@ProviderFor(SleepTimer)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue