Files
fanuzu/app/lib/features/quest/music_shake_play_screen.dart
T

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);
}
}