index.tsx 12 KB

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