mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
Add Live functionality with LiveScreen and LivePlayer components; enhance SearchScreen with optimized speech handling
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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
220
app/live.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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
66
components/LivePlayer.tsx
Normal 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
70
services/m3u.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user