Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user