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 shakes; /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. final Widget Function(ValueChanged onEvent)? player; @override State createState() => _MusicShakePlayScreenState(); } class _MusicShakePlayScreenState extends State { late final ShakeJudge _judge = widget.judge ?? ShakeJudge( bpm: widget.setup.bpm, segments: widget.setup.segments, windows: widget.setup.windows, ); StreamSubscription? _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 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 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}) => '''
'''; @override State createState() => _YoutubeEmbedState(); } class _YoutubeEmbedState extends State { 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 {}, ) : 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; 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); } }