388 lines
13 KiB
Dart
388 lines
13 KiB
Dart
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_android/webview_flutter_android.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 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을
|
|
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83).
|
|
///
|
|
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
|
|
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
|
|
class MusicShakePlayScreen extends StatefulWidget {
|
|
const MusicShakePlayScreen({
|
|
super.key,
|
|
required this.setup,
|
|
required this.shakes,
|
|
this.player,
|
|
this.judge,
|
|
});
|
|
|
|
final MusicShakeSetup setup;
|
|
|
|
/// 이 판의 판정표. 넘기지 않으면 새 판이다. 「이어하기」는 멈춘 판의 판정표를 넘긴다.
|
|
final ShakeJudge? judge;
|
|
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 =
|
|
widget.judge ??
|
|
ShakeJudge(
|
|
bpm: widget.setup.bpm,
|
|
segments: widget.setup.segments,
|
|
windows: widget.setup.windows,
|
|
);
|
|
StreamSubscription<DateTime>? _sub;
|
|
Timer? _tick;
|
|
|
|
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
|
|
bool _playing = false;
|
|
late Duration _anchorMedia = _judge.position;
|
|
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;
|
|
_judge.position = 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,
|
|
start: _judge.position,
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
this.start = Duration.zero,
|
|
});
|
|
|
|
final String videoId;
|
|
|
|
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
|
|
final Duration start;
|
|
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
|
|
/// [resumeAt] 이 있으면 「이어하기」 — 플레이어가 준비되면 그 자리(밀리초까지)로 옮겨 바로 튼다.
|
|
/// 처음 시작은 지금처럼 사용자가 재생 버튼을 누른다.
|
|
static String html(String videoId, {int startSeconds = 0, Duration? resumeAt}) =>
|
|
'''
|
|
<!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,start:$startSeconds,origin:'$appOrigin'},
|
|
events:{onReady:function(){${resumeAt == null ? '' : 'player.seekTo(${resumeAt.inMilliseconds / 1000},true);player.playVideo();'}},
|
|
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,
|
|
// 「이어하기」 자동 재생 — 사용자 탭 없이 재생을 허용한다.
|
|
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
|
)
|
|
: const PlatformWebViewControllerCreationParams();
|
|
final web = _web = WebViewController.fromPlatformCreationParams(params)
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..setBackgroundColor(Colors.black)
|
|
..addJavaScriptChannel('Player', onMessageReceived: _onMessage);
|
|
// Android WebView 는 기본으로 탭 없는 재생을 막는다 — 이어하기 자동 재생을 위해 푼다.
|
|
// 불러오기 전에 설정해야 첫 문서에 적용된다.
|
|
if (web.platform case final AndroidWebViewController android) {
|
|
unawaited(android.setMediaPlaybackRequiresUserGesture(false));
|
|
}
|
|
unawaited(
|
|
web.loadHtmlString(
|
|
YoutubeEmbed.html(
|
|
widget.videoId,
|
|
startSeconds: widget.start.inSeconds,
|
|
// 이어하기(멈춘 자리가 있음)면 저장 위치로 옮겨 자동 재생한다(10/6 실기기 A-2).
|
|
resumeAt: widget.start > Duration.zero ? widget.start : null,
|
|
),
|
|
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);
|
|
}
|
|
}
|