Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리
This commit is contained in:
@@ -420,6 +420,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
: FirestoreQuestRepository.pausedSessionOf(
|
||||
QuestType.parse(quest.strOr('type')),
|
||||
progress,
|
||||
quest,
|
||||
),
|
||||
rewardPop:
|
||||
openTimeAttack(quest)?.rewardPop ??
|
||||
|
||||
@@ -34,6 +34,7 @@ class MusicShakePlayScreen extends StatefulWidget {
|
||||
required this.shakes,
|
||||
this.player,
|
||||
this.judge,
|
||||
this.countdown = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
final MusicShakeSetup setup;
|
||||
@@ -42,6 +43,10 @@ class MusicShakePlayScreen extends StatefulWidget {
|
||||
final ShakeJudge? judge;
|
||||
final Stream<DateTime> shakes;
|
||||
|
||||
/// 재생을 시작(이어하기 포함)하면 영상을 멈춰 두고 세는 3·2·1 — 이클립스 오버레이 카운트다운
|
||||
/// (사본 `373:444976`)과 같다. Figma 에 Music Shake 카운트다운 화면은 없다(대표 지시 · 확인 통보).
|
||||
final Duration countdown;
|
||||
|
||||
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
|
||||
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
|
||||
|
||||
@@ -55,10 +60,16 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
ShakeJudge(
|
||||
bpm: widget.setup.bpm,
|
||||
segments: widget.setup.segments,
|
||||
firstBeat: widget.setup.firstBeat,
|
||||
windows: widget.setup.windows,
|
||||
);
|
||||
StreamSubscription<DateTime>? _sub;
|
||||
Timer? _tick;
|
||||
final _control = ShakePlayerControl();
|
||||
|
||||
/// 카운트다운 — 한 번 세었으면 true, 세는 중이면 [_countEnd] 가 있다.
|
||||
bool _counted = false;
|
||||
DateTime? _countEnd;
|
||||
|
||||
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
|
||||
bool _playing = false;
|
||||
@@ -89,6 +100,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
|
||||
|
||||
void _onEvent(ShakePlayerEvent e) {
|
||||
if (e.playing && !_counted && widget.countdown > Duration.zero) {
|
||||
// 처음 재생(이어하기 포함)이 시작되면 영상을 멈춰 두고 센다 — 세는 동안은 판정하지 않는다.
|
||||
_counted = true;
|
||||
_countEnd = DateTime.now().add(widget.countdown);
|
||||
_control.pauseAt(_judge.position);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
if (_countEnd != null) {
|
||||
_playing = false;
|
||||
return;
|
||||
}
|
||||
if (_playing != e.playing && mounted) setState(() {});
|
||||
_playing = e.playing;
|
||||
_anchorMedia = e.position;
|
||||
@@ -98,6 +120,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
}
|
||||
|
||||
void _onTick() {
|
||||
final end = _countEnd;
|
||||
if (end != null) {
|
||||
if (DateTime.now().isBefore(end)) {
|
||||
setState(() {});
|
||||
} else {
|
||||
_countEnd = null;
|
||||
_control.play();
|
||||
setState(() {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final now = _mediaAt(DateTime.now());
|
||||
if (now == null || _done) return;
|
||||
final judged = _judge.live(now).judged;
|
||||
@@ -120,6 +153,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
|
||||
final v = _judge.live(now);
|
||||
final left = _countEnd?.difference(DateTime.now());
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.cosmicVoid,
|
||||
appBar: AppBar(
|
||||
@@ -138,19 +172,21 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
switch (v.last) {
|
||||
ShakeHit.perfect => 'PERFECT',
|
||||
ShakeHit.good => 'GOOD',
|
||||
ShakeHit.miss => 'MISS',
|
||||
null => ' ',
|
||||
},
|
||||
left != null
|
||||
? '${(left.inMilliseconds / 1000).ceil().clamp(1, 99)}'
|
||||
: switch (v.last) {
|
||||
ShakeHit.perfect => 'PERFECT',
|
||||
ShakeHit.good => 'GOOD',
|
||||
ShakeHit.miss => 'MISS',
|
||||
null => ' ',
|
||||
},
|
||||
key: const ValueKey('shake-last'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 34,
|
||||
fontSize: left != null ? 62 : 34,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
color: switch (v.last) {
|
||||
height: left != null ? 1.4 : 1.3,
|
||||
color: left != null ? AppColors.onCosmic : switch (v.last) {
|
||||
ShakeHit.perfect => AppColors.cosmicAccent,
|
||||
ShakeHit.good => const Color(0xFF67D3A9),
|
||||
_ => const Color(0xFF777777),
|
||||
@@ -201,9 +237,21 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
videoId: widget.setup.videoId,
|
||||
onEvent: onEvent,
|
||||
start: _judge.position,
|
||||
control: _control,
|
||||
);
|
||||
}
|
||||
|
||||
/// 플레이어를 밖에서 멈추고 켠다 — 카운트다운 동안 영상을 세워 두는 데 쓴다.
|
||||
/// 플레이어가 붙기 전(테스트의 가짜 플레이어 포함)에는 아무 일도 하지 않는다.
|
||||
class ShakePlayerControl {
|
||||
void Function(String js)? _run;
|
||||
|
||||
void pauseAt(Duration at) =>
|
||||
_run?.call('player.seekTo(${at.inMilliseconds / 1000},true);player.pauseVideo();');
|
||||
|
||||
void play() => _run?.call('player.playVideo();');
|
||||
}
|
||||
|
||||
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
|
||||
class ShakeTally extends StatelessWidget {
|
||||
const ShakeTally({
|
||||
@@ -275,9 +323,11 @@ class YoutubeEmbed extends StatefulWidget {
|
||||
required this.videoId,
|
||||
required this.onEvent,
|
||||
this.start = Duration.zero,
|
||||
this.control,
|
||||
});
|
||||
|
||||
final String videoId;
|
||||
final ShakePlayerControl? control;
|
||||
|
||||
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
|
||||
final Duration start;
|
||||
@@ -340,6 +390,7 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
|
||||
if (web.platform case final AndroidWebViewController android) {
|
||||
unawaited(android.setMediaPlaybackRequiresUserGesture(false));
|
||||
}
|
||||
widget.control?._run = (js) => unawaited(web.runJavaScript(js));
|
||||
unawaited(
|
||||
web.loadHtmlString(
|
||||
YoutubeEmbed.html(
|
||||
|
||||
@@ -370,6 +370,7 @@ class QuestSession {
|
||||
this.position = Duration.zero,
|
||||
this.hits = const [],
|
||||
this.judgement,
|
||||
this.firstBeat,
|
||||
});
|
||||
|
||||
/// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나.
|
||||
@@ -384,6 +385,16 @@ class QuestSession {
|
||||
/// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점.
|
||||
final Duration position;
|
||||
|
||||
/// 이 일시 정지가 지금 운영값([setup])의 박 위치로 이어질 수 있는지. 첫 박이나 판 길이가 바뀌었으면 false.
|
||||
bool fitsShake(MusicShakeSetup setup) => fits(setup.firstBeat, setup.segments);
|
||||
|
||||
/// [fitsShake] 와 같은 비교 — 운영값만 아는 곳(목록 · HOME · 캡슐은 퀘스트 문서를 읽는다)용.
|
||||
bool fits(Duration firstBeat, int segments) =>
|
||||
this.firstBeat == firstBeat && total == segments;
|
||||
|
||||
/// Music Shake 판을 시작할 때의 첫 박(`quests.firstBeatMs`). 없으면 첫 박 도입 전에 저장된 판이다.
|
||||
final Duration? firstBeat;
|
||||
|
||||
/// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1.
|
||||
final List<double> hits;
|
||||
|
||||
@@ -414,6 +425,7 @@ class QuestCard {
|
||||
this.publishState = '',
|
||||
this.authorName = '',
|
||||
this.session,
|
||||
this.staleShake = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -423,6 +435,10 @@ class QuestCard {
|
||||
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
|
||||
final QuestSession? session;
|
||||
|
||||
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다.
|
||||
/// 이때 [session] 은 null 이고 카드는 「시작하기」로 보인다 — 시작하면 안내를 한 번 띄운다.
|
||||
final bool staleShake;
|
||||
|
||||
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
|
||||
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
|
||||
final String authorName;
|
||||
@@ -473,6 +489,7 @@ class QuestCard {
|
||||
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
|
||||
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
|
||||
session: session,
|
||||
staleShake: staleShake,
|
||||
id: id,
|
||||
title: title,
|
||||
categoryName: categoryName,
|
||||
@@ -500,7 +517,9 @@ class MusicShakeSetup {
|
||||
required this.title,
|
||||
required this.bpm,
|
||||
required this.segments,
|
||||
this.firstBeat = Duration.zero,
|
||||
this.windows = const ShakeWindows(),
|
||||
this.leadIn = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
|
||||
@@ -512,9 +531,19 @@ class MusicShakeSetup {
|
||||
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
|
||||
final double bpm;
|
||||
|
||||
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
|
||||
/// `quests.totalSegments` — 판정 박 수. 곡 전체를 덮도록 운영자가 정한다. 🟡 회신 대기(Q-MS-02).
|
||||
final int segments;
|
||||
|
||||
/// `quests.firstBeatMs` — 영상에서 첫 박 시각. 🟡 임시값 — 회신 대기(Q-MS-02 · Q-MS-03). 없으면 0.
|
||||
final Duration firstBeat;
|
||||
final ShakeWindows windows;
|
||||
|
||||
/// 첫 박 몇 초 전부터 영상을 트는가 — `config/policy.musicShake.leadInMs`(없으면 3초).
|
||||
/// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」 B안).
|
||||
final Duration leadIn;
|
||||
|
||||
/// 새 판의 영상 시작 시각 — 첫 박 [leadIn] 전(0 아래로는 가지 않는다). 이어하기는 저장 위치다.
|
||||
Duration get startAt => firstBeat > leadIn ? firstBeat - leadIn : Duration.zero;
|
||||
}
|
||||
|
||||
class QuestDetail {
|
||||
|
||||
@@ -850,12 +850,19 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
// 운영값(첫 박 · 판 길이)이 바뀐 뒤의 옛 일시 정지는 박 위치가 맞지 않는다 — 버리고 새 판으로 시작한다.
|
||||
// 목록 · 상세가 이미 시작하기로 보이는 경우(staleShake)도 같은 안내를 한 번 한다.
|
||||
if (detail.card.staleShake || (resume != null && !resume.fitsShake(setup))) {
|
||||
resume = null;
|
||||
_toast(context.l10n.questShakeRestart);
|
||||
}
|
||||
final judge = ShakeJudge(
|
||||
bpm: setup.bpm,
|
||||
segments: setup.segments,
|
||||
firstBeat: setup.firstBeat,
|
||||
windows: setup.windows,
|
||||
hits: resume?.hits ?? const [],
|
||||
position: resume?.position ?? Duration.zero,
|
||||
position: resume?.position ?? setup.startAt,
|
||||
);
|
||||
QuestSession snapshot(QuestStage state) => QuestSession(
|
||||
state: state,
|
||||
@@ -863,6 +870,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
total: setup.segments,
|
||||
position: judge.position,
|
||||
hits: judge.hits,
|
||||
firstBeat: setup.firstBeat,
|
||||
);
|
||||
setState(() => _stage = QuestStage.inProgress);
|
||||
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
|
||||
@@ -900,6 +908,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
state: QuestStage.completed,
|
||||
progress: setup.segments,
|
||||
total: setup.segments,
|
||||
firstBeat: setup.firstBeat,
|
||||
judgement: run.judgement(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ typedef ShakeLive = ({
|
||||
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||
///
|
||||
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
|
||||
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
|
||||
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박
|
||||
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
|
||||
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
|
||||
/// 수) 서버가 끊는다.
|
||||
@@ -158,6 +158,7 @@ class ShakeJudge {
|
||||
ShakeJudge({
|
||||
required this.bpm,
|
||||
required this.segments,
|
||||
this.firstBeat = Duration.zero,
|
||||
this.windows = const ShakeWindows(),
|
||||
List<double> hits = const [],
|
||||
this.position = Duration.zero,
|
||||
@@ -170,6 +171,9 @@ class ShakeJudge {
|
||||
|
||||
final double bpm;
|
||||
final int segments;
|
||||
|
||||
/// `quests.firstBeatMs` — 영상에서 첫 박이 오는 시각. 박 i 는 `firstBeat + i * 60/bpm` 이다.
|
||||
final Duration firstBeat;
|
||||
final ShakeWindows windows;
|
||||
|
||||
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
|
||||
@@ -184,13 +188,19 @@ class ShakeJudge {
|
||||
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
||||
|
||||
/// 마지막 박의 판정 창이 닫히는 영상 시각.
|
||||
Duration get end => Duration(
|
||||
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
|
||||
);
|
||||
Duration get end =>
|
||||
firstBeat +
|
||||
Duration(
|
||||
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
|
||||
);
|
||||
|
||||
/// 영상 시각 [at] 이 첫 박에서 몇 박 떨어졌나.
|
||||
double _beats(Duration at) =>
|
||||
(at - firstBeat).inMicroseconds / beat.inMicroseconds;
|
||||
|
||||
/// 영상 시각 [at] 의 흔들기 한 번.
|
||||
void add(Duration at) {
|
||||
final pos = at.inMicroseconds / beat.inMicroseconds;
|
||||
final pos = _beats(at);
|
||||
final i = pos.round();
|
||||
if (i < 0 || i >= segments) return;
|
||||
final d = (pos - i).abs();
|
||||
@@ -203,7 +213,7 @@ class ShakeJudge {
|
||||
ShakeLive live(Duration now) {
|
||||
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
|
||||
ShakeHit? last;
|
||||
final pos = now.inMicroseconds / beat.inMicroseconds;
|
||||
final pos = _beats(now);
|
||||
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
|
||||
judged++;
|
||||
final d = _best[i];
|
||||
|
||||
@@ -158,13 +158,26 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
/// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진
|
||||
/// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null.
|
||||
static QuestSession? pausedSessionOf(QuestType type, Map<String, dynamic> progress) {
|
||||
static QuestSession? pausedSessionOf(
|
||||
QuestType type,
|
||||
Map<String, dynamic> progress,
|
||||
Map<String, dynamic> quest,
|
||||
) {
|
||||
final s = sessionOf(progress);
|
||||
return s == null || type == QuestType.steps || s.state == QuestStage.completed
|
||||
return s == null ||
|
||||
type == QuestType.steps ||
|
||||
s.state == QuestStage.completed ||
|
||||
staleShake(type, quest, s)
|
||||
? null
|
||||
: s;
|
||||
}
|
||||
|
||||
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션인가 — [QuestSession.fitsShake] 와 같은 기준.
|
||||
static bool staleShake(QuestType type, Map<String, dynamic> quest, QuestSession? s) =>
|
||||
type == QuestType.musicShake &&
|
||||
s != null &&
|
||||
!s.fits(Duration(milliseconds: quest.intOr('firstBeatMs')), quest.intOr('totalSegments'));
|
||||
|
||||
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
|
||||
static QuestSession? sessionOf(Map<String, dynamic> progress) {
|
||||
final raw = progress['session'];
|
||||
@@ -185,6 +198,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
total: m.intOr('total'),
|
||||
position: Duration(milliseconds: m.intOr('positionMs')),
|
||||
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
|
||||
firstBeat: m['firstBeatMs'] is num ? Duration(milliseconds: m.intOr('firstBeatMs')) : null,
|
||||
judgement: judgement is Map
|
||||
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
|
||||
: null,
|
||||
@@ -238,13 +252,17 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
: type == QuestType.musicShake
|
||||
? quest.intOr('bpm')
|
||||
: dailyLimit;
|
||||
final session = sessionOf(progress);
|
||||
final saved = sessionOf(progress);
|
||||
// 이어갈 수 없는 옛 Music Shake 판은 세션이 없는 것으로 본다 — 「시작하기」로 보이고 시작 때 안내한다.
|
||||
final stale = staleShake(type, quest, saved);
|
||||
final session = stale ? null : saved;
|
||||
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
|
||||
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
|
||||
final walked = steps && session != null ? walk ?? session.progress : null;
|
||||
|
||||
return QuestCard(
|
||||
session: session,
|
||||
staleShake: stale,
|
||||
id: id,
|
||||
title: questTitle(quest),
|
||||
categoryName: type.category,
|
||||
@@ -561,6 +579,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
'total': session.total,
|
||||
'positionMs': session.position.inMilliseconds,
|
||||
if (session.hits.isNotEmpty) 'hits': session.hits,
|
||||
if (session.firstBeat != null) 'firstBeatMs': session.firstBeat!.inMilliseconds,
|
||||
'judgement': ?session.judgement,
|
||||
},
|
||||
});
|
||||
@@ -792,12 +811,18 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
|
||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||
}
|
||||
final policy = await _policy();
|
||||
final shake = policy['musicShake'];
|
||||
// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」): 첫 박 3초 전부터 튼다.
|
||||
final leadIn = shake is Map ? shake['leadInMs'] : null;
|
||||
return MusicShakeSetup(
|
||||
videoId: videoId,
|
||||
title: link.strOr('title', questTitle(quest)),
|
||||
bpm: bpm.toDouble(),
|
||||
segments: segments,
|
||||
windows: ShakeWindows.from(await _policy()),
|
||||
firstBeat: Duration(milliseconds: quest.intOr('firstBeatMs')),
|
||||
windows: ShakeWindows.from(policy),
|
||||
leadIn: Duration(milliseconds: leadIn is num && leadIn >= 0 ? leadIn.round() : 3000),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -829,6 +854,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
'questId': card.id,
|
||||
'trackId': trackId,
|
||||
'totalSegments': segments,
|
||||
if (run != null) 'firstBeatMs': run.firstBeat.inMilliseconds,
|
||||
'judgement': judgement,
|
||||
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
||||
...await _fandomField(),
|
||||
|
||||
Reference in New Issue
Block a user