diff --git a/app/_layout.tsx b/app/_layout.tsx index 700de07..c56534a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -42,6 +42,7 @@ export default function RootLayout() { {Platform.OS !== "web" && } + diff --git a/app/index.tsx b/app/index.tsx index 305f5dc..661e5dc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -117,7 +117,14 @@ export default function HomeScreen() { {/* 顶部导航 */} - 首页 + + 首页 + router.push("/live")}> + {({ focused }) => ( + 直播 + )} + + ([]); + const [groupedChannels, setGroupedChannels] = useState>({}); + const [channelGroups, setChannelGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(""); + + const [currentChannelIndex, setCurrentChannelIndex] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isPlayerLoading, setIsPlayerLoading] = useState(true); + const [isChannelListVisible, setIsChannelListVisible] = useState(false); + const [channelTitle, setChannelTitle] = useState(null); + const titleTimer = useRef(null); + + const selectedChannelUrl = channels.length > 0 ? getPlayableUrl(channels[currentChannelIndex].url) : null; + + useEffect(() => { + const loadChannels = async () => { + setIsLoading(true); + const parsedChannels = await fetchAndParseM3u(M3U_URL); + setChannels(parsedChannels); + + const groups: Record = parsedChannels.reduce((acc, channel) => { + const groupName = channel.group || "Other"; + if (!acc[groupName]) { + acc[groupName] = []; + } + acc[groupName].push(channel); + return acc; + }, {} as Record); + + const groupNames = Object.keys(groups); + setGroupedChannels(groups); + setChannelGroups(groupNames); + setSelectedGroup(groupNames[0] || ""); + + if (parsedChannels.length > 0) { + showChannelTitle(parsedChannels[0].name); + } + setIsLoading(false); + }; + loadChannels(); + }, []); + + const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => { + if (status.isLoaded) { + setIsPlayerLoading(!status.isPlaying && !status.isBuffering); + } else { + setIsPlayerLoading(true); + } + }; + + const showChannelTitle = (title: string) => { + setChannelTitle(title); + if (titleTimer.current) clearTimeout(titleTimer.current); + titleTimer.current = setTimeout(() => setChannelTitle(null), 3000); + }; + + const handleSelectChannel = (channel: Channel) => { + const globalIndex = channels.findIndex((c) => c.id === channel.id); + if (globalIndex !== -1) { + setCurrentChannelIndex(globalIndex); + showChannelTitle(channel.name); + setIsChannelListVisible(false); + } + }; + + const changeChannel = (direction: "next" | "prev") => { + if (channels.length === 0) return; + let newIndex = + direction === "next" + ? (currentChannelIndex + 1) % channels.length + : (currentChannelIndex - 1 + channels.length) % channels.length; + setCurrentChannelIndex(newIndex); + showChannelTitle(channels[newIndex].name); + }; + + const handleTVEvent = useCallback( + (event: HWEvent) => { + if (isChannelListVisible) return; + if (event.eventType === "down") setIsChannelListVisible(true); + else if (event.eventType === "left") changeChannel("prev"); + else if (event.eventType === "right") changeChannel("next"); + }, + [channels, currentChannelIndex, isChannelListVisible] + ); + + useTVEventHandler(handleTVEvent); + + return ( + + + {isPlayerLoading && ( + + + + )} + setIsChannelListVisible(false)} + > + + + 选择频道 + + + item} + renderItem={({ item }) => ( + setSelectedGroup(item)} + isSelected={selectedGroup === item} + style={styles.groupButton} + textStyle={styles.groupButtonText} + /> + )} + /> + + + {isLoading ? ( + + ) : ( + item.id} + renderItem={({ item }) => ( + handleSelectChannel(item)} + isSelected={channels[currentChannelIndex]?.id === item.id} + hasTVPreferredFocus={channels[currentChannelIndex]?.id === item.id} + style={styles.channelItem} + textStyle={styles.channelItemText} + /> + )} + /> + )} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + }, + modalContainer: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + backgroundColor: "transparent", + }, + modalContent: { + width: 450, + height: "100%", + backgroundColor: "rgba(0, 0, 0, 0.85)", + padding: 15, + }, + modalTitle: { + color: "white", + marginBottom: 10, + textAlign: "center", + fontSize: 16, + fontWeight: "bold", + }, + listContainer: { + flex: 1, + flexDirection: "row", + }, + groupColumn: { + flex: 1, + marginRight: 10, + }, + channelColumn: { + flex: 2, + }, + groupButton: { + paddingVertical: 8, + paddingHorizontal: 4, + marginVertical: 4, + }, + groupButtonText: { + fontSize: 13, + }, + channelItem: { + paddingVertical: 6, + paddingHorizontal: 4, + marginVertical: 3, + }, + channelItemText: { + fontSize: 12, + }, +}); diff --git a/app/search.tsx b/app/search.tsx index fb4e986..520dd38 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { View, TextInput, @@ -28,20 +28,20 @@ export default function SearchScreen() { const [isInputFocused, setIsInputFocused] = useState(false); const [isListening, setIsListening] = useState(false); - const onSpeechResults = (e: any) => { + const onSpeechResults = useCallback((e: any) => { if (e.value && e.value.length > 0) { setKeyword(e.value[0]); } - }; + }, []); - const onSpeechEnd = () => { + const onSpeechEnd = useCallback(() => { setIsListening(false); - }; + }, []); - const onSpeechError = (e: any) => { + const onSpeechError = useCallback((e: any) => { console.error(e); setIsListening(false); - }; + }, []); useEffect(() => { Voice.onSpeechResults = onSpeechResults; @@ -49,9 +49,9 @@ export default function SearchScreen() { Voice.onSpeechError = onSpeechError; return () => { - Voice.destroy().then(Voice.removeAllListeners); + Voice.destroy().then(() => Voice.removeAllListeners()); }; - }, []); + }, [onSpeechResults, onSpeechEnd, onSpeechError]); useEffect(() => { // Focus the text input when the screen loads diff --git a/components/LivePlayer.tsx b/components/LivePlayer.tsx new file mode 100644 index 0000000..0ed4aa7 --- /dev/null +++ b/components/LivePlayer.tsx @@ -0,0 +1,66 @@ +import React, { useRef } from "react"; +import { View, StyleSheet, Text, ActivityIndicator } from "react-native"; +import { Video, ResizeMode, AVPlaybackStatus } from "expo-av"; + +interface LivePlayerProps { + streamUrl: string | null; + channelTitle?: string | null; + onPlaybackStatusUpdate: (status: AVPlaybackStatus) => void; +} + +export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUpdate }: LivePlayerProps) { + const video = useRef