index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import { Audio, AVPlaybackStatus, ResizeMode, Video } from "expo-av";
  2. import { Image } from "expo-image";
  3. import { Stack, useLocalSearchParams, useRouter } from "expo-router";
  4. import React, { useEffect, useRef, useState } from "react";
  5. import {
  6. Dimensions,
  7. StyleSheet,
  8. Text,
  9. TouchableOpacity,
  10. View,
  11. } from "react-native";
  12. import { useSafeAreaInsets } from "react-native-safe-area-context";
  13. import LotteryGrid from "./components/LotteryGrid";
  14. import LotteryReel from "./components/LotteryReel";
  15. import { Images } from "@/constants/images";
  16. import services from "@/services/api";
  17. const { width: SCREEN_WIDTH } = Dimensions.get("window");
  18. // Sound URL from Uniapp config
  19. const SOUND_URL =
  20. "https://cdn.acefig.com/kai_xin_ma_te/resource/magic/lottery.mp3";
  21. const DEFAULT_JACKPOT_VIDEO =
  22. "https://cdn.acefig.com/kai_xin_ma_te/supermart/box/lottery/jackpot.mp4";
  23. export default function LotteryScreen() {
  24. const router = useRouter();
  25. const params = useLocalSearchParams();
  26. const insets = useSafeAreaInsets();
  27. const num = Number(params.num) || 1;
  28. const isGrid = num >= 10;
  29. const tradeNo = params.tradeNo as string;
  30. const poolId = params.poolId as string;
  31. const prefetchedResults = params.prefetchedResults as string | undefined;
  32. const [results, setResults] = useState<any[]>([]);
  33. const [pool, setPool] = useState<any[]>([]);
  34. const [loading, setLoading] = useState(true);
  35. const [sound, setSound] = useState<Audio.Sound>();
  36. const [animationFinished, setAnimationFinished] = useState(false);
  37. const isFinishedRef = useRef(false);
  38. // Video state
  39. const [videoVisible, setVideoVisible] = useState(false);
  40. const [videoUrl, setVideoUrl] = useState("");
  41. const videoRef = useRef<Video>(null);
  42. // Timer Ref for cleanup
  43. const timerRef = useRef<any>(null);
  44. // Layout calculations
  45. const singleItemWidth = 240;
  46. const itemWidth = num === 1 ? singleItemWidth : 72;
  47. const maskWidth = (SCREEN_WIDTH - (num === 1 ? singleItemWidth : 72)) / 2;
  48. const padding = SCREEN_WIDTH > 375 ? 32 : 0;
  49. // Dynamic styles
  50. const singleReelHeight = singleItemWidth * 1.35 + 20; // 324 + 20
  51. useEffect(() => {
  52. init();
  53. return () => {
  54. sound?.unloadAsync();
  55. if (timerRef.current) clearTimeout(timerRef.current);
  56. };
  57. }, []);
  58. // ... (rest of the file)
  59. // Update render styles inline or via StyleSheet
  60. // I will update the stylesheet usage in return and the StyleSheet definition below
  61. const init = async () => {
  62. try {
  63. console.log(
  64. "LotteryScreen Init: num",
  65. num,
  66. "tradeNo",
  67. tradeNo,
  68. "poolId",
  69. poolId,
  70. );
  71. await Promise.all([loadData() /*, playSound()*/]);
  72. } catch (e) {
  73. console.error("LotteryScreen Init Error", e);
  74. } finally {
  75. setLoading(false);
  76. }
  77. };
  78. const playSound = async () => {
  79. try {
  80. const { sound } = await Audio.Sound.createAsync({ uri: SOUND_URL });
  81. setSound(sound);
  82. await sound.playAsync();
  83. } catch (error) {
  84. console.log("Error playing sound", error);
  85. }
  86. };
  87. const loadData = async () => {
  88. if (!tradeNo) return;
  89. try {
  90. // 优先使用预取数据,避免重复网络请求
  91. let list: any[] = [];
  92. let videoFromRes = "";
  93. if (prefetchedResults) {
  94. try {
  95. list = JSON.parse(prefetchedResults);
  96. } catch (e) {
  97. console.warn(
  98. "Failed to parse prefetchedResults, falling back to API",
  99. );
  100. }
  101. }
  102. if (list.length === 0) {
  103. // fallback: 没有预取数据时才调用 API
  104. const res: any = await services.award.getApplyResult(tradeNo);
  105. list = res?.data?.inventoryList || res?.inventoryList || [];
  106. videoFromRes = res?.video || "";
  107. }
  108. setResults(list);
  109. // Pool 数据不阻塞动画,并行加载
  110. if (poolId && !isGrid) {
  111. services.award
  112. .getPoolDetail(poolId)
  113. .then((detailRes: any) => {
  114. const goods = detailRes?.luckGoodsList || [];
  115. setPool(goods);
  116. })
  117. .catch(() => setPool(list));
  118. } else if (!isGrid) {
  119. setPool(list);
  120. }
  121. // Auto show result after animation
  122. if (timerRef.current) clearTimeout(timerRef.current);
  123. // Check for Level A video
  124. const hasLevelA = list.some((item: any) =>
  125. ["A", "a"].includes(item.level),
  126. );
  127. const playVideoUrl =
  128. videoFromRes || (hasLevelA ? DEFAULT_JACKPOT_VIDEO : "");
  129. if (playVideoUrl) {
  130. console.log("Found Level A, preparing video:", playVideoUrl);
  131. setVideoUrl(playVideoUrl);
  132. setVideoVisible(true);
  133. } else {
  134. if (!isGrid) {
  135. timerRef.current = setTimeout(() => {
  136. handleFinish(list);
  137. }, 2800);
  138. }
  139. }
  140. } catch (error) {
  141. console.error("Load Data Error", error);
  142. }
  143. };
  144. const handleFinish = (finalResults?: any[]) => {
  145. if (timerRef.current) {
  146. clearTimeout(timerRef.current);
  147. timerRef.current = null;
  148. }
  149. if (isFinishedRef.current) return;
  150. isFinishedRef.current = true;
  151. if (isGrid) {
  152. // Stop sound and mark finished, stay on page
  153. sound?.stopAsync();
  154. setAnimationFinished(true);
  155. return;
  156. }
  157. // Navigate to result modal/screen
  158. const data = finalResults || results;
  159. // We can navigate to a result page or show a local modal components
  160. // Assuming we have a result route
  161. router.replace({
  162. pathname: "/happy-spin/result" as any,
  163. params: {
  164. results: JSON.stringify(data),
  165. poolId,
  166. },
  167. });
  168. };
  169. const handleSkip = () => {
  170. if (videoVisible) {
  171. setVideoVisible(false);
  172. handleFinish();
  173. return;
  174. }
  175. if (isGrid) {
  176. if (animationFinished) {
  177. // User clicked "Claim Prize", exit
  178. router.back();
  179. } else {
  180. // User clicked "Skip Animation", finish immediately
  181. // Ideally LotteryGrid should expose a "finish" method or we force state
  182. // But for now calling handleFinish stops navigation
  183. handleFinish();
  184. // Note: This doesn't force cards to flip instantly unless we implement that prop in LotteryGrid
  185. }
  186. } else {
  187. sound?.stopAsync();
  188. handleFinish();
  189. }
  190. };
  191. if (loading)
  192. return (
  193. <View style={styles.container}>
  194. <Image
  195. source={{ uri: Images.mine.kaixinMineBg }}
  196. style={styles.bg}
  197. contentFit="cover"
  198. />
  199. </View>
  200. );
  201. return (
  202. <View style={styles.container}>
  203. <Stack.Screen options={{ headerShown: false }} />
  204. <Image
  205. source={{ uri: Images.mine.kaixinMineBg }}
  206. style={styles.bg}
  207. contentFit="cover"
  208. />
  209. <View style={styles.maskPage} />
  210. <View style={[styles.wrapper, { paddingTop: padding }]}>
  211. {isGrid ? (
  212. <LotteryGrid results={results} onFinish={() => handleFinish()} />
  213. ) : (
  214. <View style={styles.reelsContainer}>
  215. {/* Left Column Masks */}
  216. <View style={[styles.maskLeft, { width: maskWidth }]} />
  217. {/* Reels */}
  218. <View style={num === 1 ? styles.height1 : styles.height5}>
  219. {results.map((item, index) => (
  220. <View
  221. key={index}
  222. style={[
  223. styles.reelRow,
  224. num === 1 && { height: singleReelHeight, marginBottom: 0 },
  225. ]}
  226. >
  227. <LotteryReel
  228. key={`reel-${index}-${pool.length}-${results.length}`}
  229. pool={pool.length > 0 ? pool : results}
  230. result={item}
  231. width={SCREEN_WIDTH}
  232. itemWidth={itemWidth}
  233. index={index}
  234. delay={index * 30} // Very fast stagger
  235. duration={2000} // Faster spin (was 3000)
  236. />
  237. </View>
  238. ))}
  239. </View>
  240. {/* Right Column Masks */}
  241. <View style={[styles.maskRight, { width: maskWidth }]} />
  242. {/* Middle Frame */}
  243. <Image
  244. source={{
  245. uri:
  246. num === 1
  247. ? Images.resource.lottery_middle_s
  248. : Images.resource.lottery_middle_l,
  249. }}
  250. style={[
  251. styles.middleImage,
  252. {
  253. height: num === 1 ? 360 : 540,
  254. top: num === 1 ? -20 : -10,
  255. },
  256. ]}
  257. contentFit="contain"
  258. />
  259. </View>
  260. )}
  261. </View>
  262. {/* Video Overlay */}
  263. {videoVisible && videoUrl ? (
  264. <View style={styles.videoContainer}>
  265. <Video
  266. ref={videoRef}
  267. key={videoUrl}
  268. source={{ uri: videoUrl }}
  269. style={styles.video}
  270. resizeMode={ResizeMode.COVER}
  271. shouldPlay
  272. isLooping={false}
  273. useNativeControls={false}
  274. onLoad={() => {
  275. videoRef.current?.playAsync();
  276. }}
  277. onPlaybackStatusUpdate={(status: AVPlaybackStatus) => {
  278. if (status.isLoaded && status.didJustFinish) {
  279. setVideoVisible(false);
  280. handleFinish();
  281. }
  282. }}
  283. onError={() => {
  284. setVideoVisible(false);
  285. handleFinish();
  286. }}
  287. />
  288. </View>
  289. ) : null}
  290. <View style={styles.bottom}>
  291. <TouchableOpacity style={styles.skipBtn} onPress={handleSkip}>
  292. <Text style={styles.skipText}>
  293. {videoVisible
  294. ? "跳过动画"
  295. : isGrid && animationFinished
  296. ? "收下奖品"
  297. : "跳过动画"}
  298. </Text>
  299. </TouchableOpacity>
  300. </View>
  301. </View>
  302. );
  303. }
  304. const styles = StyleSheet.create({
  305. container: {
  306. flex: 1,
  307. backgroundColor: "#222335",
  308. },
  309. bg: {
  310. position: "absolute",
  311. width: "100%",
  312. height: "100%",
  313. zIndex: -1,
  314. },
  315. maskPage: {
  316. ...StyleSheet.absoluteFillObject,
  317. backgroundColor: "rgba(0,0,0,0.4)",
  318. zIndex: 0,
  319. },
  320. wrapper: {
  321. flex: 1,
  322. // justifyContent: 'center',
  323. alignItems: "center",
  324. marginTop: 0, // Remove fixed margin, let flex center it or use justify center if needed
  325. justifyContent: "center", // Center vertically
  326. },
  327. reelsContainer: {
  328. position: "relative",
  329. alignItems: "center",
  330. justifyContent: "center",
  331. // backgroundColor: 'rgba(255,0,0,0.1)', // Debug
  332. },
  333. reelRow: {
  334. overflow: "hidden",
  335. width: SCREEN_WIDTH,
  336. alignItems: "center",
  337. justifyContent: "center",
  338. marginBottom: 12, // Gap between rows
  339. height: 94, // Height of one row (card height + tiny padding)
  340. },
  341. height1: {
  342. height: 360, // Accommodate larger card (approx 325px height)
  343. justifyContent: "center",
  344. },
  345. height5: {
  346. height: 540, // 5 rows * (~100)
  347. justifyContent: "center",
  348. paddingVertical: 10,
  349. },
  350. maskLeft: {
  351. position: "absolute",
  352. left: 0,
  353. top: 0,
  354. bottom: 0,
  355. backgroundColor: "rgba(0,0,0,0.5)",
  356. zIndex: 10,
  357. },
  358. maskRight: {
  359. position: "absolute",
  360. right: 0,
  361. top: 0,
  362. bottom: 0,
  363. backgroundColor: "rgba(0,0,0,0.5)",
  364. zIndex: 10,
  365. },
  366. middleImage: {
  367. position: "absolute",
  368. width: SCREEN_WIDTH,
  369. zIndex: 20,
  370. left: 0,
  371. // The image frame should be centered over the reels
  372. // top is handled dynamically or via flex centering in parent if possible
  373. },
  374. bottom: {
  375. position: "absolute",
  376. bottom: 50,
  377. width: "100%",
  378. alignItems: "center",
  379. zIndex: 60,
  380. },
  381. skipBtn: {
  382. backgroundColor: "rgba(255,255,255,0.2)",
  383. paddingHorizontal: 30,
  384. paddingVertical: 10,
  385. borderRadius: 32,
  386. borderWidth: 1,
  387. borderColor: "rgba(255,255,255,0.4)",
  388. },
  389. skipText: {
  390. color: "#fff",
  391. fontSize: 14,
  392. },
  393. videoContainer: {
  394. ...StyleSheet.absoluteFillObject,
  395. backgroundColor: "#000",
  396. zIndex: 50,
  397. },
  398. video: {
  399. width: "100%",
  400. height: "100%",
  401. },
  402. });