mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-28 03:22:39 +00:00
6314188835
- align Android/iOS native bridge implementations and audio handling paths - improve Bluetooth provisioning/verification flow and related error handling - refactor WebSocket, music, and device utility logic for more stable behavior - clean up noisy debug logs and normalize comments across Flutter and native code - update AR view, dance/agent/device pages, and platform integration details
577 lines
18 KiB
Dart
577 lines
18 KiB
Dart
/*
|
|
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
|
SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flex_color_picker/flex_color_picker.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:get/get.dart' hide FormData, MultipartFile;
|
|
import 'package:intl/intl.dart';
|
|
import 'package:stack_chan/app_state.dart';
|
|
import 'package:stack_chan/model/dance_list.dart';
|
|
import 'package:stack_chan/model/expression_data.dart';
|
|
import 'package:stack_chan/util/blue_util.dart';
|
|
import 'package:stack_chan/util/extension.dart';
|
|
import 'package:stack_chan/util/music_util.dart';
|
|
import 'package:stack_chan/view/util/grid_coordinate_joystick.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../util/stackchan_robot_box.dart';
|
|
import 'add_music.dart';
|
|
|
|
class RecordDance extends StatefulWidget {
|
|
const RecordDance({super.key, this.onResult});
|
|
|
|
final Function(List<DanceData>, String, String?)? onResult;
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _RecordDanceState();
|
|
}
|
|
|
|
class RecordDanceModel extends GetxController {
|
|
Rxn<MusicInfo> musicInfo = Rxn(null);
|
|
RxnString musicUrl = RxnString(null);
|
|
RxString danceName = RxString("");
|
|
|
|
RxBool isPlaying = RxBool(false);
|
|
RxBool isRecording = RxBool(false);
|
|
RxDouble playbackProgress = RxDouble(0.0);
|
|
|
|
Rx<ExpressionData> avatarData = Rx(
|
|
ExpressionData(
|
|
leftEye: ExpressionItem(weight: 100),
|
|
rightEye: ExpressionItem(weight: 100),
|
|
mouth: ExpressionItem(weight: 0),
|
|
),
|
|
);
|
|
Rx<MotionData> motionData = Rx(
|
|
MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()),
|
|
);
|
|
|
|
Rx<String> leftRgbColor = RxString("#FFFFFF");
|
|
Rx<String> rightRgbColor = RxString("#FFFFFF");
|
|
|
|
RxList<double> bandFrequency = RxList([]);
|
|
}
|
|
|
|
class _RecordDanceState extends State<RecordDance> {
|
|
RecordDanceModel model = RecordDanceModel();
|
|
|
|
Timer? recordTimer;
|
|
Timer? playbackTimer;
|
|
DateTime? recordStartTime;
|
|
final Uuid uuid = const Uuid();
|
|
|
|
List<DanceData> recordedDanceFrames = [];
|
|
|
|
@override
|
|
void dispose() {
|
|
stopAllTimers();
|
|
MusicUtil.shared.stopMusic();
|
|
model.onClose();
|
|
super.dispose();
|
|
}
|
|
|
|
void stopAllTimers() {
|
|
recordTimer?.cancel();
|
|
playbackTimer?.cancel();
|
|
recordTimer = null;
|
|
playbackTimer = null;
|
|
}
|
|
|
|
String formatTime(int seconds) {
|
|
final minutes = seconds ~/ 60;
|
|
final remainingSeconds = seconds % 60;
|
|
return DateFormat(
|
|
'mm:ss',
|
|
).format(DateTime(0, 0, 0, 0, minutes, remainingSeconds));
|
|
}
|
|
|
|
void recordDanceFrame() {
|
|
if (!model.isRecording.value) return;
|
|
final danceFrame = DanceData(
|
|
leftEye: model.avatarData.value.leftEye.copy(),
|
|
rightEye: model.avatarData.value.rightEye.copy(),
|
|
mouth: model.avatarData.value.mouth.copy(),
|
|
yawServo: model.motionData.value.yawServo.copy(),
|
|
pitchServo: model.motionData.value.pitchServo.copy(),
|
|
leftRgbColor: model.leftRgbColor.value,
|
|
rightRgbColor: model.rightRgbColor.value,
|
|
durationMs: 100,
|
|
);
|
|
recordedDanceFrames.add(danceFrame);
|
|
}
|
|
|
|
Widget buildBandFrequencyChart(List<double> frequencies, double progress) {
|
|
if (frequencies.isEmpty) {
|
|
return const SizedBox(height: 0);
|
|
}
|
|
return SizedBox(
|
|
height: 60,
|
|
width: .infinity,
|
|
child: Stack(
|
|
clipBehavior: .none,
|
|
alignment: .bottomCenter,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: .end,
|
|
mainAxisAlignment: .spaceEvenly,
|
|
children: frequencies.map((freq) {
|
|
final normalizedFreq = freq.clamp(0.0, 1.0);
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: const .symmetric(horizontal: 1),
|
|
child: Container(
|
|
height: normalizedFreq * 250,
|
|
decoration: BoxDecoration(
|
|
color: CupertinoColors.systemBlue.withValues(alpha: 0.7),
|
|
borderRadius: .vertical(top: .circular(2)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
Positioned(
|
|
left: progress * MediaQuery.of(context).size.width - 1,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
width: 2,
|
|
color: CupertinoColors.systemRed,
|
|
height: 60,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = CupertinoTheme.of(context);
|
|
List<Widget> listWidget = [];
|
|
|
|
if (model.musicInfo.value != null) {
|
|
listWidget.add(
|
|
Column(
|
|
mainAxisSize: .min,
|
|
spacing: 8,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Obx(
|
|
() => Text(
|
|
model.musicInfo.value!.title ?? "Music",
|
|
style: theme.textTheme.textStyle,
|
|
),
|
|
),
|
|
Spacer(),
|
|
CupertinoButton(
|
|
padding: .zero,
|
|
minimumSize: .zero,
|
|
child: Row(
|
|
spacing: 4,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Obx(
|
|
() => SvgPicture.asset(
|
|
model.isRecording.value
|
|
? "assets/stop.circle.fill.svg"
|
|
: "assets/record.circle.fill.svg",
|
|
colorFilter: .mode(
|
|
model.isRecording.value
|
|
? CupertinoColors.systemRed
|
|
: CupertinoColors.systemOrange,
|
|
.srcIn,
|
|
),
|
|
width: 15,
|
|
height: 15,
|
|
),
|
|
),
|
|
Obx(
|
|
() => Text(
|
|
model.isRecording.value
|
|
? "Stop Record"
|
|
: "Start Record",
|
|
style: theme.textTheme.textStyle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onPressed: () {
|
|
if (model.isRecording.value) {
|
|
stopRecordingAndPlayback();
|
|
} else {
|
|
startRecordingAndPlayback();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
Obx(() {
|
|
final musicInfo = model.musicInfo.value;
|
|
final duration = musicInfo?.duration ?? 0;
|
|
final currentSec = (model.playbackProgress.value * duration)
|
|
.toInt();
|
|
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
spacing: 4,
|
|
children: [
|
|
Obx(
|
|
() => buildBandFrequencyChart(
|
|
model.bandFrequency,
|
|
model.playbackProgress.value,
|
|
),
|
|
),
|
|
LinearProgressIndicator(
|
|
value: model.playbackProgress.value,
|
|
color: theme.primaryColor,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: .spaceBetween,
|
|
children: [
|
|
Text(
|
|
formatTime(currentSec),
|
|
style: theme.textTheme.dateTimePickerTextStyle,
|
|
),
|
|
Text(
|
|
formatTime(duration),
|
|
style: theme.textTheme.dateTimePickerTextStyle,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
listWidget.add(
|
|
CupertinoButton(
|
|
color: CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
|
borderRadius: .circular(12),
|
|
padding: .zero,
|
|
minimumSize: .zero,
|
|
child: SizedBox(
|
|
height: 60,
|
|
child: Row(
|
|
crossAxisAlignment: .center,
|
|
children: [
|
|
Spacer(),
|
|
Text("Select Music", style: TextStyle(fontSize: 30)),
|
|
Spacer(),
|
|
],
|
|
),
|
|
),
|
|
onPressed: () {
|
|
showCupertinoSheet(
|
|
context: context,
|
|
builder: (context) {
|
|
return AddMusic(
|
|
onResult: (url) async {
|
|
final musicInfo = await MusicUtil.shared.getMusicInfoAsync(
|
|
url,
|
|
);
|
|
if (musicInfo != null) {
|
|
setState(() {
|
|
model.musicUrl.value = url;
|
|
model.musicInfo.value = musicInfo;
|
|
model.danceName.value = musicInfo.title ?? "";
|
|
});
|
|
final progressList = await model.musicInfo.value!
|
|
.getProgressData(targetSampleCount: 100);
|
|
model.bandFrequency.value = progressList;
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
listWidget.add(
|
|
Obx(
|
|
() => StackChanRobotBox(
|
|
topLook: true,
|
|
width: double.infinity,
|
|
height: 250,
|
|
data: DanceData(
|
|
leftEye: model.avatarData.value.leftEye,
|
|
rightEye: model.avatarData.value.rightEye,
|
|
mouth: model.avatarData.value.mouth,
|
|
yawServo: model.motionData.value.yawServo,
|
|
pitchServo: model.motionData.value.pitchServo,
|
|
durationMs: 1000,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
listWidget.add(
|
|
Container(
|
|
height: 200,
|
|
width: .infinity,
|
|
decoration: BoxDecoration(
|
|
color: CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
|
borderRadius: BorderRadius.circular(25),
|
|
),
|
|
child: Obx(
|
|
() => GridCoordinateJoystick(
|
|
minX: -1280,
|
|
maxX: 1280,
|
|
minY: 0,
|
|
maxY: 900,
|
|
padding: const EdgeInsets.all(25),
|
|
showMarking: false,
|
|
targetGridSize: 50,
|
|
buttonSize: 50,
|
|
point: Offset(
|
|
model.motionData.value.yawServo.angle.toDouble(),
|
|
model.motionData.value.pitchServo.angle.toDouble(),
|
|
),
|
|
onImmediatelyRelease: (point) {
|
|
model.motionData.value.yawServo.rotate = 0;
|
|
model.motionData.value.yawServo.angle = point.dx.toInt();
|
|
model.motionData.value.pitchServo.angle = point.dy.toInt();
|
|
model.motionData.refresh();
|
|
saveMotionData();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
listWidget.add(SizedBox(height: 15));
|
|
|
|
listWidget.add(
|
|
Row(
|
|
children: [
|
|
Text("Light strip left color", style: theme.textTheme.textStyle),
|
|
Spacer(),
|
|
Obx(
|
|
() => CupertinoButton(
|
|
borderRadius: .circular(50),
|
|
color: CupertinoColors.systemGroupedBackground.resolveFrom(
|
|
context,
|
|
),
|
|
minimumSize: .zero,
|
|
padding: .all(5),
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: hexToColor(model.leftRgbColor.value),
|
|
borderRadius: .circular(50),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
colorPickerDialog(true);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
listWidget.add(SizedBox(height: 15));
|
|
|
|
listWidget.add(
|
|
Row(
|
|
children: [
|
|
Text("Light strip right color", style: theme.textTheme.textStyle),
|
|
Spacer(),
|
|
Obx(
|
|
() => CupertinoButton(
|
|
borderRadius: .circular(50),
|
|
color: CupertinoColors.systemGroupedBackground.resolveFrom(
|
|
context,
|
|
),
|
|
minimumSize: .zero,
|
|
padding: .all(5),
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: hexToColor(model.rightRgbColor.value),
|
|
borderRadius: .circular(50),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
colorPickerDialog(false);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return CupertinoPageScaffold(
|
|
backgroundColor: CupertinoColors.systemBackground.resolveFrom(context),
|
|
navigationBar: CupertinoNavigationBar.large(
|
|
backgroundColor: CupertinoColors.systemBackground.resolveFrom(context),
|
|
largeTitle: Text("Record Dance"),
|
|
trailing: CupertinoButton(
|
|
sizeStyle: .medium,
|
|
onPressed: () async {
|
|
if (model.isRecording.value) {
|
|
stopRecordingAndPlayback();
|
|
}
|
|
CupertinoSheetRoute.popSheet(context);
|
|
//directreturn
|
|
widget.onResult?.call(
|
|
recordedDanceFrames,
|
|
model.musicUrl.value ?? "",
|
|
model.danceName.value,
|
|
);
|
|
},
|
|
child: Icon(CupertinoIcons.check_mark),
|
|
),
|
|
leading: CupertinoButton(
|
|
sizeStyle: .medium,
|
|
child: Icon(CupertinoIcons.xmark),
|
|
onPressed: () => CupertinoSheetRoute.popSheet(context),
|
|
),
|
|
),
|
|
child: ListView(padding: .all(15), children: listWidget),
|
|
);
|
|
}
|
|
|
|
Future<bool> colorPickerDialog(bool isLeft) async {
|
|
return ColorPicker(
|
|
// Use the dialogPickerColor as start and active color.
|
|
color: isLeft
|
|
? hexToColor(model.leftRgbColor.value)
|
|
: hexToColor(model.rightRgbColor.value),
|
|
// Update the dialogPickerColor using the callback.
|
|
onColorChanged: (Color color) {
|
|
if (isLeft) {
|
|
model.leftRgbColor.value = colorToHex(color);
|
|
} else {
|
|
model.rightRgbColor.value = colorToHex(color);
|
|
}
|
|
},
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 4,
|
|
spacing: 5,
|
|
runSpacing: 5,
|
|
wheelDiameter: 155,
|
|
heading: Text(
|
|
'Select color',
|
|
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
|
|
),
|
|
subheading: Text(
|
|
'Select color shade',
|
|
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
|
|
),
|
|
wheelSubheading: Text(
|
|
'Selected color and its shades',
|
|
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
|
|
),
|
|
showMaterialName: true,
|
|
showColorName: true,
|
|
showColorCode: true,
|
|
copyPasteBehavior: const ColorPickerCopyPasteBehavior(
|
|
longPressMenu: true,
|
|
),
|
|
|
|
pickersEnabled: const <ColorPickerType, bool>{
|
|
ColorPickerType.both: false,
|
|
ColorPickerType.primary: true,
|
|
ColorPickerType.accent: true,
|
|
ColorPickerType.bw: false,
|
|
ColorPickerType.custom: true,
|
|
ColorPickerType.wheel: true,
|
|
},
|
|
).showPickerDialog(context);
|
|
}
|
|
|
|
String colorToHex(Color color) {
|
|
return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
|
}
|
|
|
|
Color hexToColor(String hexString) {
|
|
final hex = hexString.replaceAll('#', '');
|
|
return Color(int.parse('FF$hex', radix: 16));
|
|
}
|
|
|
|
void stopRecordingAndPlayback() {
|
|
MusicUtil.shared.stopMusic();
|
|
|
|
stopAllTimers();
|
|
|
|
model.isRecording.value = false;
|
|
model.isPlaying.value = false;
|
|
|
|
if (model.playbackProgress.value > 0.9) {
|
|
model.playbackProgress.value = 1.0;
|
|
}
|
|
}
|
|
|
|
void startRecordingAndPlayback() {
|
|
final musicInfo = model.musicInfo.value;
|
|
if (musicInfo == null) return;
|
|
|
|
recordedDanceFrames.clear();
|
|
model.playbackProgress.value = 0.0;
|
|
model.isRecording.value = true;
|
|
recordStartTime = DateTime.now();
|
|
recordTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
|
recordDanceFrame();
|
|
});
|
|
MusicUtil.shared.playMusicOnce(musicInfo, () {
|
|
stopRecordingAndPlayback();
|
|
});
|
|
|
|
playbackTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) {
|
|
final duration = MusicUtil.shared.getMusicDuration();
|
|
final currentPosition = MusicUtil.shared.getCurrentPosition();
|
|
|
|
if (duration > 0 && currentPosition >= 0) {
|
|
//calculatenormalizeprogress (0.0 to 1.0)
|
|
final progress = currentPosition / duration;
|
|
model.playbackProgress.value = progress.clamp(0.0, 1.0);
|
|
}
|
|
});
|
|
}
|
|
|
|
DateTime lastBluetoothSendTime = DateTime.now();
|
|
|
|
void saveMotionData() {
|
|
if (AppState.shared.deviceControlMode == 0) {
|
|
if (AppState.shared.deviceMac.isNotEmpty) {
|
|
final jsonString =
|
|
AppState.shared.deviceMac + model.motionData.value.toString();
|
|
final data = jsonString.toUint8List();
|
|
AppState.shared.sendWebSocketMessage(.controlMotion, data: data);
|
|
}
|
|
} else {
|
|
final currentTime = DateTime.now();
|
|
final timeInterval = currentTime
|
|
.difference(lastBluetoothSendTime)
|
|
.inMilliseconds;
|
|
if (timeInterval >= 200) {
|
|
final danceData = DanceData(
|
|
leftEye: ExpressionItem(weight: 100),
|
|
rightEye: ExpressionItem(weight: 100),
|
|
mouth: ExpressionItem(weight: 0),
|
|
yawServo: model.motionData.value.yawServo,
|
|
pitchServo: model.motionData.value.pitchServo,
|
|
durationMs: 0,
|
|
);
|
|
BlueUtil.shared.sendDanceData(danceData);
|
|
lastBluetoothSendTime = currentTime;
|
|
}
|
|
}
|
|
}
|
|
}
|