From bd7087264d84b6a4475fff8fef379766bfa1227d Mon Sep 17 00:00:00 2001 From: zimplexing Date: Fri, 18 Jul 2025 17:15:24 +0800 Subject: [PATCH] feat: add VideoLoadingAnimation component and integrate it into detail, play, and search screens for improved loading experience --- app/detail.tsx | 7 +- app/play.tsx | 16 +- app/search.tsx | 7 +- components/VideoLoadingAnimation.tsx | 335 +++++++++++++++++++++++++++ package.json | 1 + yarn.lock | 36 ++- 6 files changed, 382 insertions(+), 20 deletions(-) create mode 100644 components/VideoLoadingAnimation.tsx diff --git a/app/detail.tsx b/app/detail.tsx index bcc6382..ddae6bc 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -4,6 +4,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; import { StyledButton } from "@/components/StyledButton"; +import VideoLoadingAnimation from "@/components/VideoLoadingAnimation"; import useDetailStore from "@/stores/detailStore"; import { FontAwesome } from "@expo/vector-icons"; @@ -49,11 +50,7 @@ export default function DetailScreen() { }; if (loading) { - return ( - - - - ); + return ; } if (error) { diff --git a/app/play.tsx b/app/play.tsx index 3a9faf7..b6de405 100644 --- a/app/play.tsx +++ b/app/play.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from "react"; -import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler, AppState, AppStateStatus } from "react-native"; +import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Video, ResizeMode } from "expo-av"; import { useKeepAwake } from "expo-keep-awake"; @@ -9,7 +9,7 @@ import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal"; import { SourceSelectionModal } from "@/components/SourceSelectionModal"; import { SeekingBar } from "@/components/SeekingBar"; import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; -import { LoadingOverlay } from "@/components/LoadingOverlay"; +import VideoLoadingAnimation from "@/components/VideoLoadingAnimation"; import useDetailStore from "@/stores/detailStore"; import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler"; import Toast from "react-native-toast-message"; @@ -116,11 +116,7 @@ export default function PlayScreen() { }, [isLoading]); if (!detail) { - return ( - - - - ); + return ; } return ( @@ -150,7 +146,11 @@ export default function PlayScreen() { - + {isLoading && ( + + + + )} setShowNextEpisodeOverlay(false)} /> diff --git a/app/search.tsx b/app/search.tsx index 9672b70..8c9c54c 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -1,8 +1,9 @@ import React, { useState, useRef, useEffect } from "react"; -import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Alert, Keyboard } from "react-native"; +import { View, TextInput, StyleSheet, FlatList, Alert, Keyboard } from "react-native"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; import VideoCard from "@/components/VideoCard.tv"; +import VideoLoadingAnimation from "@/components/VideoLoadingAnimation"; import { api, SearchResult } from "@/services/api"; import { Search, QrCode } from "lucide-react-native"; import { StyledButton } from "@/components/StyledButton"; @@ -121,9 +122,7 @@ export default function SearchScreen() { {loading ? ( - - - + ) : error ? ( {error} diff --git a/components/VideoLoadingAnimation.tsx b/components/VideoLoadingAnimation.tsx new file mode 100644 index 0000000..8687fc5 --- /dev/null +++ b/components/VideoLoadingAnimation.tsx @@ -0,0 +1,335 @@ +import React, { useEffect, useRef } from "react"; +import { View, StyleSheet, Animated, Easing } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; + +interface VideoLoadingAnimationProps { + showProgressBar?: boolean; +} + +const VideoLoadingAnimation: React.FC = ({ showProgressBar = true }) => { + const floatAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(0)).current; + const bounceAnims = [ + useRef(new Animated.Value(0)).current, + useRef(new Animated.Value(0)).current, + useRef(new Animated.Value(0)).current, + ]; + const progressAnim = useRef(new Animated.Value(0)).current; + const gradientAnim = useRef(new Animated.Value(0)).current; + const textFadeAnim = useRef(new Animated.Value(0)).current; + const shapeAnims = [ + useRef(new Animated.Value(0)).current, + useRef(new Animated.Value(0)).current, + useRef(new Animated.Value(0)).current, + useRef(new Animated.Value(0)).current, + ]; + + useEffect(() => { + const floatAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(floatAnim, { + toValue: -20, + duration: 1500, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(floatAnim, { + toValue: 0, + duration: 1500, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ); + + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(pulseAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ); + + const bounceAnimations = bounceAnims.map((anim, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(anim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(anim, { + toValue: 0, + duration: 700, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ) + ); + + const progressAnimation = Animated.loop( + Animated.timing(progressAnim, { + toValue: 1, + duration: 4000, + useNativeDriver: false, // width animation not supported by native driver + easing: Easing.inOut(Easing.ease), + }) + ); + + const gradientAnimation = Animated.loop( + Animated.timing(gradientAnim, { + toValue: 1, + duration: 2000, + useNativeDriver: false, // gradient animation not supported by native driver + easing: Easing.inOut(Easing.ease), + }) + ); + + const textFadeAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(textFadeAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(textFadeAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ); + + const shapeAnimations = shapeAnims.map((anim, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 2000), + Animated.timing(anim, { + toValue: 1, + duration: 8000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ) + ); + + Animated.parallel([ + floatAnimation, + pulseAnimation, + ...bounceAnimations, + progressAnimation, + gradientAnimation, + textFadeAnimation, + ...shapeAnimations, + ]).start(); + }, []); + + const animatedStyles = { + float: { + transform: [{ translateY: floatAnim }], + }, + pulse: { + opacity: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] }), + transform: [ + { translateX: -12.5 }, + { translateY: -15 }, + { + scale: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 1.1] }), + }, + ], + }, + bounce: bounceAnims.map((anim) => ({ + transform: [{ scale: anim.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.2] }) }], + opacity: anim.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] }), + })), + progress: { + width: progressAnim.interpolate({ + inputRange: [0, 0.7, 1], + outputRange: ["0%", "100%", "100%"], + }), + }, + textFade: { + opacity: textFadeAnim.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }), + }, + shapes: shapeAnims.map((anim, i) => ({ + transform: [ + { + translateY: anim.interpolate({ + inputRange: [0, 0.33, 0.66, 1], + outputRange: [0, -30, 10, 0], + }), + }, + { + rotate: anim.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }), + }, + ], + })), + }; + + return ( + + + + + + + + + + + + + + + + + + + + + {showProgressBar && ( + + + + + + )} + + 正在加载视频 + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#2d3748", + overflow: "hidden", + }, + loadingContainer: { + alignItems: "center", + zIndex: 10, + }, + videoIcon: { + width: 120, + height: 120, + marginBottom: 30, + }, + videoFrame: { + width: "100%", + height: "100%", + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 3, + borderColor: "rgba(255, 255, 255, 0.2)", + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + }, + playButton: { + width: 0, + height: 0, + borderStyle: "solid", + borderLeftWidth: 25, + borderLeftColor: "rgba(255, 255, 255, 0.9)", + borderTopWidth: 15, + borderTopColor: "transparent", + borderBottomWidth: 15, + borderBottomColor: "transparent", + }, + loadingDots: { + flexDirection: "row", + justifyContent: "center", + gap: 8, + marginBottom: 20, + }, + dot: { + width: 12, + height: 12, + backgroundColor: "rgba(255, 255, 255, 0.9)", + borderRadius: 6, + }, + progressBar: { + width: 300, + height: 6, + backgroundColor: "rgba(255, 255, 255, 0.1)", + borderRadius: 3, + marginVertical: 20, + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 3, + }, + loadingText: { + color: "rgba(255, 255, 255, 0.9)", + fontSize: 18, + fontWeight: "300", + letterSpacing: 2, + marginTop: 10, + }, + bgShapes: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + zIndex: 1, + }, + shape: { + position: "absolute", + backgroundColor: "rgba(255, 255, 255, 0.03)", + borderRadius: 50, + }, + shape1: { + width: 80, + height: 80, + top: "20%", + left: "10%", + }, + shape2: { + width: 60, + height: 60, + top: "60%", + right: "15%", + }, + shape3: { + width: 100, + height: 100, + bottom: "20%", + left: "20%", + }, + shape4: { + width: 40, + height: 40, + top: "30%", + right: "30%", + }, +}); + +export default VideoLoadingAnimation; diff --git a/package.json b/package.json index d70b157..a2dc3a0 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "expo-build-properties": "~0.12.3", "expo-constants": "~16.0.2", "expo-font": "~12.0.7", + "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-router": "~3.5.16", "expo-splash-screen": "~0.27.5", diff --git a/yarn.lock b/yarn.lock index 84be445..831db06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4587,6 +4587,11 @@ expo-keep-awake@~13.0.2: resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e" integrity sha512-kKiwkVg/bY0AJ5q1Pxnm/GvpeB6hbNJhcFsoOWDh2NlpibhCLaHL826KHUM+WsnJRbVRxJ+K9vbPRHEMvFpVyw== +expo-linear-gradient@~13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-13.0.2.tgz#21bd7bc7c71ef4f7c089521daa16db729d2aec5f" + integrity sha512-EDcILUjRKu4P1rtWcwciN6CSyGtH7Bq4ll3oTRV7h3h8oSzSilH1g6z7kTAMlacPBKvMnkkWOGzW6KtgMKEiTg== + expo-linking@~6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-6.3.1.tgz#05aef8a42bd310391d0b00644be40d80ece038d9" @@ -8804,7 +8809,16 @@ string-length@^5.0.1: char-regex "^2.0.0" strip-ansi "^7.0.1" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8895,7 +8909,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8909,6 +8923,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9740,7 +9761,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9758,6 +9779,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"