이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
|
||||
/// 플레이어 상태 한 번 — 재생 중인가 · 영상 시각 · 끝났는가.
|
||||
typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
|
||||
|
||||
/// Music Shake 진행 — PRD-04 「뮤직비디오를 보면서 스마트폰을 흔드는 미션」.
|
||||
///
|
||||
/// 공식 YouTube 영상(퀘스트의 지정 링크 · PRD-04 MVP 수용 기준)을 IFrame 플레이어로 재생하고,
|
||||
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
|
||||
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
|
||||
/// 판정표를 돌려준다. 중간에 나가면 null — 판정표를 남기지 않는다.
|
||||
///
|
||||
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
|
||||
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
|
||||
class MusicShakePlayScreen extends StatefulWidget {
|
||||
const MusicShakePlayScreen({
|
||||
super.key,
|
||||
required this.setup,
|
||||
required this.shakes,
|
||||
this.player,
|
||||
});
|
||||
|
||||
final MusicShakeSetup setup;
|
||||
final Stream<DateTime> shakes;
|
||||
|
||||
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
|
||||
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
|
||||
|
||||
@override
|
||||
State<MusicShakePlayScreen> createState() => _MusicShakePlayScreenState();
|
||||
}
|
||||
|
||||
class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
||||
late final ShakeJudge _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;
|
||||
DateTime _anchorWall = DateTime.now();
|
||||
int _lastJudged = 0;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sub = widget.shakes.listen((at) {
|
||||
final media = _mediaAt(at);
|
||||
if (media != null) _judge.add(media);
|
||||
}, onError: (_) {});
|
||||
_tick = Timer.periodic(const Duration(milliseconds: 50), (_) => _onTick());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_tick?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 단말 시각 [wall] 의 영상 시각. 재생 중이 아니면 null — 멈춘 동안의 흔들기는 세지 않는다.
|
||||
Duration? _mediaAt(DateTime wall) =>
|
||||
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
|
||||
|
||||
void _onEvent(ShakePlayerEvent e) {
|
||||
if (_playing != e.playing && mounted) setState(() {});
|
||||
_playing = e.playing;
|
||||
_anchorMedia = e.position;
|
||||
_anchorWall = DateTime.now();
|
||||
if (e.ended) _finish();
|
||||
}
|
||||
|
||||
void _onTick() {
|
||||
final now = _mediaAt(DateTime.now());
|
||||
if (now == null || _done) return;
|
||||
final judged = _judge.live(now).judged;
|
||||
if (judged != _lastJudged) {
|
||||
_lastJudged = judged;
|
||||
setState(() {});
|
||||
}
|
||||
if (now >= _judge.end) _finish();
|
||||
}
|
||||
|
||||
void _finish() {
|
||||
if (_done || !mounted) return;
|
||||
_done = true;
|
||||
// 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」.
|
||||
HapticFeedback.mediumImpact();
|
||||
Navigator.of(context).pop(_judge);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
|
||||
final v = _judge.live(now);
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.cosmicVoid,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.setup.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 플레이어 위에는 아무것도 겹치지 않는다(YouTube RMF — 오버레이 금지 · 최소 200×200).
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: (widget.player ?? _defaultPlayer)(_onEvent),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
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,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
color: switch (v.last) {
|
||||
ShakeHit.perfect => AppColors.cosmicAccent,
|
||||
ShakeHit.good => const Color(0xFF67D3A9),
|
||||
_ => const Color(0xFF777777),
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.shakeCombo('${v.combo}'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ShakeTally(
|
||||
perfect: v.perfect,
|
||||
good: v.good,
|
||||
miss: v.miss,
|
||||
maxCombo: v.maxCombo,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_playing
|
||||
? context.l10n.shakeJudged(
|
||||
'${v.judged}',
|
||||
'${widget.setup.segments}',
|
||||
)
|
||||
: context.l10n.shakePlayHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultPlayer(ValueChanged<ShakePlayerEvent> onEvent) =>
|
||||
YoutubeEmbed(videoId: widget.setup.videoId, onEvent: onEvent);
|
||||
}
|
||||
|
||||
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
|
||||
class ShakeTally extends StatelessWidget {
|
||||
const ShakeTally({
|
||||
super.key,
|
||||
required this.perfect,
|
||||
required this.good,
|
||||
required this.miss,
|
||||
required this.maxCombo,
|
||||
});
|
||||
|
||||
final int perfect;
|
||||
final int good;
|
||||
final int miss;
|
||||
final int maxCombo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (label, n) in [
|
||||
('PERFECT', perfect),
|
||||
('GOOD', good),
|
||||
('MISS', miss),
|
||||
('MAX COMBO', maxCombo),
|
||||
])
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'$n',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 16 / 11,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 공식 YouTube IFrame 플레이어(IFrame Player API)를 WebView 로 띄운다.
|
||||
///
|
||||
/// - **영상을 저장하지 않는다**(PRD-04 주의 사항) — 스트리밍 재생만 한다.
|
||||
/// - 플레이어를 고치거나 위에 겹치지 않는다. 컨트롤 그대로다(YouTube Required Minimum Functionality).
|
||||
/// - 앱 식별: `baseUrl` 을 스토어 앱 ID 의 https URL 로 둔다(같은 문서 「Referer for mobile apps」).
|
||||
/// - iOS 는 인라인 재생을 켜야 전체 화면으로 빠지지 않고 판정 화면이 보인다.
|
||||
class YoutubeEmbed extends StatefulWidget {
|
||||
const YoutubeEmbed({super.key, required this.videoId, required this.onEvent});
|
||||
|
||||
final String videoId;
|
||||
final ValueChanged<ShakePlayerEvent> onEvent;
|
||||
|
||||
/// 스토어 앱 ID(Android applicationId · iOS bundle ID 가 같다).
|
||||
static const appOrigin = 'https://kr.co.fanuzu.app';
|
||||
|
||||
/// WebView 를 쓸 수 있는가 — 위젯 테스트 · 데스크톱은 빈 칸.
|
||||
static bool enabled =
|
||||
!kIsWeb &&
|
||||
(Platform.isAndroid || Platform.isIOS) &&
|
||||
!Platform.environment.containsKey('FLUTTER_TEST');
|
||||
|
||||
@visibleForTesting
|
||||
static String html(String videoId) =>
|
||||
'''
|
||||
<!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>
|
||||
<body><div id="p"></div><script src="https://www.youtube.com/iframe_api"></script><script>
|
||||
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'},
|
||||
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);
|
||||
}
|
||||
</script></body></html>''';
|
||||
|
||||
@override
|
||||
State<YoutubeEmbed> createState() => _YoutubeEmbedState();
|
||||
}
|
||||
|
||||
class _YoutubeEmbedState extends State<YoutubeEmbed> {
|
||||
WebViewController? _web;
|
||||
bool _error = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!YoutubeEmbed.enabled) return;
|
||||
final params = WebViewPlatform.instance is WebKitWebViewPlatform
|
||||
? WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true)
|
||||
: const PlatformWebViewControllerCreationParams();
|
||||
_web = WebViewController.fromPlatformCreationParams(params)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
|
||||
..loadHtmlString(
|
||||
YoutubeEmbed.html(widget.videoId),
|
||||
baseUrl: YoutubeEmbed.appOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
void _onMessage(JavaScriptMessage m) {
|
||||
final msg = jsonDecode(m.message) as Map<String, dynamic>;
|
||||
if (msg.containsKey('err')) {
|
||||
if (mounted) setState(() => _error = true);
|
||||
return;
|
||||
}
|
||||
final s = msg['s'];
|
||||
final t = msg['t'];
|
||||
widget.onEvent((
|
||||
playing: s == 1,
|
||||
position: Duration(microseconds: ((t is num ? t : 0) * 1e6).round()),
|
||||
ended: s == 0,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final web = _web;
|
||||
if (web == null || _error) {
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_error ? context.l10n.shakeVideoError : '',
|
||||
style: const TextStyle(color: AppColors.onCosmicVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return WebViewWidget(controller: web);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user