퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user