걸음 카드 보상 칸 · Music Shake 이어하기 재생

This commit is contained in:
Sn-Kinos
2026-10-06 04:15:28 +09:00
parent 99b9bb8665
commit 976e569a56
9 changed files with 94 additions and 13 deletions
@@ -6,6 +6,7 @@ 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';
@@ -292,7 +293,9 @@ class YoutubeEmbed extends StatefulWidget {
!Platform.environment.containsKey('FLUTTER_TEST');
@visibleForTesting
static String html(String videoId, {int startSeconds = 0}) =>
/// [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>
@@ -302,7 +305,8 @@ 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:{onStateChange:function(e){post(e.data);},
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);
}
@@ -321,16 +325,32 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
super.initState();
if (!YoutubeEmbed.enabled) return;
final params = WebViewPlatform.instance is WebKitWebViewPlatform
? WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true)
? WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
// 「이어하기」 자동 재생 — 사용자 탭 없이 재생을 허용한다.
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
)
: const PlatformWebViewControllerCreationParams();
_web = WebViewController.fromPlatformCreationParams(params)
final web = _web = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel('Player', onMessageReceived: _onMessage)
..loadHtmlString(
YoutubeEmbed.html(widget.videoId, startSeconds: widget.start.inSeconds),
..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) {
+17 -1
View File
@@ -132,9 +132,25 @@ enum QuestType {
/// 출석은 `fn-quest-attend` 가 `config/policy.attendPop` 을 지급하고
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
///
/// 운영 걸음 Quest 는 `fn-quest-steps` 가 `config/policy.stepTiers` 구간표(원장 B-26 —
/// 1 천/5 천/1 만/1.5 만보 = 5/6/7/8)대로 지급하고 `quests.rewardPop` 을 보지 않는다. 카드의
/// 목표 칸이 오늘 다음 구간(`[cleared]` 번째)이므로 보상 칸도 **그 구간의 지급액**이다.
int? rewardFrom(
Map<String, dynamic> quest,
Map<String, dynamic> policy, {
int cleared = 0,
}) {
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
if (this == QuestType.streaming) return 0;
if (this == QuestType.steps && quest['authorUid'] is! String) {
final tiers = [
for (final row in policy['stepTiers'] is List ? policy['stepTiers'] as List : const [])
if (row is Map && row['steps'] is num && row['pop'] is num)
(steps: row['steps'] as num, pop: (row['pop'] as num).round()),
]..sort((a, b) => a.steps.compareTo(b.steps));
if (tiers.isNotEmpty) return tiers[cleared.clamp(0, tiers.length - 1)].pop;
}
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
@@ -271,7 +271,8 @@ class FirestoreQuestRepository implements QuestRepository {
goal: cardGoal,
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
progress: type.gathers ? (done ? goal : walked ?? 0) : count,
rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy),
rewardPop: openTimeAttack(quest)?.rewardPop ??
type.rewardFrom(quest, policy, cleared: count),
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
participants: quest.intOr('participantCount'),
// 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다.
@@ -211,7 +211,10 @@ class QuestCardTile extends StatelessWidget {
? '-'
: formatPop(card.rewardPop!),
unit: 'POP',
muted: preview && (card.rewardPop ?? 0) <= 0,
// 운영값이 아직 없는 보상(Music Shake — Q-MS-05)도 미리보기(`254:164081`)의
// 빈 값처럼 회색 「-」다. 숫자를 지어내지 않는다.
muted: card.rewardPop == null ||
(preview && card.rewardPop! <= 0),
),
),
],