10 Commits

Author SHA1 Message Date
zimplexing
3fa2eb3159 feat: add useKeepAwake to LivePlayer and update version to 1.2.7 2025-07-21 19:03:12 +08:00
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
zimplexing
fe05525805 fix: add padding to badge text for improved spacing 2025-07-18 23:00:42 +08:00
zimplexing
1be777825b fix: update VideoCard styles for improved visual consistency and accessibility 2025-07-18 22:57:56 +08:00
zimplexing
813ca40576 chore: bump version to 1.2.5 in package.json 2025-07-18 22:21:53 +08:00
zimplexing
4c633febdc feat: update color scheme and styles across components for improved UI consistency 2025-07-18 22:21:33 +08:00
20 changed files with 128 additions and 99 deletions

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ export default function DetailScreen() {
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#FFD700" : "#ccc"}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
@@ -107,29 +107,32 @@ export default function DetailScreen() {
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={styles.sourceList}>
{searchResults.map((item, index) => (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={detail?.source === item.source}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#28a745" }]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
))}
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
);
})}
</View>
</View>
<View style={styles.episodesContainer}>
@@ -236,16 +239,23 @@ const styles = StyleSheet.create({
fontSize: 16,
},
badge: {
backgroundColor: "red",
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: "white",
color: "#fff",
fontSize: 12,
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
selectedbadgeText: {
color: "#333",
},
episodesContainer: {
marginTop: 20,

View File

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

View File

@@ -8,7 +8,7 @@ import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
// import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
@@ -42,13 +42,13 @@ export default function PlayScreen() {
const {
isLoading,
showControls,
showNextEpisodeOverlay,
// showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
handlePlaybackStatusUpdate,
setShowControls,
setShowNextEpisodeOverlay,
// setShowNextEpisodeOverlay,
reset,
loadVideo,
} = usePlayerStore();
@@ -151,7 +151,7 @@ export default function PlayScreen() {
</View>
)}
<NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} />
{/* <NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> */}
</TouchableOpacity>
<EpisodeSelectionModal />

View File

@@ -11,6 +11,7 @@ import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { RemoteControlModal } from "@/components/RemoteControlModal";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRouter } from "expo-router";
import { Colors } from "@/constants/Colors";
export default function SearchScreen() {
const [keyword, setKeyword] = useState("");
@@ -101,7 +102,7 @@ export default function SearchScreen() {
{
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
color: colorScheme === "dark" ? "white" : "black",
borderColor: isInputFocused ? "#007bff" : "transparent",
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
},
]}
placeholder="搜索电影、剧集..."

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { Video, ResizeMode, AVPlaybackStatus } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
interface LivePlayerProps {
streamUrl: string | null;
@@ -15,6 +16,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
const [isLoading, setIsLoading] = useState(false);
const [isTimeout, setIsTimeout] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useKeepAwake();
useEffect(() => {
if (timeoutRef.current) {
@@ -66,7 +68,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (!streamUrl) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Select a channel to play.</Text>
<Text style={styles.messageText}></Text>
</View>
);
}
@@ -74,7 +76,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (isTimeout) {
return (
<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>
);
}
@@ -98,7 +100,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.messageText}>Loading...</Text>
<Text style={styles.messageText}>...</Text>
</View>
)}
{channelTitle && !isLoading && !isTimeout && (

View File

@@ -168,7 +168,7 @@ const styles = StyleSheet.create({
position: "absolute",
left: 0,
height: 8,
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 4,
},
progressBarTouchable: {

View File

@@ -80,7 +80,7 @@ const styles = StyleSheet.create({
},
seekingBarFilled: {
height: "100%",
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 2.5,
},
});

View File

@@ -72,7 +72,9 @@ const styles = StyleSheet.create({
sourceItem: {
paddingVertical: 2,
margin: 4,
width: "31%",
marginLeft: 10,
marginRight: 8,
width: "30%",
},
sourceItemText: {
fontSize: 14,

View File

@@ -29,11 +29,10 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
selectedButton: {
backgroundColor: colors.tint,
backgroundColor: colors.primary,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
borderColor: colors.primary,
},
selectedText: {
color: Colors.dark.text,
@@ -47,11 +46,11 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
backgroundColor: colors.primary,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
backgroundColor: colors.primary,
},
selectedText: {
color: colors.link,
@@ -65,7 +64,8 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
backgroundColor: "rgba(119, 119, 119, 0.2)",
borderColor: colors.primary,
},
selectedButton: {},
selectedText: {},

View File

@@ -6,6 +6,7 @@ import { Heart, Star, Play, Trash2 } from "lucide-react-native";
import { FavoriteManager, PlayRecordManager } from "@/services/storage";
import { API, api } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
interface VideoCardProps {
id: string;
@@ -209,6 +210,9 @@ const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.3)",
borderColor: Colors.dark.primary,
borderWidth: 2,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
@@ -290,17 +294,17 @@ const styles = StyleSheet.create({
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "rgba(0, 0, 0, 0.5)",
height: 4,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
progressBar: {
height: 3,
backgroundColor: "#ff0000",
height: 4,
backgroundColor: Colors.dark.primary,
},
continueWatchingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255, 0, 0, 0.8)",
backgroundColor: Colors.dark.primary,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 5,
@@ -312,7 +316,7 @@ const styles = StyleSheet.create({
fontWeight: "bold",
},
continueLabel: {
color: "#ff5252",
color: Colors.dark.primary,
fontSize: 12,
},
});

View File

@@ -199,17 +199,17 @@ const VideoLoadingAnimation: React.FC<VideoLoadingAnimationProps> = ({ showProgr
</View>
</Animated.View>
<View style={styles.loadingDots}>
{/* <View style={styles.loadingDots}>
<Animated.View style={[styles.dot, animatedStyles.bounce[0]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[1]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[2]]} />
</View>
</View> */}
{showProgressBar && (
<View style={styles.progressBar}>
<Animated.View style={[styles.progressFill, animatedStyles.progress]}>
<LinearGradient
colors={["#4fd1c7", "#06b6d4", "#3b82f6", "#8b5cf6"]}
colors={["#00bb5e", "#feff5f"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface APIConfigSectionProps {
onChanged: () => void;
@@ -126,8 +127,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface LiveStreamSectionProps {
onChanged: () => void;
@@ -120,8 +121,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface RemoteInputSectionProps {
onChanged: () => void;
@@ -59,7 +60,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<Switch
value={remoteInputEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: "#007AFF" }}
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
@@ -70,7 +71,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<View style={styles.statusContainer}>
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}></ThemedText>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? "#00FF00" : "#FF6B6B" }]}>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? Colors.dark.primary : "#FF6B6B" }]}>
{isServerRunning ? "运行中" : "已停止"}
</ThemedText>
</View>

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { Colors } from "@/constants/Colors";
interface SettingsSectionProps {
children: React.ReactNode;
@@ -9,12 +10,7 @@ interface SettingsSectionProps {
focusable?: boolean;
}
export const SettingsSection: React.FC<SettingsSectionProps> = ({
children,
onFocus,
onBlur,
focusable = false
}) => {
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
@@ -28,20 +24,12 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({
};
if (!focusable) {
return (
<ThemedView style={styles.section}>
{children}
</ThemedView>
);
return <ThemedView style={styles.section}>{children}</ThemedView>;
}
return (
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
<Pressable
style={styles.sectionPressable}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
{children}
</Pressable>
</ThemedView>
@@ -57,10 +45,10 @@ const styles = StyleSheet.create({
borderColor: "#333",
},
sectionFocused: {
borderColor: "#007AFF",
borderColor: Colors.dark.primary,
backgroundColor: "#007AFF10",
},
sectionPressable: {
width: "100%",
},
});
});

View File

@@ -26,5 +26,6 @@ export const Colors = {
tabIconSelected: tintColorDark,
link: "#0a7ea4",
border: "#333",
primary: "#00bb5e",
},
};

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.2.4",
"version": "1.2.7",
"scripts": {
"start": "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) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = { id: '', name: '', url: '', logo: '', group: '' };
const commaIndex = trimmedLine.indexOf(',');
currentChannelInfo = {}; // Start a new channel
const commaIndex = trimmedLine.lastIndexOf(',');
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];
if (logoMatch && logoMatch[1]) {
currentChannelInfo.logo = logoMatch[1];
}
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) currentChannelInfo.group = groupMatch[1];
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;
// 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;
@@ -57,14 +71,14 @@ export const getPlayableUrl = (originalUrl: string | null): string | null => {
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)}`;
}
// 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;
};
};