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

66
components/LivePlayer.tsx Normal file
View File

@@ -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<Video>(null);
if (!streamUrl) {
return (
<View style={styles.container}>
<Text>Select a channel to play.</Text>
</View>
);
}
return (
<View style={styles.container}>
<Video
ref={video}
style={styles.video}
source={{
uri: streamUrl,
}}
resizeMode={ResizeMode.CONTAIN}
shouldPlay
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
/>
{channelTitle && (
<View style={styles.overlay}>
<Text style={styles.title}>{channelTitle}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#000",
},
video: {
flex: 1,
alignSelf: "stretch",
},
overlay: {
position: "absolute",
top: 20,
left: 20,
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 10,
borderRadius: 5,
},
title: {
color: "#fff",
fontSize: 18,
},
});

70
services/m3u.ts Normal file
View File

@@ -0,0 +1,70 @@
import { api } from "./api";
export interface Channel {
id: string;
name: string;
url: string;
logo: string;
group: string;
}
export const parseM3U = (m3uText: string): Channel[] => {
const parsedChannels: Channel[] = [];
const lines = m3uText.split('\n');
let currentChannelInfo: Partial<Channel> | null = null;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = { id: '', name: '', url: '', logo: '', group: '' };
const commaIndex = trimmedLine.indexOf(',');
if (commaIndex !== -1) {
currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim();
const attributesPart = trimmedLine.substring(8, commaIndex);
const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i);
if (logoMatch && logoMatch[1]) currentChannelInfo.logo = logoMatch[1];
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) currentChannelInfo.group = groupMatch[1];
} else {
currentChannelInfo.name = trimmedLine.substring(8).trim();
}
} else if (currentChannelInfo && trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('://')) {
currentChannelInfo.url = trimmedLine;
currentChannelInfo.id = currentChannelInfo.url; // Use URL as ID
parsedChannels.push(currentChannelInfo as Channel);
currentChannelInfo = null;
}
}
return parsedChannels;
};
export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
try {
const response = await fetch(m3uUrl);
if (!response.ok) {
throw new Error(`Failed to fetch M3U: ${response.statusText}`);
}
const m3uText = await response.text();
return parseM3U(m3uText);
} catch (error) {
console.error("Error fetching or parsing M3U:", error);
return []; // Return empty array on error
}
};
export const getPlayableUrl = (originalUrl: string | null): string | null => {
if (!originalUrl) {
return null;
}
// In React Native, we use the proxy for all http streams to avoid potential issues.
if (originalUrl.toLowerCase().startsWith('http://')) {
// Use the baseURL from the existing api instance.
if (!api.baseURL) {
console.warn("API base URL is not set. Cannot create proxy URL.")
return originalUrl; // Fallback to original URL
}
return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`;
}
// HTTPS streams can be played directly.
return originalUrl;
};