Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리

This commit is contained in:
Sn-Kinos
2026-10-06 12:23:47 +09:00
parent 4e8d989f29
commit 14fef64999
24 changed files with 300 additions and 34 deletions
@@ -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(
+30 -1
View File
@@ -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(),
),
);
+16 -6
View File
@@ -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(),
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "Details",
"questInProgress": "In progress",
"questResume": "Resume",
"questShakeRestart": "The quest settings changed, so you will start from the beginning.",
"questPaused": "Paused",
"questTypeAttend": "Check-in",
"questTypeSteps": "Walking",
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "Detalles",
"questInProgress": "En curso",
"questResume": "Reanudar",
"questShakeRestart": "La configuración de la misión cambió, así que empiezas desde el principio.",
"questPaused": "En pausa",
"questTypeAttend": "Asistencia",
"questTypeSteps": "Caminar",
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "詳細",
"questInProgress": "進行中",
"questResume": "再開する",
"questShakeRestart": "クエストの設定が変わったため、最初から始めます",
"questPaused": "一時停止",
"questTypeAttend": "出席",
"questTypeSteps": "ウォーキング",
+1
View File
@@ -1163,6 +1163,7 @@
"questDetails": "자세히",
"questInProgress": "진행중",
"questResume": "이어하기",
"questShakeRestart": "퀘스트 설정이 바뀌어 처음부터 시작합니다",
"questPaused": "일시 정지",
"questTypeAttend": "출석",
"questTypeSteps": "걷기",
+6
View File
@@ -3620,6 +3620,12 @@ abstract class AppLocalizations {
/// **'이어하기'**
String get questResume;
/// No description provided for @questShakeRestart.
///
/// In ko, this message translates to:
/// **'퀘스트 설정이 바뀌어 처음부터 시작합니다'**
String get questShakeRestart;
/// No description provided for @questPaused.
///
/// In ko, this message translates to:
+4
View File
@@ -2036,6 +2036,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get questResume => 'Resume';
@override
String get questShakeRestart =>
'The quest settings changed, so you will start from the beginning.';
@override
String get questPaused => 'Paused';
+4
View File
@@ -2048,6 +2048,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get questResume => 'Reanudar';
@override
String get questShakeRestart =>
'La configuración de la misión cambió, así que empiezas desde el principio.';
@override
String get questPaused => 'En pausa';
+3
View File
@@ -1976,6 +1976,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get questResume => '再開する';
@override
String get questShakeRestart => 'クエストの設定が変わったため、最初から始めます';
@override
String get questPaused => '一時停止';
+3
View File
@@ -1977,6 +1977,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get questResume => '이어하기';
@override
String get questShakeRestart => '퀘스트 설정이 바뀌어 처음부터 시작합니다';
@override
String get questPaused => '일시 정지';
+3
View File
@@ -1974,6 +1974,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get questResume => '继续';
@override
String get questShakeRestart => '任务设置已更改,将从头开始';
@override
String get questPaused => '已暂停';
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "详情",
"questInProgress": "进行中",
"questResume": "继续",
"questShakeRestart": "任务设置已更改,将从头开始",
"questPaused": "已暂停",
"questTypeAttend": "签到",
"questTypeSteps": "步行",
@@ -2158,6 +2158,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
@override
String get questShakeRestart => _remote['questShakeRestart'] ?? _base.questShakeRestart;
@override
String get questStart => _remote['questStart'] ?? _base.questStart;