퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기

This commit is contained in:
Sn-Kinos
2026-10-06 03:20:46 +09:00
parent ecf5998725
commit abe5f1ae50
28 changed files with 1023 additions and 118 deletions
@@ -21,7 +21,8 @@ typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고,
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다.
/// 판정표를 돌려준다. 중간에 나가면 null 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83).
///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
@@ -31,9 +32,13 @@ class MusicShakePlayScreen extends StatefulWidget {
required this.setup,
required this.shakes,
this.player,
this.judge,
});
final MusicShakeSetup setup;
/// 이 판의 판정표. 넘기지 않으면 새 판이다. 「이어하기」는 멈춘 판의 판정표를 넘긴다.
final ShakeJudge? judge;
final Stream<DateTime> shakes;
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
@@ -44,17 +49,19 @@ class MusicShakePlayScreen extends StatefulWidget {
}
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
late final ShakeJudge _judge = ShakeJudge(
bpm: widget.setup.bpm,
segments: widget.setup.segments,
windows: widget.setup.windows,
);
late final ShakeJudge _judge =
widget.judge ??
ShakeJudge(
bpm: widget.setup.bpm,
segments: widget.setup.segments,
windows: widget.setup.windows,
);
StreamSubscription<DateTime>? _sub;
Timer? _tick;
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
bool _playing = false;
Duration _anchorMedia = Duration.zero;
late Duration _anchorMedia = _judge.position;
DateTime _anchorWall = DateTime.now();
int _lastJudged = 0;
bool _done = false;
@@ -84,6 +91,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
if (_playing != e.playing && mounted) setState(() {});
_playing = e.playing;
_anchorMedia = e.position;
_judge.position = e.position;
_anchorWall = DateTime.now();
if (e.ended) _finish();
}
@@ -188,8 +196,11 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
);
}
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) =>
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent);
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) => YoutubeEmbed(
videoId: widget.setup.videoId,
onEvent: onEvent,
start: _judge.position,
);
}
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
@@ -258,9 +269,17 @@ class ShakeTally extends StatelessWidget {
/// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」).
/// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다.
class YoutubeEmbed extends StatefulWidget {
const YoutubeEmbed({super.key, required this.videoId, required this.onEvent});
const YoutubeEmbed({
super.key,
required this.videoId,
required this.onEvent,
this.start = Duration.zero,
});
final String videoId;
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
final Duration start;
final ValueChanged<ShakePlayerEvent> onEvent;
/// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다).
@@ -273,7 +292,7 @@ class YoutubeEmbed extends StatefulWidget {
!Platform.environment.containsKey('FLUTTER_TEST');
@visibleForTesting
static String html(String videoId) =>
static String html(String videoId, {int startSeconds = 0}) =>
'''
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body{margin:0;height:100%;background:#000}#p{width:100%;height:100%}</style></head>
@@ -282,7 +301,7 @@ var player;
function post(s){Player.postMessage(JSON.stringify({s:s,t:player.getCurrentTime()}));}
function onYouTubeIframeAPIReady(){
player=new YT.Player('p',{videoId:${jsonEncode(videoId)},
playerVars:{playsinline:1,rel:0,origin:'$appOrigin'},
playerVars:{playsinline:1,rel:0,start:$startSeconds,origin:'$appOrigin'},
events:{onStateChange:function(e){post(e.data);},
onError:function(e){Player.postMessage(JSON.stringify({err:e.data}));}}});
setInterval(function(){if(player&&player.getPlayerState&&player.getPlayerState()==1)post(1);},500);
@@ -309,7 +328,7 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
..setBackgroundColor(Colors.black)
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
..loadHtmlString(
YoutubeEmbed.html(widget.videoId),
YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds),
baseUrl: YoutubeEmbed.appOrigin,
);
}