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