diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/level.dart | 169 | ||||
-rw-r--r-- | lib/level_selection.dart | 113 | ||||
-rw-r--r-- | lib/main.dart | 2 | ||||
-rw-r--r-- | lib/models/arrow_direction.dart | 12 | ||||
-rw-r--r-- | lib/models/input_direction.dart | 14 | ||||
-rw-r--r-- | lib/models/note.dart | 10 | ||||
-rw-r--r-- | lib/screens/game_over.dart | 63 | ||||
-rw-r--r-- | lib/screens/level.dart | 285 | ||||
-rw-r--r-- | lib/screens/level_selection.dart | 161 | ||||
-rw-r--r-- | lib/utils/esense_input.dart | 188 | ||||
-rw-r--r-- | lib/utils/simfile.dart | 175 | ||||
-rw-r--r-- | lib/widgets/arrow.dart | 18 | ||||
-rw-r--r-- | lib/widgets/arrows.dart | 23 | ||||
-rw-r--r-- | lib/widgets/connection_status_button.dart | 40 | ||||
-rw-r--r-- | lib/widgets/esense_connect_dialog.dart | 64 | ||||
-rw-r--r-- | lib/widgets/esense_not_connected_dialog.dart | 34 | ||||
-rw-r--r-- | lib/widgets/level_info_chip.dart | 37 | ||||
-rw-r--r-- | lib/widgets/level_list_entry.dart | 95 |
18 files changed, 1220 insertions, 283 deletions
diff --git a/lib/level.dart b/lib/level.dart deleted file mode 100644 index 1391f8a..0000000 --- a/lib/level.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:audioplayers/audioplayers.dart'; - -class Level extends StatefulWidget { - const Level({super.key, required this.stepmaniaFolderPath}); - final String stepmaniaFolderPath; - - @override - State<Level> createState() => _LevelState(); -} - -class _LevelState extends State<Level> { - final player = AudioPlayer(); - bool _isPlaying = true; - Duration? _duration; - Duration? _position; - - StreamSubscription? _durationSubscription; - StreamSubscription? _positionSubscription; - - @override - void setState(VoidCallback fn) { - // Subscriptions only can be closed asynchronously, - // therefore events can occur after widget has been disposed. - if (mounted) { - super.setState(fn); - } - } - - @override - void initState() { - super.initState(); - // Use initial values from player - player.getDuration().then( - (value) => setState(() { - _duration = value; - }), - ); - player.getCurrentPosition().then( - (value) => setState(() { - _position = value; - }), - ); - _durationSubscription = player.onDurationChanged.listen((duration) { - setState(() => _duration = duration); - }); - - _positionSubscription = player.onPositionChanged.listen( - (p) => setState(() => _position = p), - ); - } - - @override - Widget build(BuildContext context) { - player.onDurationChanged.listen((Duration d) { - // print('Max duration: $d'); - setState(() => _duration = d); - }); - - player.onPositionChanged.listen((Duration p) { - // print('Current position: $p'); - setState(() => _position = p); - }); - - String audioPath = Directory(widget.stepmaniaFolderPath) - .listSync() - .firstWhere((entity) => entity.path.endsWith('.ogg'), - orElse: () => File('')) - .path; - player.play(DeviceFileSource(audioPath)); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: () { - if (_isPlaying) { - player.pause(); - setState(() { - _isPlaying = false; - }); - } else { - player.resume(); - setState(() { - _isPlaying = true; - }); - } - }, - ), - title: Text(widget.stepmaniaFolderPath.split('/').last), - actions: [ - IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.pop(context)) - ], - bottom: PreferredSize( - preferredSize: Size(double.infinity, 1.0), - child: LinearProgressIndicator( - value: (_duration != null && - _position != null && - _position!.inMilliseconds > 0 && - _position!.inMilliseconds < _duration!.inMilliseconds) - ? _position!.inMilliseconds / _duration!.inMilliseconds - : 0.0, - )), - ), - body: Stack(children: [ - Arrow( - position: -100.0, - ), - Arrow( - position: 00.0, - ), - Arrow( - position: 100.0, - ), - Arrow( - position: 200.0, - ), - Positioned( - top: 50, - width: MediaQuery.of(context).size.width, - left: 0, - child: Text( - "Great!", - textScaler: TextScaler.linear(4), - textAlign: TextAlign.center, - ), - ), - Positioned( - left: MediaQuery.of(context).size.width / 2 - 50, - bottom: 50, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - // color: Colors.blue, - border: Border.all(color: Colors.black, width: 10)), - ), - ), - ])); - } - - @override - void dispose() { - _durationSubscription?.cancel(); - _positionSubscription?.cancel(); - player.dispose(); - super.dispose(); - } -} - -class Arrow extends StatelessWidget { - final double position; - - const Arrow({super.key, required this.position}); - - @override - Widget build(BuildContext context) { - return Positioned( - left: MediaQuery.of(context).size.width / 2 - 25, // Center the arrow - top: position, - child: Icon(size: 100, Icons.arrow_forward), - ); - } -} diff --git a/lib/level_selection.dart b/lib/level_selection.dart deleted file mode 100644 index e2cdcbe..0000000 --- a/lib/level_selection.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'level.dart'; - -class LevelSelection extends StatefulWidget { - const LevelSelection({super.key}); - - @override - State<LevelSelection> createState() => _LevelSelectionState(); -} - -class _LevelSelectionState extends State<LevelSelection> { - String? stepmaniaCoursesPath; - List<FileSystemEntity> stepmaniaCoursesFolders = []; - - @override - void initState() { - super.initState(); - loadFolderPath(); - } - - Future<void> loadFolderPath() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - final String? stepmaniaCoursesPathSetting = - prefs.getString('stepmania_courses'); - - if (stepmaniaCoursesPathSetting == null) return; - setState(() { - stepmaniaCoursesPath = stepmaniaCoursesPathSetting; - }); - setState(() async { - stepmaniaCoursesFolders = - await listFilesAndFolders(stepmaniaCoursesPathSetting); - }); - } - - Future<void> selectFolder() async { - String? selectedFolder = await FilePicker.platform.getDirectoryPath(); - - if (selectedFolder != null) { - // Save the selected folder path - SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString('stepmania_courses', selectedFolder); - - loadFolderPath(); - } - } - - Future<List<FileSystemEntity>> listFilesAndFolders( - String directoryPath) async { - final directory = Directory(directoryPath); - try { - // List all files and folders in the directory - return directory - .listSync() - .where((entity) => FileSystemEntity.isDirectorySync(entity.path)) - .toList(); - } catch (e) { - print("Error reading directory: $e"); - return []; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Sense the Rhythm')), - body: Builder(builder: (context) { - if (stepmaniaCoursesPath == null) { - return Text('Add a Directory with Stepmania Songs on \'+\''); - } else if (stepmaniaCoursesFolders.isEmpty) { - return Text( - 'Folder empty. Add Stepmania Songs to Folder or select a different folder on \'+\''); - } else { - return ListView.separated( - itemCount: stepmaniaCoursesFolders.length, - separatorBuilder: (BuildContext context, int index) => - const Divider(), - itemBuilder: (context, index) { - String thumbnailPath = Directory( - stepmaniaCoursesFolders[index].path) - .listSync() - .firstWhere( - (file) => file.path.toLowerCase().endsWith('banner.png'), - orElse: () => File('')) - .path; - return ListTile( - leading: Image.file(File(thumbnailPath)), - trailing: Icon(Icons.play_arrow), - title: - Text(stepmaniaCoursesFolders[index].path.split('/').last), - subtitle: Text('3:45'), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) => Level( - stepmaniaFolderPath: - stepmaniaCoursesFolders[index].path, - ))), - ); - }, - ); - } - }), - floatingActionButton: FloatingActionButton( - onPressed: () => {selectFolder()}, child: Icon(Icons.add)), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index eb01d44..85443bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'level_selection.dart'; +import 'package:sense_the_rhythm/screens/level_selection.dart'; void main() { runApp(const MyApp()); diff --git a/lib/models/arrow_direction.dart b/lib/models/arrow_direction.dart new file mode 100644 index 0000000..5f0298b --- /dev/null +++ b/lib/models/arrow_direction.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +enum ArrowDirection { + left(Icons.arrow_back), + down(Icons.arrow_downward), + up(Icons.arrow_upward), + right(Icons.arrow_forward); + + const ArrowDirection(this.icon); + + final IconData icon; +} diff --git a/lib/models/input_direction.dart b/lib/models/input_direction.dart new file mode 100644 index 0000000..b15b880 --- /dev/null +++ b/lib/models/input_direction.dart @@ -0,0 +1,14 @@ +class InputDirection { + bool up = false; + bool down = false; + bool left = false; + bool right = false; + + /// reset all directions to false + void reset() { + up = false; + down = false; + left = false; + right = false; + } +} diff --git a/lib/models/note.dart b/lib/models/note.dart new file mode 100644 index 0000000..bcc6ac6 --- /dev/null +++ b/lib/models/note.dart @@ -0,0 +1,10 @@ +import 'package:sense_the_rhythm/models/arrow_direction.dart'; + +class Note { + final double time; + final ArrowDirection direction; + double position = 0; + bool? wasHit; + + Note({required this.time, required this.direction}); +} diff --git a/lib/screens/game_over.dart b/lib/screens/game_over.dart new file mode 100644 index 0000000..76ea2fb --- /dev/null +++ b/lib/screens/game_over.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/models/note.dart'; +import 'package:sense_the_rhythm/utils/simfile.dart'; +import 'package:sense_the_rhythm/screens/level.dart'; + +class GameOverStats extends StatelessWidget { + const GameOverStats({super.key, required this.simfile, required this.notes}); + + final Simfile simfile; + final List<Note> notes; + + @override + Widget build(BuildContext context) { + int hits = notes.where((note) => note.wasHit == true).length; + int misses = notes.where((note) => note.wasHit == false).length; + int total = notes.length; + int percent = (hits.toDouble() / total.toDouble() * 100).toInt(); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.arrow_back)), + title: Text('Game Stats'), + ), + body: Center( + child: Column( + children: [ + Text(' $percent%', + style: TextStyle( + fontSize: 60, + fontWeight: FontWeight.bold, + color: Colors.orange)), + DataTable(columns: [ + DataColumn(label: Container()), + DataColumn(label: Container()), + ], rows: [ + DataRow(cells: [ + DataCell(Text('Hits')), + DataCell(Text(hits.toString())), + ]), + DataRow(cells: [ + DataCell(Text('Misses')), + DataCell(Text(misses.toString())), + ]), + DataRow(cells: [ + DataCell(Text('Total')), + DataCell(Text(total.toString())), + ]), + ]), + TextButton( + onPressed: () { + Route route = + MaterialPageRoute(builder: (context) => Level(simfile)); + Navigator.pushReplacement(context, route); + }, + child: Text('Retry')) + ], + ), + ), + ); + } +} diff --git a/lib/screens/level.dart b/lib/screens/level.dart new file mode 100644 index 0000000..000c43c --- /dev/null +++ b/lib/screens/level.dart @@ -0,0 +1,285 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/services.dart'; +import 'package:sense_the_rhythm/models/arrow_direction.dart'; +import 'package:sense_the_rhythm/models/input_direction.dart'; +import 'package:sense_the_rhythm/models/note.dart'; +import 'package:sense_the_rhythm/utils/esense_input.dart'; +import 'package:sense_the_rhythm/utils/simfile.dart'; +import 'package:sense_the_rhythm/widgets/arrows.dart'; +import 'package:sense_the_rhythm/screens/game_over.dart'; + +class Level extends StatefulWidget { + const Level(this.simfile, {super.key}); + final Simfile simfile; + + @override + State<Level> createState() => _LevelState(); +} + +class _LevelState extends State<Level> with SingleTickerProviderStateMixin { + final _player = AudioPlayer(); + bool _isPlaying = true; + Duration? _duration; + Duration? _position; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _buttonSubscription; + + final FocusNode _focusNode = FocusNode(); + final InputDirection _inputDirection = InputDirection(); + + String _hitOrMissMessage = 'Play!'; + + final List<Note> _notes = []; + + late AnimationController _animationController; + late Animation<double> _animation; + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + ESenseInput.instance.resetAngles(); + + _animationController = AnimationController( + vsync: this, + duration: Duration(seconds: 2), + ); + _animation = + Tween<double>(begin: 1.0, end: 0.0).animate(_animationController); + _animationController.forward(); + + // Use initial values from player + _player.getDuration().then( + (value) => setState(() { + _duration = value; + }), + ); + _player.getCurrentPosition().then( + (value) => setState(() { + _position = value; + }), + ); + + // listen for new values from player + _durationSubscription = + _player.onDurationChanged.listen((Duration duration) { + setState(() => _duration = duration); + }); + + _positionSubscription = + _player.onPositionChanged.listen((Duration position) { + setState(() => _position = position); + for (final note in _notes) { + _noteHitCheck(note, position); + } + }); + + // go to GameOverStats when level finishes + _player.onPlayerComplete.listen((void _) { + Route route = MaterialPageRoute( + builder: (context) => GameOverStats( + simfile: widget.simfile, + notes: _notes, + )); + Navigator.pushReplacement(context, route); + }); + + // listen for esense button and pause/resume + if (ESenseInput.instance.connected) { + _buttonSubscription = ESenseInput.instance.buttonEvents().listen((event) { + if (!event.pressed) { + _pauseResume(); + } + }); + } + + // convert beats to notes + widget.simfile.chartSimplest?.beats.forEach((time, noteData) { + int arrowIndex = noteData.indexOf('1'); + if (arrowIndex < 0 || arrowIndex > 3) { + return; + } + _notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex])); + }); + + _player.play(DeviceFileSource(widget.simfile.audioPath!)); + } + + @override + void dispose() { + _animationController.dispose(); + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _buttonSubscription?.cancel(); + _player.dispose(); + super.dispose(); + } + + /// toggle between pause and resume + void _pauseResume() { + if (_isPlaying) { + _player.pause(); + setState(() { + _isPlaying = false; + }); + } else { + _player.resume(); + setState(() { + _isPlaying = true; + }); + } + } + + /// checks if the [note] is hit on [time] with the correct InputDirection + void _noteHitCheck(Note note, Duration time) { + note.position = note.time - time.inMilliseconds / 60000.0; + if (note.wasHit != null) { + return; + } + + // you have +- half a second to hit + if (note.position.abs() < 0.5 * 1.0 / 60.0) { + // combine keyboard and esense input + InputDirection esenseDirection = + ESenseInput.instance.getInputDirection(note.direction); + _inputDirection.up |= esenseDirection.up; + _inputDirection.down |= esenseDirection.down; + _inputDirection.left |= esenseDirection.left; + _inputDirection.right |= esenseDirection.right; + + // check if input matches arrow direction + bool keypressCorrect = false; + switch (note.direction) { + case ArrowDirection.up: + keypressCorrect = _inputDirection.up; + break; + case ArrowDirection.down: + keypressCorrect = _inputDirection.down; + break; + case ArrowDirection.right: + keypressCorrect = _inputDirection.right; + break; + case ArrowDirection.left: + keypressCorrect = _inputDirection.left; + break; + } + if (keypressCorrect) { + print("you hit!"); + note.wasHit = true; + _animationController.reset(); + _animationController.forward(); + _inputDirection.reset(); + setState(() { + _hitOrMissMessage = 'Great!'; + }); + } + } else if (note.position < -0.5 * 1.0 / 60.0) { + print("Missed"); + note.wasHit = false; + _animationController.reset(); + _animationController.forward(); + _inputDirection.reset(); + setState(() { + _hitOrMissMessage = 'Missed'; + }); + } + } + + /// sets the InputDirection based on the arrow keys + void _keyboardHandler(event) { + bool isDown = false; + if (event is KeyDownEvent) { + isDown = true; + } else if (event is KeyUpEvent) { + isDown = false; + } else { + return; + } + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowUp: + _inputDirection.up = isDown; + break; + case LogicalKeyboardKey.arrowDown: + _inputDirection.down = isDown; + break; + case LogicalKeyboardKey.arrowLeft: + _inputDirection.left = isDown; + break; + case LogicalKeyboardKey.arrowRight: + _inputDirection.right = isDown; + break; + } + } + + @override + Widget build(BuildContext context) { + return KeyboardListener( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _keyboardHandler, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: _pauseResume, + ), + title: Text(widget.simfile.tags['TITLE']!), + actions: [ + IconButton( + icon: Icon(Icons.close), + onPressed: () => Navigator.pop(context)) + ], + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: LinearProgressIndicator( + value: (_duration != null && + _position != null && + _position!.inMilliseconds > 0 && + _position!.inMilliseconds < _duration!.inMilliseconds) + ? _position!.inMilliseconds / _duration!.inMilliseconds + : 0.0, + )), + ), + body: Stack(children: [ + Arrows(notes: _notes), + Positioned( + top: 50, + width: MediaQuery.of(context).size.width, + left: 0, + child: FadeTransition( + opacity: _animation, + child: Text( + _hitOrMissMessage, + textScaler: TextScaler.linear(4), + textAlign: TextAlign.center, + ), + ), + ), + Positioned( + left: MediaQuery.of(context).size.width / 2 - 50, + bottom: 50, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black, width: 10)), + ), + ), + ])), + ); + } +} diff --git a/lib/screens/level_selection.dart b/lib/screens/level_selection.dart new file mode 100644 index 0000000..0987063 --- /dev/null +++ b/lib/screens/level_selection.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:sense_the_rhythm/utils/esense_input.dart'; +import 'package:sense_the_rhythm/utils/simfile.dart'; +import 'package:sense_the_rhythm/widgets/connection_status_button.dart'; +import 'package:sense_the_rhythm/widgets/level_list_entry.dart'; + +class LevelSelection extends StatefulWidget { + const LevelSelection({super.key}); + + @override + State<LevelSelection> createState() => _LevelSelectionState(); +} + +class _LevelSelectionState extends State<LevelSelection> { + String? _stepmaniaCoursesPath; + List<Simfile> _stepmaniaCoursesFolders = []; + List<Simfile> _stepmaniaCoursesFoldersFiltered = []; + + @override + void initState() { + super.initState(); + _loadFolderPath(); + } + + /// gets folder path from persistent storage and updates state with loaded simfiles + Future<void> _loadFolderPath() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? stepmaniaCoursesPathSetting = + prefs.getString('stepmania_courses'); + + if (stepmaniaCoursesPathSetting == null) return; + List<Simfile> stepmaniaCoursesFoldersFuture = + await _listFilesAndFolders(stepmaniaCoursesPathSetting); + + setState(() { + _stepmaniaCoursesPath = stepmaniaCoursesPathSetting; + _stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture; + _stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFoldersFuture; + }); + } + + /// open folder selection dialog and save selected folder in persistent storage + Future<void> _selectFolder() async { + await Permission.manageExternalStorage.request(); + String? selectedFolder = await FilePicker.platform.getDirectoryPath(); + + if (selectedFolder != null) { + // Save the selected folder path + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString('stepmania_courses', selectedFolder); + + _loadFolderPath(); + } + } + + /// load all simfiles from a [directoryPath] + Future<List<Simfile>> _listFilesAndFolders(String directoryPath) async { + final directory = Directory(directoryPath); + try { + // List all files and folders in the directory + List<Simfile> simfiles = directory + .listSync(recursive: true) + .where((entity) => entity.path.endsWith('.sm')) + .map((entity) => Simfile(entity.path)) + .toList(); + + List<bool> successfullLoads = + await Future.wait(simfiles.map((simfile) => simfile.load())); + List<Simfile> simfilesFiltered = []; + for (int i = 0; i < simfiles.length; i++) { + if (successfullLoads[i]) { + simfilesFiltered.add(simfiles[i]); + } + } + + simfilesFiltered + .sort((a, b) => a.tags['TITLE']!.compareTo(b.tags['TITLE']!)); + + return simfilesFiltered; + } catch (e) { + print("Error reading directory: $e"); + return []; + } + } + + /// filter stepmaniaCoursesFolders based on [input] + void _filterLevels(String input) { + setState(() { + _stepmaniaCoursesFoldersFiltered = _stepmaniaCoursesFolders + .where((simfile) => simfile.tags["TITLE"]! + .toLowerCase() + .contains(input.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sense the Rhythm'), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ValueListenableBuilder( + valueListenable: ESenseInput.instance.deviceStatus, + builder: + (BuildContext context, String deviceStatus, Widget? child) { + return ConnectionStatusButton(deviceStatus); + }, + ), + ) + ], + ), + body: Builder(builder: (context) { + if (_stepmaniaCoursesPath == null) { + return Text('Add a Directory with Stepmania Songs on \'+\''); + } else if (_stepmaniaCoursesFolders.isEmpty) { + return Text( + 'Folder empty. Add Stepmania Songs to Folder or select a different folder on \'+\''); + } else { + return Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0), + child: TextField( + onChanged: _filterLevels, + decoration: InputDecoration( + // icon: Icon(Icons.search), + hintText: 'Search'), + ), + ), + Expanded( + child: ListView.separated( + itemCount: _stepmaniaCoursesFoldersFiltered.length, + separatorBuilder: (BuildContext context, int index) => + const Divider(), + itemBuilder: (context, index) { + Simfile simfile = _stepmaniaCoursesFoldersFiltered[index]; + return LevelListEntry(simfile: simfile); + }, + ), + ), + ], + ); + } + }), + floatingActionButton: FloatingActionButton( + onPressed: () { + _selectFolder(); + }, + child: Icon(Icons.add)), + ); + } +} diff --git a/lib/utils/esense_input.dart b/lib/utils/esense_input.dart new file mode 100644 index 0000000..08875d5 --- /dev/null +++ b/lib/utils/esense_input.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:esense_flutter/esense.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:sense_the_rhythm/models/arrow_direction.dart'; +import 'package:sense_the_rhythm/models/input_direction.dart'; + +class ESenseInput { + // create singleton that is available on all widgets so it does not have to be + // carried down in the widget tree + static final instance = ESenseInput._(); + + ESenseManager eSenseManager = ESenseManager('unknown'); + // valuenotifier allows widgets to rerender when the value changes + ValueNotifier<String> deviceStatus = ValueNotifier('Disconnected'); + StreamSubscription? _subscription; + + String eSenseDeviceName = ''; + bool connected = false; + + final int _sampleRate = 20; + + final InputDirection _inputDirection = InputDirection(); + int _x = 0; + int _y = 0; + int _z = 0; + + ESenseInput._() { + _listenToESense(); + } + + /// ask and check if permissions are enabled and granted + Future<bool> _askForPermissions() async { + // is desktop + if (!Platform.isAndroid && !Platform.isIOS) return false; + // is bluetooth even enabled? + if (!await Permission.bluetooth.serviceStatus.isEnabled) { + deviceStatus.value = "Bluetooth is disabled!"; + return false; + } + if (!(await Permission.bluetoothScan.request().isGranted && + await Permission.bluetoothConnect.request().isGranted && + await Permission.bluetooth.request().isGranted)) { + print( + 'WARNING - no permission to use Bluetooth granted. Cannot access eSense device.'); + deviceStatus.value = "Insufficiant Permissions"; + return false; + } + // for some strange reason, Android requires permission to location for Bluetooth to work.....? + if (Platform.isAndroid) { + if (!(await Permission.locationWhenInUse.request().isGranted)) { + print( + 'WARNING - no permission to access location granted. Cannot access eSense device.'); + deviceStatus.value = "Insufficiant Permissions"; + return false; + } + } + return true; + } + + /// listen to connectionEvents and set deviceStatus + void _listenToESense() { + // if you want to get the connection events when connecting, + // set up the listener BEFORE connecting... + eSenseManager.connectionEvents.listen((event) { + print('CONNECTION event: $event'); + + // when we're connected to the eSense device, we can start listening to events from it + // if (event.type == ConnectionType.connected) _listenToESenseEvents(); + + connected = false; + switch (event.type) { + case ConnectionType.connected: + deviceStatus.value = 'Connected'; + connected = true; + _startListenToSensorEvents(); + break; + case ConnectionType.unknown: + deviceStatus.value = 'Unknown'; + break; + case ConnectionType.disconnected: + deviceStatus.value = 'Disconnected'; + _pauseListenToSensorEvents(); + break; + case ConnectionType.device_found: + deviceStatus.value = 'Device_found'; + break; + case ConnectionType.device_not_found: + deviceStatus.value = 'Device_not_found'; + break; + } + }); + } + + /// get eSenseEvent stream only containung button events + Stream<ButtonEventChanged> buttonEvents() { + return eSenseManager.eSenseEvents + .where((event) => event.runtimeType == ButtonEventChanged) + .cast(); + } + + /// sets sampling rate and listens to sensorEvents + void _startListenToSensorEvents() async { + // // any changes to the sampling frequency must be done BEFORE listening to sensor events + print('setting sampling frequency...'); + bool successs = await eSenseManager.setSamplingRate(_sampleRate); + if (successs) { + print('setSamplingRate success'); + } else { + print('setSamplingRate fail'); + } + + // subscribe to sensor event from the eSense device + _subscription = eSenseManager.sensorEvents.listen((event) { + // print('SENSOR event: $event'); + if (event.gyro != null) { + _parseGyroData(event.gyro!); + } + }); + } + + /// cancels the sensorEvents listening + void _pauseListenToSensorEvents() async { + _subscription?.cancel(); + } + + /// add up all new gyro [data] in the form of deg/s multiplied by scaling factor + /// to get real angles + void _parseGyroData(List<int> data) { + // Float value in deg/s = Gyro value / Gyro scale factor + // The default configuration is +- 500deg/s for the gyroscope. + _x = (_x + (15 * data[0] ~/ (500 * _sampleRate))) % 360; + _y = (_y + (15 * data[1] ~/ (500 * _sampleRate))) % 360; + _z = (_z + (15 * data[2] ~/ (500 * _sampleRate))) % 360; + print('$_x, $_y, $_z'); + // print('${(z.toDouble() / 500.0 * (1.0 / sampleRate.toDouble())) * 7.5}'); + // print('${z.toDouble() / 500.0 * (1.0 / 10.0)}'); + } + + /// nulls all angles and reset inputDirection + void resetAngles() { + _inputDirection.reset(); + _x = 0; + _y = 0; + _z = 0; + } + + /// get InputDirection by checking if angels are in defined ranges and + /// calibrating based on the [expect]ed direction from ArrowDirection + InputDirection getInputDirection(ArrowDirection expect) { + // check if angle is in range + _inputDirection.up = _z > 180 && _z < 340; + _inputDirection.down = _z > 20 && _z < 180; + _inputDirection.left = _y > 0 && _y < 180; + _inputDirection.right = _y > 180 && _y < 360; + + // calibrate based on expected directoin from ArrowDirection + if (expect == ArrowDirection.up && _inputDirection.up || + expect == ArrowDirection.down && _inputDirection.down) { + _y = 0; + } + if (expect == ArrowDirection.left && _inputDirection.left || + expect == ArrowDirection.right && _inputDirection.right) { + _z = 0; + } + + return _inputDirection; + } + + /// connect to ESense with [deviceName] by first asking for permissions + Future<void> connectToESense(String deviceName) async { + if (!connected) { + bool permissionSuccessfull = await _askForPermissions(); + if (!permissionSuccessfull) return; + print('Trying to connect to eSense device namend \'$deviceName\''); + eSenseDeviceName = deviceName; + eSenseManager.deviceName = deviceName; + bool connecting = await eSenseManager.connect(); + print( + 'Trying to connect to eSense device namend \'${eSenseManager.deviceName}\''); + + deviceStatus.value = connecting ? 'connecting...' : 'connection failed'; + print(deviceStatus.value); + } + } +} diff --git a/lib/utils/simfile.dart b/lib/utils/simfile.dart new file mode 100644 index 0000000..71613a9 --- /dev/null +++ b/lib/utils/simfile.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; + +enum Difficulty { Beginner, Easy, Medium, Hard, Challenge, Edit } + +// These are the standard note values: +// +// 0 – No note +// 1 – Normal note +// 2 – Hold head +// 3 – Hold/Roll tail +// 4 – Roll head +// M – Mine (or other negative note) +// +// Later versions of StepMania accept other note values which may not work in older versions: +// +// K – Automatic keysound +// L – Lift note +// F – Fake note + +RegExp noteTypes = RegExp(r'^([012345MKLF]+)\s*([,;])?'); + +class Chart { + String? chartType; + // Description/author + String? author; + // Difficulty (one of Beginner, Easy, Medium, Hard, Challenge, Edit) + Difficulty? difficulty; + // Numerical meter + int? numericalMeter; + // Groove radar values, generated by the program + String? radarValues; + + List<List<String>>? measures; + + Map<double, String> beats = {}; +} + +class Simfile { + String? directoryPath; + String simfilePath; + String? audioPath; + String? bannerPath; + String? lines; + + Duration? duration; + // tags of simfile + Map<String, String> tags = {}; + + Chart? chartSimplest; + + Map<double, double> bpms = {}; + double offset = 0; + + Simfile(this.simfilePath); + + /// parses a chart tag with metadata [keys] and note data [value] + void _parseChart({required List<String> keys, required String value}) { + Chart chart = Chart(); + chart.chartType = keys[1]; + chart.author = keys[2]; + chart.difficulty = Difficulty.values.byName(keys[3]); + chart.numericalMeter = int.parse(keys[4]); + chart.radarValues = keys[5]; + + // find simplest chart + if (chartSimplest == null || + (chart.difficulty!.index <= chartSimplest!.difficulty!.index && + chart.numericalMeter! <= chartSimplest!.numericalMeter!)) { + List<List<String>> measures = []; + for (final measureRaw in value.split(',')) { + List<String> measure = []; + for (final noteRaw in measureRaw.split('\n')) { + String note = noteRaw.trim(); + if (noteTypes.hasMatch(note)) { + measure.add(note); + } + } + measures.add(measure); + } + + // for now only use the first bpm value + double bpm = bpms.entries.first.value; + + // calculate timing for all notes based on offset, bpm and measure + for (final (measureIndex, measure) in measures.indexed) { + for (final (noteIndex, noteData) in measure.indexed) { + double beat = measureIndex * 4.0 + + (noteIndex.toDouble() / measure.length) * 4.0; + double minutesPerBeat = 1.0 / bpm; + double offsetMinutes = offset / 60.0; + chart.beats[beat * minutesPerBeat + offsetMinutes] = noteData; + } + } + + chart.measures = measures; + chartSimplest = chart; + } + } + + /// parse a tag based on a regex match [fieldData] and parsing the value based + /// on the key + void _parseTag(RegExpMatch fieldData) { + List<String> keys = + fieldData[1]!.split(':').map((key) => key.trim()).toList(); + String value = fieldData[2]!; + + if (keys[0] == "BPMS") { + for (final pairRaw in value.split(',')) { + List<String> pair = pairRaw.split('='); + if (pair.length != 2) { + continue; + } + double time = double.parse(pair[0]); + double bpm = double.parse(pair[1]); + bpms[time] = bpm; + } + } + + if (keys[0] == "OFFSET") { + offset = double.parse(value); + } + + if (keys[0] != "NOTES") { + tags[keys[0]] = value; + return; + } + _parseChart(keys: keys, value: value); + } + + /// load the simfile + Future<bool> load() async { + directoryPath = File(simfilePath).parent.path; + lines = File(simfilePath).readAsStringSync(); + + // remove comments + RegExp commentsRegExp = RegExp(r'//.*$'); + lines = lines?.replaceAll(commentsRegExp, ''); + // find all tags + RegExp fieldDataRegExp = RegExp(r'#([^;]+):([^;]*);'); + + // parse all tags + for (final fieldData in fieldDataRegExp.allMatches(lines!)) { + try { + _parseTag(fieldData); + } catch (err) { + return false; + } + } + + // searching for audio and banned in the directory is more robust than using + // values from metadata as they are wrong more often + for (FileSystemEntity entity in Directory(directoryPath!).listSync()) { + if (entity.path.endsWith('.ogg')) { + audioPath = entity.path; + } + if (entity.path.endsWith('anner.png')) { + bannerPath = entity.path; + } + } + + // dont use this simfile of files are missing + if (audioPath == null) return false; + if (bannerPath == null) return false; + + // get duration from audio + AudioPlayer audioplayer = AudioPlayer(); + await audioplayer.setSource(DeviceFileSource(audioPath!)); + duration = await audioplayer.getDuration(); + audioplayer.dispose(); + + return true; + } +} diff --git a/lib/widgets/arrow.dart b/lib/widgets/arrow.dart new file mode 100644 index 0000000..1ad0ec4 --- /dev/null +++ b/lib/widgets/arrow.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/models/arrow_direction.dart'; + +class Arrow extends StatelessWidget { + final double position; + final ArrowDirection direction; + + const Arrow({super.key, required this.position, required this.direction}); + + @override + Widget build(BuildContext context) { + return Positioned( + left: MediaQuery.of(context).size.width / 2 - 50, // Center the arrow + bottom: position + 50, + child: Icon(size: 100, color: Colors.redAccent.shade400, direction.icon), + ); + } +} diff --git a/lib/widgets/arrows.dart b/lib/widgets/arrows.dart new file mode 100644 index 0000000..162f0f3 --- /dev/null +++ b/lib/widgets/arrows.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/models/note.dart'; +import 'package:sense_the_rhythm/widgets/arrow.dart'; + +class Arrows extends StatelessWidget { + final List<Note> notes; + + const Arrows({super.key, required this.notes}); + + @override + Widget build(BuildContext context) { + return Stack( + children: notes.map((note) { + double position = + note.position * 10000; // * 20 * MediaQuery.of(context).size.height; + + return Arrow( + position: position, + direction: note.direction, + ); + }).toList()); + } +} diff --git a/lib/widgets/connection_status_button.dart b/lib/widgets/connection_status_button.dart new file mode 100644 index 0000000..e0c4cb4 --- /dev/null +++ b/lib/widgets/connection_status_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/utils/esense_input.dart'; +import 'package:sense_the_rhythm/widgets/esense_connect_dialog.dart'; + +class ConnectionStatusButton extends StatelessWidget { + final String deviceStatus; + const ConnectionStatusButton( + this.deviceStatus, { + super.key, + }); + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + ESenseInput.instance.connected ? Colors.green : Colors.grey), + padding: + WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 8.0))), + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) { + return ESenseConnectDialog( + deviceStatus: ESenseInput.instance.deviceStatus, + connect: (String name) { + ESenseInput.instance.connectToESense(name); + }, + disconnect: () { + ESenseInput.instance.eSenseManager.disconnect(); + }, + ); + }, + ), + label: Text(deviceStatus), + iconAlignment: IconAlignment.end, + icon: Icon(ESenseInput.instance.connected + ? Icons.bluetooth_connected + : Icons.bluetooth)); + } +} diff --git a/lib/widgets/esense_connect_dialog.dart b/lib/widgets/esense_connect_dialog.dart new file mode 100644 index 0000000..2853ffe --- /dev/null +++ b/lib/widgets/esense_connect_dialog.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/utils/esense_input.dart'; + +class ESenseConnectDialog extends StatefulWidget { + final void Function(String) connect; + final VoidCallback disconnect; + final ValueNotifier<String> deviceStatus; + + const ESenseConnectDialog( + {super.key, + required this.deviceStatus, + required this.connect, + required this.disconnect}); + + @override + State<ESenseConnectDialog> createState() => _ESenseConnectDialogState(); +} + +class _ESenseConnectDialogState extends State<ESenseConnectDialog> { + String _eSenseDeviceName = ''; + + @override + Widget build(BuildContext context) { + // rerender whenever the deviceStatus changes + return ValueListenableBuilder( + valueListenable: widget.deviceStatus, + builder: (BuildContext context, String deviceStatus, Widget? child) { + return AlertDialog( + title: const Text('Connect to ESense'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField( + onChanged: (input) { + setState(() { + _eSenseDeviceName = input; + }); + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'eSense-xxxx', + labelText: 'Device name', + ), + ), + // Text(eSenseDeviceName), + Text(deviceStatus) + ]), + actions: <Widget>[ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Close'), + ), + ESenseInput.instance.connected + ? TextButton( + onPressed: () => widget.disconnect(), + child: const Text('Disconnect'), + ) + : TextButton( + onPressed: () => widget.connect(_eSenseDeviceName), + child: const Text('Connect'), + ), + ], + ); + }); + } +} diff --git a/lib/widgets/esense_not_connected_dialog.dart b/lib/widgets/esense_not_connected_dialog.dart new file mode 100644 index 0000000..32d1d6b --- /dev/null +++ b/lib/widgets/esense_not_connected_dialog.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class ESenseNotConnectedDialog extends StatelessWidget { + const ESenseNotConnectedDialog( + {super.key, required this.onCancel, required this.onContinue}); + + final VoidCallback onCancel; + final VoidCallback onContinue; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("ESense not connected"), + content: const Text( + "You will only be able to play with the arrow keys of an external keyboard. "), + actions: <Widget>[ + TextButton( + onPressed: () { + Navigator.pop(context, 'Cancel'); + onCancel(); + }, + child: const Text('Connect to ESense'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, 'Cancel'); + onContinue(); + }, + child: const Text('Continue anyway'), + ), + ], + ); + } +} diff --git a/lib/widgets/level_info_chip.dart b/lib/widgets/level_info_chip.dart new file mode 100644 index 0000000..8e4146c --- /dev/null +++ b/lib/widgets/level_info_chip.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class LevelInfoChip extends StatelessWidget { + final String label; + final IconData icon; + + const LevelInfoChip({super.key, required this.label, required this.icon}); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + style: ButtonStyle( + shape: WidgetStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)))), + minimumSize: WidgetStateProperty.all(Size(10, 10)), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStateProperty.all( + EdgeInsets.symmetric(vertical: 4.0, horizontal: 5.0)) + ), + onPressed: () {}, + child: Row(children: [ + Icon( + icon, + size: 16, + ), + SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w200), // Adjust font size for smaller appearance + ), + ]), + ); + } +} diff --git a/lib/widgets/level_list_entry.dart b/lib/widgets/level_list_entry.dart new file mode 100644 index 0000000..ad7766d --- /dev/null +++ b/lib/widgets/level_list_entry.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:sense_the_rhythm/utils/esense_input.dart'; +import 'package:sense_the_rhythm/utils/simfile.dart'; +import 'package:sense_the_rhythm/screens/level.dart'; +import 'package:sense_the_rhythm/widgets/esense_connect_dialog.dart'; +import 'package:sense_the_rhythm/widgets/esense_not_connected_dialog.dart'; +import 'package:sense_the_rhythm/widgets/level_info_chip.dart'; + +class LevelListEntry extends StatelessWidget { + const LevelListEntry({ + super.key, + required this.simfile, + }); + + final Simfile simfile; + + /// navigates to level screen + void _navigateToLevel(BuildContext context) { + Navigator.push(context, + MaterialPageRoute(builder: (BuildContext context) => Level(simfile))); + } + + /// opens ESenseConnectDialog + void _openESenseConnectDialog(context) { + showDialog( + context: context, + builder: (BuildContext context) { + return ESenseConnectDialog( + deviceStatus: ESenseInput.instance.deviceStatus, + connect: (String name) { + ESenseInput.instance.connectToESense(name); + }, + disconnect: () { + ESenseInput.instance.eSenseManager.disconnect(); + }, + ); + }, + ); + } + + /// when clocked on the level, warn if not connected to ESense + void _tapHandler(BuildContext context) { + if (ESenseInput.instance.connected) { + _navigateToLevel(context); + } else { + showDialog( + context: context, + builder: (BuildContext context) { + return ESenseNotConnectedDialog( + onCancel: () { + _openESenseConnectDialog(context); + }, + onContinue: () { + _navigateToLevel(context); + }, + ); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Image.file(File(simfile.bannerPath!)), + trailing: Icon(Icons.play_arrow), + title: Text( + simfile.tags["TITLE"]!, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + spacing: 2, + children: [ + LevelInfoChip( + label: + '${simfile.duration!.inMinutes}:${simfile.duration!.inSeconds.remainder(60).toString().padLeft(2, "0")}', + icon: Icons.timer_outlined, + ), + LevelInfoChip( + label: '${simfile.bpms.entries.first.value.toInt()} BPM', + icon: Icons.graphic_eq, + ), + ], + ), + ), + onTap: () { + _tapHandler(context); + }, + ); + } +} |