5 Commits

Author SHA1 Message Date
zimplexing
e4e4417ef6 feat: enhance LivePlayer messages with localized text and improve M3U parsing logic 2025-07-21 14:06:44 +08:00
Xin
64cdcb78b6 Merge pull request #73 from Moon3r/fix-m3u-without-extinf
Fix fetch m3u failed when m3u file without extinf.
2025-07-21 11:04:35 +08:00
Moon3r
809422f702 Fix fetch m3u failed when m3u file without extinf. 2025-07-21 10:16:42 +08:00
Xin
1c9b3b2553 Update README.md 2025-07-21 09:16:01 +08:00
Xin
e02b3c512f Update README.md 2025-07-21 09:14:50 +08:00
7 changed files with 47 additions and 29 deletions

View File

@@ -4,7 +4,7 @@
## ✨ 功能特性 ## ✨ 功能特性
- **跨平台支持**: 同时支持 Apple TV 和 Android TV。 - **框架跨平台支持**: 同时支持构建 Apple TV 和 Android TV。
- **现代化前端**: 使用 Expo、React Native TVOS 和 TypeScript 构建,性能卓越。 - **现代化前端**: 使用 Expo、React Native TVOS 和 TypeScript 构建,性能卓越。
- **Expo Router**: 基于文件系统的路由,使导航逻辑清晰简单。 - **Expo Router**: 基于文件系统的路由,使导航逻辑清晰简单。
- **后端服务**: 配套 Express 后端,用于处理数据获取、搜索和详情展示。 - **后端服务**: 配套 Express 后端,用于处理数据获取、搜索和详情展示。
@@ -52,10 +52,8 @@
接下来,在项目根目录运行前端应用: 接下来,在项目根目录运行前端应用:
```sh ```sh
# (如果还在 backend 目录) 返回根目录
cd ..
# 安装前端依赖 # 安装依赖
yarn yarn
# [首次运行或依赖更新后] 生成原生项目文件 # [首次运行或依赖更新后] 生成原生项目文件
@@ -73,7 +71,8 @@ yarn android-tv
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 部署使用api 地址填部MoonTV署后的访问地址。 - 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 部署使用api 地址填部MoonTV署后的访问地址。
- **注意:** 地址后面不要带 `/` ,不要遗漏 `http://` 或者 `https://` - **注意:** 地址后面不要带 `/` ,不要遗漏 `http://` 或者 `https://`
- 如果部署在CF请确保电视端可以访问不然会出现无法登录或者登录项与自己配置不符的问题
- 如果不想依赖 MoonTV可以使用 1.1.x 版本。 - 如果不想依赖 MoonTV可以使用 1.1.x 版本。

View File

@@ -38,6 +38,7 @@
"android": { "android": {
"package": "com.oriontv", "package": "com.oriontv",
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"hardwareAcceleration": true,
"networkSecurityConfig": "@xml/network_security_config", "networkSecurityConfig": "@xml/network_security_config",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"permissions": [ "permissions": [

View File

@@ -106,7 +106,7 @@ export default function LiveScreen() {
<View style={styles.groupColumn}> <View style={styles.groupColumn}>
<FlatList <FlatList
data={channelGroups} data={channelGroups}
keyExtractor={(item) => item} keyExtractor={(item, index) => `group-${item}-${index}`}
renderItem={({ item }) => ( renderItem={({ item }) => (
<StyledButton <StyledButton
text={item} text={item}
@@ -124,7 +124,7 @@ export default function LiveScreen() {
) : ( ) : (
<FlatList <FlatList
data={groupedChannels[selectedGroup] || []} data={groupedChannels[selectedGroup] || []}
keyExtractor={(item) => item.id} keyExtractor={(item, index) => `${item.id}-${item.group}-${index}`}
renderItem={({ item }) => ( renderItem={({ item }) => (
<StyledButton <StyledButton
text={item.name || "Unknown Channel"} text={item.name || "Unknown Channel"}
@@ -190,6 +190,8 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 4, paddingHorizontal: 4,
marginVertical: 4, marginVertical: 4,
paddingLeft: 10,
paddingRight: 10,
}, },
groupButtonText: { groupButtonText: {
fontSize: 13, fontSize: 13,
@@ -198,6 +200,8 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
paddingHorizontal: 4, paddingHorizontal: 4,
marginVertical: 3, marginVertical: 3,
paddingLeft: 16,
paddingRight: 16,
}, },
channelItemText: { channelItemText: {
fontSize: 12, fontSize: 12,

View File

@@ -42,13 +42,13 @@ export default function PlayScreen() {
const { const {
isLoading, isLoading,
showControls, showControls,
showNextEpisodeOverlay, // showNextEpisodeOverlay,
initialPosition, initialPosition,
introEndTime, introEndTime,
setVideoRef, setVideoRef,
handlePlaybackStatusUpdate, handlePlaybackStatusUpdate,
setShowControls, setShowControls,
setShowNextEpisodeOverlay, // setShowNextEpisodeOverlay,
reset, reset,
loadVideo, loadVideo,
} = usePlayerStore(); } = usePlayerStore();
@@ -151,7 +151,7 @@ export default function PlayScreen() {
</View> </View>
)} )}
<NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> {/* <NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> */}
</TouchableOpacity> </TouchableOpacity>
<EpisodeSelectionModal /> <EpisodeSelectionModal />

View File

@@ -66,7 +66,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (!streamUrl) { if (!streamUrl) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.messageText}>Select a channel to play.</Text> <Text style={styles.messageText}></Text>
</View> </View>
); );
} }
@@ -74,7 +74,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (isTimeout) { if (isTimeout) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.messageText}>Failed to load stream. It might be offline or unavailable.</Text> <Text style={styles.messageText}></Text>
</View> </View>
); );
} }
@@ -98,7 +98,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
{isLoading && ( {isLoading && (
<View style={styles.loadingOverlay}> <View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" /> <ActivityIndicator size="large" color="#fff" />
<Text style={styles.messageText}>Loading...</Text> <Text style={styles.messageText}>...</Text>
</View> </View>
)} )}
{channelTitle && !isLoading && !isTimeout && ( {channelTitle && !isLoading && !isTimeout && (

View File

@@ -2,7 +2,7 @@
"name": "OrionTV", "name": "OrionTV",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.2.5", "version": "1.2.6",
"scripts": { "scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",

View File

@@ -16,23 +16,37 @@ export const parseM3U = (m3uText: string): Channel[] => {
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) { if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = { id: '', name: '', url: '', logo: '', group: '' }; currentChannelInfo = {}; // Start a new channel
const commaIndex = trimmedLine.indexOf(','); const commaIndex = trimmedLine.lastIndexOf(',');
if (commaIndex !== -1) { if (commaIndex !== -1) {
currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim(); currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim();
const attributesPart = trimmedLine.substring(8, commaIndex); const attributesPart = trimmedLine.substring(8, commaIndex);
const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i); const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i);
if (logoMatch && logoMatch[1]) currentChannelInfo.logo = logoMatch[1]; if (logoMatch && logoMatch[1]) {
currentChannelInfo.logo = logoMatch[1];
}
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i); const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) currentChannelInfo.group = groupMatch[1]; if (groupMatch && groupMatch[1]) {
currentChannelInfo.group = groupMatch[1];
}
} else { } else {
currentChannelInfo.name = trimmedLine.substring(8).trim(); currentChannelInfo.name = trimmedLine.substring(8).trim();
} }
} else if (currentChannelInfo && trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('://')) { } else if (currentChannelInfo && trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('://')) {
currentChannelInfo.url = trimmedLine; currentChannelInfo.url = trimmedLine;
currentChannelInfo.id = currentChannelInfo.url; // Use URL as ID currentChannelInfo.id = currentChannelInfo.url; // Use URL as ID
parsedChannels.push(currentChannelInfo as Channel);
currentChannelInfo = null; // Ensure all required fields are present, providing defaults if necessary
const finalChannel: Channel = {
id: currentChannelInfo.id,
url: currentChannelInfo.url,
name: currentChannelInfo.name || 'Unknown',
logo: currentChannelInfo.logo || '',
group: currentChannelInfo.group || 'Default',
};
parsedChannels.push(finalChannel);
currentChannelInfo = null; // Reset for the next channel
} }
} }
return parsedChannels; return parsedChannels;
@@ -57,14 +71,14 @@ export const getPlayableUrl = (originalUrl: string | null): string | null => {
return null; return null;
} }
// In React Native, we use the proxy for all http streams to avoid potential issues. // In React Native, we use the proxy for all http streams to avoid potential issues.
if (originalUrl.toLowerCase().startsWith('http://')) { // if (originalUrl.toLowerCase().startsWith('http://')) {
// Use the baseURL from the existing api instance. // // Use the baseURL from the existing api instance.
if (!api.baseURL) { // if (!api.baseURL) {
console.warn("API base URL is not set. Cannot create proxy URL.") // console.warn("API base URL is not set. Cannot create proxy URL.")
return originalUrl; // Fallback to original URL // return originalUrl; // Fallback to original URL
} // }
return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`; // return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`;
} // }
// HTTPS streams can be played directly. // HTTPS streams can be played directly.
return originalUrl; return originalUrl;
}; };