Add Live functionality with LiveScreen and LivePlayer components; enhance SearchScreen with optimized speech handling

This commit is contained in:
zimplexing
2025-07-10 16:45:54 +08:00
parent 8000cde907
commit 9b242497d0
6 changed files with 374 additions and 10 deletions

View File

@@ -42,6 +42,7 @@ export default function RootLayout() {
<Stack.Screen name="detail" options={{ headerShown: false }} />
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<Toast />

View File

@@ -117,7 +117,14 @@ export default function HomeScreen() {
<ThemedView style={styles.container}>
{/* 顶部导航 */}
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={styles.headerTitle}></ThemedText>
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
{({ focused }) => (
<ThemedText style={[styles.headerTitle, { color: focused ? "white" : "grey" }]}></ThemedText>
)}
</Pressable>
</View>
<View style={styles.rightHeaderButtons}>
<StyledButton
style={styles.searchButton}

220
app/live.tsx Normal file
View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { View, FlatList, StyleSheet, ActivityIndicator, Modal, useTVEventHandler, HWEvent, Text } from "react-native";
import LivePlayer from "@/components/LivePlayer";
import { fetchAndParseM3u, getPlayableUrl, Channel } from "@/services/m3u";
import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { AVPlaybackStatus } from "expo-av";
const M3U_URL = "https://raw.githubusercontent.com/fanmingming/live/refs/heads/main/tv/m3u/ipv6.m3u";
export default function LiveScreen() {
const [channels, setChannels] = useState<Channel[]>([]);
const [groupedChannels, setGroupedChannels] = useState<Record<string, Channel[]>>({});
const [channelGroups, setChannelGroups] = useState<string[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string>("");
const [currentChannelIndex, setCurrentChannelIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isPlayerLoading, setIsPlayerLoading] = useState(true);
const [isChannelListVisible, setIsChannelListVisible] = useState(false);
const [channelTitle, setChannelTitle] = useState<string | null>(null);
const titleTimer = useRef<NodeJS.Timeout | null>(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<string, Channel[]> = parsedChannels.reduce((acc, channel) => {
const groupName = channel.group || "Other";
if (!acc[groupName]) {
acc[groupName] = [];
}
acc[groupName].push(channel);
return acc;
}, {} as Record<string, Channel[]>);
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 (
<ThemedView style={styles.container}>
<LivePlayer
streamUrl={selectedChannelUrl}
channelTitle={channelTitle}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
{isPlayerLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
</View>
)}
<Modal
animationType="slide"
transparent={true}
visible={isChannelListVisible}
onRequestClose={() => setIsChannelListVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<View style={styles.listContainer}>
<View style={styles.groupColumn}>
<FlatList
data={channelGroups}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<StyledButton
text={item}
onPress={() => setSelectedGroup(item)}
isSelected={selectedGroup === item}
style={styles.groupButton}
textStyle={styles.groupButtonText}
/>
)}
/>
</View>
<View style={styles.channelColumn}>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={groupedChannels[selectedGroup] || []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<StyledButton
text={item.name || "Unknown Channel"}
onPress={() => handleSelectChannel(item)}
isSelected={channels[currentChannelIndex]?.id === item.id}
hasTVPreferredFocus={channels[currentChannelIndex]?.id === item.id}
style={styles.channelItem}
textStyle={styles.channelItemText}
/>
)}
/>
)}
</View>
</View>
</View>
</View>
</Modal>
</ThemedView>
);
}
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,
},
});

View File

@@ -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