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
@@ -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(