index.tsx 12 KB

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