24 Commits

Author SHA1 Message Date
litecn
619901ef69 Bump version from 1.3.12 to 1.3.13 2026-02-05 11:04:58 +08:00
litecn
8523e5f157 Merge pull request #266 from litecn/cokvr
fix: set login state and show login modal on authentication failure
2026-02-05 10:38:47 +08:00
James Chen
29ad5a5e75 fix: set login state and show login modal on authentication failure 2026-02-05 10:32:05 +08:00
litecn
cf854c3c9f Bump version from 1.3.11 to 1.3.12 2025-12-17 16:29:59 +08:00
litecn
a86eb8ca5c Merge pull request #257 from litecn/cokvr
fix: 修复显示“认证失败,请重新登录”,却找不到登录框 #247 #255
2025-12-17 16:27:30 +08:00
James Chen
487c15d8b6 fix: 修复显示“认证失败,请重新登录”,却找不到登录框 #247 #255 2025-12-13 22:26:00 +08:00
litecn
3526189e32 Merge pull request #240 from litecn/cokvr
fix: installApk error: exposed beyond app through Intent.getData()
2025-10-12 16:19:13 +08:00
James Chen
c473581c26 fix: installApk error: exposed beyond app through Intent.getData() 2025-10-12 15:33:30 +08:00
Xin
826380714d Update package.json 2025-10-09 11:24:29 +08:00
Xin
3caa9af11a Merge pull request #234 from litecn/cokvr
fix: ios-tv work ok
2025-10-09 11:23:41 +08:00
James Chen
e6194a50ab fix: ios-tv work ok 2025-09-22 15:16:13 +08:00
litecn
aa7efb0dfb Update package.json 2025-09-08 09:20:02 +08:00
litecn
01cf3b9a07 Merge pull request #212 from litecn/cokvr
添加返回键逻辑:按两下返回退出;列表页面返回先回滚到顶部。
2025-09-08 09:18:41 +08:00
James Chen
37d8580b9c feat: implement back button logic — single tap to scroll to top, double tap to exit. 2025-09-05 12:26:44 +08:00
James Chen
79308607b8 style(CustomScrollView): adjust layout styles for better responsiveness and spacing, Fix ATV card overflowing the right edge 2025-09-04 16:44:58 +08:00
Xin
11cbcf08c1 Update package.json 2025-09-04 14:22:00 +08:00
Xin
a13d528cbe Merge pull request #200 from litecn/cokvr
设置菜单内可使用遥控器
2025-09-04 14:21:13 +08:00
James Chen
a2fd16ede5 fix(homeStore): update hasMore logic to check for non-empty result list 2025-09-02 18:14:42 +08:00
James Chen
2fbbca21e7 feat(cache): implement caching mechanism for content data with expiration and size limits, can loadMoreData 2025-09-02 16:24:30 +08:00
litecn
25e7db084a Merge branch 'orion-lib:master' into cokvr 2025-09-02 15:55:14 +08:00
Xin
d14fc941c1 Update issue templates 2025-09-02 11:06:03 +08:00
James Chen
7af9bf2b4c feat(ui): add text selection handling in APIConfigSection and LiveStreamSection components, settings remote input can save 2025-09-02 09:54:58 +08:00
James Chen
0d9f552ede feat(ui): enhance settings sections with responsive layout and keyboard handling 2025-09-01 14:12:26 +08:00
James Chen
62d8141178 feat(ui): replace TouchableOpacity with Pressable in VideoCard component for improved touch handling 2025-09-01 14:12:26 +08:00
19 changed files with 909 additions and 469 deletions

15
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Version**
- OrionTV:
- LunaTV or MoonTV:

View File

@@ -56,15 +56,15 @@
"screenOrientation": "unspecified",
"intentFilters": [
{
"action": "VIEW",
"action": "android.intent.action.VIEW",
"data": [
{
"scheme": "oriontv"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
"android.intent.category.BROWSABLE",
"android.intent.category.DEFAULT"
]
}
]

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform } from "react-native";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform, BackHandler, ToastAndroid } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
@@ -53,6 +53,39 @@ export default function HomeScreen() {
}, [refreshPlayRecords])
);
// 双击返回退出逻辑(只限当前页面)
const backPressTimeRef = useRef<number | null>(null);
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
const now = Date.now();
// 如果还没按过返回键或距离上次超过2秒
if (!backPressTimeRef.current || now - backPressTimeRef.current > 2000) {
backPressTimeRef.current = now;
ToastAndroid.show("再按一次返回键退出", ToastAndroid.SHORT);
return true; // 拦截返回事件,不退出
}
// 两次返回键间隔小于2秒退出应用
BackHandler.exitApp();
return true;
};
// 仅限 Android 平台启用此功能
if (Platform.OS === "android") {
const backHandler = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
// 返回首页时重置状态
return () => {
backHandler.remove();
backPressTimeRef.current = null;
};
}
}, [])
);
// 统一的数据获取逻辑
useEffect(() => {
if (!selectedCategory) return;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform } from "react-native";
import { View, StyleSheet, Alert, Platform } from "react-native";
import { useTVEventHandler } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ThemedText } from "@/components/ThemedText";
@@ -20,6 +20,19 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
import { DeviceUtils } from "@/utils/DeviceUtils";
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
type SectionItem = {
component: React.ReactElement;
key: string;
};
/** 过滤掉 false/undefined帮 TypeScript 推断出真正的数组元素类型 */
function isSectionItem(
item: false | undefined | SectionItem
): item is SectionItem {
return !!item;
}
export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
@@ -50,6 +63,7 @@ export default function SettingsScreen() {
const realMessage = lastMessage.split("_")[0];
handleRemoteInput(realMessage);
clearMessage(); // Clear the message after processing
markAsChanged();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessage, targetPage]);
@@ -85,8 +99,66 @@ export default function SettingsScreen() {
setHasChanges(true);
};
const sections = [
// 远程输入配置 - 仅在非手机端显示
// const sections = [
// // 远程输入配置 - 仅在非手机端显示
// deviceType !== "mobile" && {
// component: (
// <RemoteInputSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(0);
// setCurrentSection("remote");
// }}
// />
// ),
// key: "remote",
// },
// {
// component: (
// <APIConfigSection
// ref={apiSectionRef}
// onChanged={markAsChanged}
// hideDescription={deviceType === "mobile"}
// onFocus={() => {
// setCurrentFocusIndex(1);
// setCurrentSection("api");
// }}
// />
// ),
// key: "api",
// },
// // 直播源配置 - 仅在非手机端显示
// deviceType !== "mobile" && {
// component: (
// <LiveStreamSection
// ref={liveStreamSectionRef}
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(2);
// setCurrentSection("livestream");
// }}
// />
// ),
// key: "livestream",
// },
// // {
// // component: (
// // <VideoSourceSection
// // onChanged={markAsChanged}
// // onFocus={() => {
// // setCurrentFocusIndex(3);
// // setCurrentSection("videoSource");
// // }}
// // />
// // ),
// // key: "videoSource",
// // },
// Platform.OS === "android" && {
// component: <UpdateSection />,
// key: "update",
// },
// ].filter(Boolean);
const rawSections = [
deviceType !== "mobile" && {
component: (
<RemoteInputSection
@@ -113,7 +185,6 @@ export default function SettingsScreen() {
),
key: "api",
},
// 直播源配置 - 仅在非手机端显示
deviceType !== "mobile" && {
component: (
<LiveStreamSection
@@ -127,23 +198,14 @@ export default function SettingsScreen() {
),
key: "livestream",
},
// {
// component: (
// <VideoSourceSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(3);
// setCurrentSection("videoSource");
// }}
// />
// ),
// key: "videoSource",
// },
Platform.OS === "android" && {
component: <UpdateSection />,
key: "update",
},
].filter(Boolean);
] as const; // 把每个对象都当作字面量保留
/** 这里得到的 sections 已经是 SectionItem[](没有 false */
const sections: SectionItem[] = rawSections.filter(isSectionItem);
// TV遥控器事件处理 - 仅在TV设备上启用
const handleTVEvent = React.useCallback(
@@ -164,13 +226,22 @@ export default function SettingsScreen() {
[currentFocusIndex, sections.length, deviceType]
);
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => {});
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => { });
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing, insets);
const renderSettingsContent = () => (
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
// <KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<KeyboardAwareScrollView
enableOnAndroid={true}
extraScrollHeight={20}
keyboardOpeningTime={0}
keyboardShouldPersistTaps="always"
scrollEnabled={true}
style={{ flex: 1, backgroundColor }}
>
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{deviceType === "tv" && (
<View style={dynamicStyles.header}>
@@ -178,7 +249,7 @@ export default function SettingsScreen() {
</View>
)}
<View style={dynamicStyles.scrollView}>
{/* <View style={dynamicStyles.scrollView}>
<FlatList
data={sections}
renderItem={({ item }) => {
@@ -191,6 +262,14 @@ export default function SettingsScreen() {
showsVerticalScrollIndicator={false}
contentContainerStyle={dynamicStyles.listContent}
/>
</View> */}
<View style={dynamicStyles.scrollView}>
{sections.map(item => (
// 必须把 key 放在最外层的 View 上
<View key={item.key} style={dynamicStyles.itemWrapper}>
{item.component}
</View>
))}
</View>
<View style={dynamicStyles.footer}>
@@ -203,7 +282,8 @@ export default function SettingsScreen() {
/>
</View>
</ThemedView>
</KeyboardAvoidingView>
</KeyboardAwareScrollView>
// </KeyboardAvoidingView>
);
// 根据设备类型决定是否包装在响应式导航中
@@ -261,5 +341,8 @@ const createResponsiveStyles = (deviceType: string, spacing: number, insets: any
disabledButton: {
opacity: 0.5,
},
itemWrapper: {
marginBottom: spacing, // 这里的 spacing 来自 useResponsiveLayout()
},
});
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
import React, { useCallback, useRef, useState, useEffect } from "react";
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, BackHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
@@ -20,7 +20,7 @@ interface CustomScrollViewProps {
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
data,
renderItem,
numColumns, // 现在可选,如果不提供将使用响应式默认值
numColumns,
loading = false,
loadingMore = false,
error = null,
@@ -29,9 +29,28 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
emptyMessage = "暂无内容",
ListFooterComponent,
}) => {
const scrollViewRef = useRef<ScrollView>(null);
const firstCardRef = useRef<any>(null); // <--- 新增
const [showScrollToTop, setShowScrollToTop] = useState(false);
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType } = responsiveConfig;
// 添加返回键处理逻辑
useEffect(() => {
if (deviceType === 'tv') {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showScrollToTop) {
scrollToTop();
return true; // 阻止默认的返回行为
}
return false; // 允许默认的返回行为
});
return () => backHandler.remove();
}
}, [showScrollToTop,deviceType]);
// 使用响应式列数,如果没有明确指定的话
const effectiveColumns = numColumns || responsiveConfig.columns;
@@ -40,6 +59,9 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
// 显示/隐藏返回顶部按钮
setShowScrollToTop(contentOffset.y > 200);
if (isCloseToBottom && !loadingMore && onEndReached) {
onEndReached();
}
@@ -47,6 +69,14 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
[onEndReached, loadingMore, loadMoreThreshold]
);
const scrollToTop = () => {
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 滚动动画结束后聚焦第一个卡片
setTimeout(() => {
firstCardRef.current?.focus();
}, 500); // 500ms 适配大多数动画时长
};
const renderFooter = () => {
if (ListFooterComponent) {
if (React.isValidElement(ListFooterComponent)) {
@@ -111,7 +141,8 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
marginBottom: responsiveConfig.spacing,
},
fullRowContainer: {
justifyContent: "space-between",
justifyContent: "space-around",
marginRight: responsiveConfig.spacing / 2,
},
partialRowContainer: {
justifyContent: "flex-start",
@@ -123,47 +154,72 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
width: responsiveConfig.cardWidth,
marginRight: responsiveConfig.spacing,
},
scrollToTopButton: {
position: 'absolute',
right: responsiveConfig.spacing,
bottom: responsiveConfig.spacing * 2,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
padding: responsiveConfig.spacing,
borderRadius: responsiveConfig.spacing,
opacity: showScrollToTop ? 1 : 0,
},
});
return (
<ScrollView
contentContainerStyle={dynamicStyles.listContent}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
>
{data.length > 0 ? (
<>
{rows.map((row, rowIndex) => {
const isFullRow = row.length === effectiveColumns;
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
return (
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
{row.map((item, itemIndex) => {
const actualIndex = rowIndex * effectiveColumns + itemIndex;
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
return (
<View key={actualIndex} style={isFullRow ? dynamicStyles.itemContainer : itemStyle}>
{renderItem({ item, index: actualIndex })}
</View>
);
})}
</View>
);
})}
{renderFooter()}
</>
) : (
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
);
<View style={{ flex: 1 }}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={dynamicStyles.listContent}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
>
{data.length > 0 ? (
<>
{rows.map((row, rowIndex) => {
const isFullRow = row.length === effectiveColumns;
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
return (
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
{row.map((item, itemIndex) => {
const actualIndex = rowIndex * effectiveColumns + itemIndex;
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
const cardProps = {
key: actualIndex,
style: isFullRow ? dynamicStyles.itemContainer : itemStyle,
};
return (
<View {...cardProps}>
{renderItem({ item, index: actualIndex })}
</View>
);
})}
</View>
);
})}
{renderFooter()}
</>
) : (
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
{deviceType!=='tv' && (
<TouchableOpacity
style={dynamicStyles.scrollToTopButton}
onPress={scrollToTop}
activeOpacity={0.8}
>
<ThemedText></ThemedText>
</TouchableOpacity>
)}
</View>
);
};
export default CustomScrollView;

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert, Keyboard, InteractionManager } from "react-native";
import { usePathname } from "expo-router";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
@@ -23,9 +23,14 @@ const LoginModal = () => {
const pathname = usePathname();
const isSettingsPage = pathname.includes("settings");
const [isModalReady, setIsModalReady] = useState(false);
// Load saved credentials when modal opens
useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) {
// 先确保键盘状态清理
Keyboard.dismiss();
const loadCredentials = async () => {
const savedCredentials = await LoginCredentialsManager.get();
if (savedCredentials) {
@@ -34,12 +39,22 @@ const LoginModal = () => {
}
};
loadCredentials();
// 延迟设置 Modal 就绪状态
const readyTimeout = setTimeout(() => {
setIsModalReady(true);
}, 300);
return () => {
clearTimeout(readyTimeout);
setIsModalReady(false);
};
}
}, [isLoginModalVisible, isSettingsPage]);
// Focus management with better TV remote handling
useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) {
if (isModalReady && isLoginModalVisible && !isSettingsPage) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
// Use a small delay to ensure the modal is fully rendered
@@ -49,11 +64,19 @@ const LoginModal = () => {
} else {
passwordInputRef.current?.focus();
}
}, 100);
}, 300);
return () => clearTimeout(focusTimeout);
}
}, [isLoginModalVisible, serverConfig, isSettingsPage]);
}, [isModalReady, isLoginModalVisible, serverConfig, isSettingsPage]);
// 清理 effect - 确保 Modal 关闭时清理所有状态
useEffect(() => {
return () => {
Keyboard.dismiss();
setIsModalReady(false);
};
}, []);
const handleLogin = async () => {
const isLocalStorage = serverConfig?.StorageType === "localstorage";
@@ -66,19 +89,38 @@ const LoginModal = () => {
await api.login(isLocalStorage ? undefined : username, password);
await checkLoginStatus(apiBaseUrl);
await refreshPlayRecords();
// Save credentials on successful login
await LoginCredentialsManager.save({ username, password });
Toast.show({ type: "success", text1: "登录成功" });
hideLoginModal();
// Show disclaimer alert after successful login
Alert.alert(
"免责声明",
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
Toast.show({ type: "success", text1: "登录成功" });
// hideLoginModal();
// // Show disclaimer alert after successful login
// Alert.alert(
// "免责声明",
// "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
// [{ text: "确定" }]
// );
// 在登录成功后清理状态,再显示 Alert
const hideAndAlert = () => {
hideLoginModal();
setIsModalReady(false);
Keyboard.dismiss();
setTimeout(() => {
Alert.alert(
"免责声明",
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
}, 100);
};
// 使用 InteractionManager 确保 UI 稳定后再执行
InteractionManager.runAfterInteractions(hideAndAlert);
} catch (error) {
Toast.show({
type: "error",

View File

@@ -150,126 +150,69 @@ const VideoCard = forwardRef<View, VideoCardProps>(
return (
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
{Platform.isTV || deviceType !== 'tv' ? (
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
<Pressable
android_ripple={Platform.isTV || deviceType !== 'tv' ? { color: 'transparent' } : { color: Colors.dark.link }}
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={({ pressed }) => [
styles.pressable,
{
zIndex: pressed ? 999 : 1, // 确保按下时有最高优先级
},
]}
// activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
) : (
<Pressable
android_ripple={{ color: Colors.dark.link }}
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
// activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</Pressable>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</Pressable>
</Animated.View>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { View, TextInput, StyleSheet, Animated } from "react-native";
import { View, TextInput, StyleSheet, Animated, Platform } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
interface APIConfigSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
onPress?: () => void;
hideDescription?: boolean;
}
@@ -20,13 +22,14 @@ export interface APIConfigSectionRef {
}
export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSectionProps>(
({ onChanged, onFocus, onBlur, hideDescription = false }, ref) => {
({ onChanged, onFocus, onBlur, onPress, hideDescription = false }, ref) => {
const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore();
const { serverUrl } = useRemoteControlStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
const deviceType = useResponsiveLayout().deviceType;
const handleUrlChange = (url: string) => {
setApiBaseUrl(url);
@@ -60,10 +63,28 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
[isSectionFocused]
);
const handlePress = () => {
inputRef.current?.focus();
onPress?.();
}
useTVEventHandler(handleTVEvent);
const [selection, setSelection] = useState<{ start: number; end: number }>({
start: 0,
end: 0,
});
// 当用户手动移动光标或选中文本时,同步到 state可选
const onSelectionChange = ({
nativeEvent: { selection },
}: any) => {
setSelection(selection);
};
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
{...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
>
<View style={styles.inputContainer}>
<View style={styles.titleContainer}>
<ThemedText style={styles.sectionTitle}>API </ThemedText>
@@ -81,7 +102,21 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onFocus={() => {
setIsInputFocused(true);
// 将光标移动到文本末尾
const end = apiBaseUrl.length;
setSelection({ start: end, end: end });
// 有时需要延迟一下,让系统先完成 focus 再设置 selection
//(在 Android 上更可靠)
setTimeout(() => {
// 对于受控的 selection 已经生效,这里仅作保险
inputRef.current?.setNativeProps({ selection: { start: end, end: end } });
}, 0);
}}
selection={selection}
onSelectionChange={onSelectionChange} // 可选
onBlur={() => setIsInputFocused(false)}
/>
</Animated.View>

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { View, TextInput, StyleSheet, Animated } from "react-native";
import { View, TextInput, StyleSheet, Animated, Platform } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
interface LiveStreamSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
onPress?: () => void;
}
export interface LiveStreamSectionRef {
@@ -19,13 +21,14 @@ export interface LiveStreamSectionRef {
}
export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSectionProps>(
({ onChanged, onFocus, onBlur }, ref) => {
({ onChanged, onFocus, onBlur, onPress }, ref) => {
const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore();
const { serverUrl } = useRemoteControlStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
const deviceType = useResponsiveLayout().deviceType;
const handleUrlChange = (url: string) => {
setM3uUrl(url);
@@ -49,6 +52,11 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
onBlur?.();
};
const handlePress = () => {
inputRef.current?.focus();
onPress?.();
}
const handleTVEvent = React.useCallback(
(event: any) => {
if (isSectionFocused && event.eventType === "select") {
@@ -60,8 +68,22 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
useTVEventHandler(handleTVEvent);
const [selection, setSelection] = useState<{ start: number; end: number }>({
start: 0,
end: 0,
});
// 当用户手动移动光标或选中文本时,同步到 state可选
const onSelectionChange = ({
nativeEvent: { selection },
}: any) => {
setSelection(selection);
};
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
onPress={Platform.isTV || deviceType !== 'tv' ? undefined : handlePress}
>
<View style={styles.inputContainer}>
<View style={styles.titleContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
@@ -79,8 +101,23 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onFocus={() => {
setIsInputFocused(true);
// 将光标移动到文本末尾
const end = m3uUrl.length;
setSelection({ start: end, end: end });
// 有时需要延迟一下,让系统先完成 focus 再设置 selection
//(在 Android 上更可靠)
setTimeout(() => {
// 对于受控的 selection 已经生效,这里仅作保险
inputRef.current?.setNativeProps({ selection: { start: end, end: end } });
}, 0);
}}
selection={selection}
onSelectionChange={onSelectionChange} // 可选
onBlur={() => setIsInputFocused(false)}
// onPress={handlePress}
/>
</Animated.View>
</View>

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { View, Switch, StyleSheet, Pressable, Animated } from "react-native";
import { View, Switch, StyleSheet, Pressable, Animated, Platform, TouchableOpacity } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
@@ -7,18 +7,21 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
interface RemoteInputSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
onPress?: () => void;
}
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur }) => {
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur, onPress }) => {
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused, 1.2);
const deviceType = useResponsiveLayout().deviceType;
const handleToggle = useCallback(
(enabled: boolean) => {
@@ -38,6 +41,10 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
onBlur?.();
};
const handlePress = () => {
handleToggle(!remoteInputEnabled);
}
// TV遥控器事件处理
const handleTVEvent = React.useCallback(
(event: any) => {
@@ -51,19 +58,32 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
useTVEventHandler(handleTVEvent);
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
{...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
>
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<View style={styles.settingInfo}>
<ThemedText style={styles.settingName}></ThemedText>
</View>
<Animated.View style={animationStyle}>
<Switch
value={remoteInputEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
{ Platform.OS === 'ios' && Platform.isTV ? (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => handlePress()}
style={styles.statusLabel}
>
<ThemedText style={styles.statusValue}>{remoteInputEnabled ? '已启用' : '已禁用'}</ThemedText>
</TouchableOpacity>
) : (
<Switch
value={remoteInputEnabled}
onValueChange={() => { }} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
)
}
</Animated.View>
</Pressable>

View File

@@ -1,17 +1,20 @@
import React, { useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { StyleSheet, Pressable, Platform } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
interface SettingsSectionProps {
children: React.ReactNode;
onFocus?: () => void;
onBlur?: () => void;
onPress?: () => void;
focusable?: boolean;
}
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, onPress, focusable = false }) => {
const [isFocused, setIsFocused] = useState(false);
const deviceType = useResponsiveLayout().deviceType;
const handleFocus = () => {
setIsFocused(true);
@@ -23,13 +26,24 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFo
onBlur?.();
};
const handlePress = () => {
onPress?.();
}
if (!focusable) {
return <ThemedView style={styles.section}>{children}</ThemedView>;
}
return (
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
<Pressable
android_ripple={Platform.isTV||deviceType !=='tv'? {color:'transparent'}:{color:Colors.dark.link}}
style={styles.sectionPressable}
// {...(Platform.isTV ? {onFocus: handleFocus, onBlur: handleBlur} : {onPress: onPress})}
onFocus={handleFocus}
onBlur={handleBlur}
onPress={handlePress}
>
{children}
</Pressable>
</ThemedView>

View File

@@ -38,7 +38,7 @@ export const useApiConfig = () => {
const validateConfig = async () => {
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
try {
await api.getServerConfig();
setValidationState({
@@ -48,7 +48,7 @@ export const useApiConfig = () => {
});
} catch (error) {
let errorMessage = '服务器连接失败';
if (error instanceof Error) {
switch (error.message) {
case 'API_URL_NOT_SET':
@@ -70,7 +70,7 @@ export const useApiConfig = () => {
break;
}
}
setValidationState({
isValidating: false,
isValid: false,
@@ -98,10 +98,10 @@ export const useApiConfig = () => {
if (serverConfig) {
setValidationState(prev => ({ ...prev, isValid: true, error: null }));
} else {
setValidationState(prev => ({
...prev,
isValid: false,
error: prev.error || '无法获取服务器配置'
setValidationState(prev => ({
...prev,
isValid: false,
error: prev.error || '无法获取服务器配置'
}));
}
}
@@ -122,18 +122,18 @@ export const getApiConfigErrorMessage = (status: ApiConfigStatus): string => {
if (status.needsConfiguration) {
return '请点击右上角设置按钮,配置您的服务器地址';
}
if (status.error) {
return status.error;
}
if (status.isValidating) {
return '正在验证服务器配置...';
}
if (status.isValid === false) {
return '服务器配置验证失败,请检查设置';
}
return '加载失败,请重试';
};

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.3.8",
"version": "1.3.13",
"scripts": {
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
@@ -36,6 +36,7 @@
"expo-build-properties": "~0.12.3",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.7",
"expo-intent-launcher": "~11.0.1",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.16",
@@ -50,6 +51,7 @@
"react-native-blob-util": "^0.22.2",
"react-native-file-viewer": "^2.1.5",
"react-native-gesture-handler": "~2.16.1",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-media-console": "*",
"react-native-qrcode-svg": "^6.3.1",
"react-native-reanimated": "~3.10.1",

View File

@@ -1,3 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
// region: --- Interface Definitions ---
export interface DoubanItem {
title: string;
@@ -104,17 +106,32 @@ export class API {
return response;
}
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}
async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
// 存储cookie到AsyncStorage
const cookies = response.headers.get("Set-Cookie");
if (cookies) {
await AsyncStorage.setItem("authCookies", cookies);
}
return response.json();
}
async logout(): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/logout", {
method: "POST",
});
await AsyncStorage.setItem("authCookies", '');
return response.json();
}
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}

View File

@@ -1,9 +1,12 @@
import ReactNativeBlobUtil from "react-native-blob-util";
import FileViewer from "react-native-file-viewer";
import Toast from "react-native-toast-message";
import { version as currentVersion } from "../package.json";
import { UPDATE_CONFIG } from "../constants/UpdateConfig";
// UpdateService.ts
import * as FileSystem from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
// import * as Device from 'expo-device';
import Toast from 'react-native-toast-message';
import { version as currentVersion } from '../package.json';
import { UPDATE_CONFIG } from '../constants/UpdateConfig';
import Logger from '@/utils/Logger';
import { Platform } from 'react-native';
const logger = Logger.withTag('UpdateService');
@@ -12,9 +15,13 @@ interface VersionInfo {
downloadUrl: string;
}
/**
* 只在 Android 平台使用的常量iOS 不会走到下载/安装流程)
*/
const ANDROID_MIME_TYPE = 'application/vnd.android.package-archive';
class UpdateService {
private static instance: UpdateService;
static getInstance(): UpdateService {
if (!UpdateService.instance) {
UpdateService.instance = new UpdateService();
@@ -22,203 +29,220 @@ class UpdateService {
return UpdateService.instance;
}
/** --------------------------------------------------------------
* 1⃣ 远程版本检查(保持不变,只是把 fetch 包装成 async/await
* --------------------------------------------------------------- */
async checkVersion(): Promise<VersionInfo> {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const timeoutId = setTimeout(() => controller.abort(), 10_000);
const response = await fetch(UPDATE_CONFIG.GITHUB_RAW_URL, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch version info`);
throw new Error(`HTTP ${response.status}`);
}
const remotePackage = await response.json();
const remoteVersion = remotePackage.version;
const remoteVersion = remotePackage.version as string;
return {
version: remoteVersion,
downloadUrl: UPDATE_CONFIG.getDownloadUrl(remoteVersion),
};
} catch (error) {
retries++;
logger.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error);
if (retries === maxRetries) {
Toast.show({ type: "error", text1: "检查更新失败", text2: "无法获取版本信息,请检查网络连接" });
throw error;
} catch (e) {
logger.warn(`checkVersion attempt ${attempt}/${maxRetries}`, e);
if (attempt === maxRetries) {
Toast.show({
type: 'error',
text1: '检查更新失败',
text2: '无法获取版本信息,请检查网络',
});
throw e;
}
// 等待一段时间后重试
await new Promise(resolve => setTimeout(resolve, 2000 * retries));
// 指数退避
await new Promise(r => setTimeout(r, 2_000 * attempt));
}
}
throw new Error("Maximum retry attempts exceeded");
// 这句永远走不到,仅为 TypeScript 报错
throw new Error('Unexpected');
}
// 清理旧的APK文件
/** --------------------------------------------------------------
* 2⃣ 清理旧的 APK 文件(使用 expo-file-system 的 API
* --------------------------------------------------------------- */
private async cleanOldApkFiles(): Promise<void> {
try {
const { dirs } = ReactNativeBlobUtil.fs;
// 使用DocumentDir而不是DownloadDir
const files = await ReactNativeBlobUtil.fs.ls(dirs.DocumentDir);
// 查找所有OrionTV APK文件
const apkFiles = files.filter(file => file.startsWith('OrionTV_v') && file.endsWith('.apk'));
// 保留最新的2个文件删除其他的
if (apkFiles.length > 2) {
const sortedFiles = apkFiles.sort((a, b) => {
// 从文件名中提取时间戳进行排序
const timeA = a.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0';
const timeB = b.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0';
return parseInt(timeB) - parseInt(timeA);
});
// 删除旧文件
const filesToDelete = sortedFiles.slice(2);
for (const file of filesToDelete) {
try {
await ReactNativeBlobUtil.fs.unlink(`${dirs.DocumentDir}/${file}`);
logger.debug(`Cleaned old APK file: ${file}`);
} catch (deleteError) {
logger.warn(`Failed to delete old APK file ${file}:`, deleteError);
}
const dirUri = FileSystem.documentDirectory; // e.g. file:///data/user/0/.../files/
if (!dirUri) {
throw new Error('Document directory is not available');
}
const listing = await FileSystem.readDirectoryAsync(dirUri);
const apkFiles = listing.filter(name => name.startsWith('OrionTV_v') && name.endsWith('.apk'));
if (apkFiles.length <= 2) return;
const sorted = apkFiles.sort((a, b) => {
const numA = parseInt(a.replace(/[^0-9]/g, ''), 10);
const numB = parseInt(b.replace(/[^0-9]/g, ''), 10);
return numB - numA; // 倒序(最新在前)
});
const stale = sorted.slice(2); // 保留最新的两个
for (const file of stale) {
const path = `${dirUri}${file}`;
try {
await FileSystem.deleteAsync(path, { idempotent: true });
logger.debug(`Deleted old APK: ${file}`);
} catch (e) {
logger.warn(`Failed to delete ${file}`, e);
}
}
} catch (error) {
logger.warn('Failed to clean old APK files:', error);
} catch (e) {
logger.warn('cleanOldApkFiles error', e);
}
}
async downloadApk(url: string, onProgress?: (progress: number) => void): Promise<string> {
let retries = 0;
/** --------------------------------------------------------------
* 3⃣ 下载 APK使用 expo-file-system 的下载 API
* --------------------------------------------------------------- */
async downloadApk(
url: string,
onProgress?: (percent: number) => void,
): Promise<string> {
const maxRetries = 3;
// 清理旧文件
await this.cleanOldApkFiles();
while (retries < maxRetries) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { dirs } = ReactNativeBlobUtil.fs;
const timestamp = new Date().getTime();
const timestamp = Date.now();
const fileName = `OrionTV_v${timestamp}.apk`;
// 使用应用的外部文件目录,而不是系统下载目录
const filePath = `${dirs.DocumentDir}/${fileName}`;
const fileUri = `${FileSystem.documentDirectory}${fileName}`;
const task = ReactNativeBlobUtil.config({
fileCache: true,
path: filePath,
timeout: UPDATE_CONFIG.DOWNLOAD_TIMEOUT,
// 移除 addAndroidDownloads 配置,避免使用系统下载管理器
// addAndroidDownloads: {
// useDownloadManager: true,
// notification: true,
// title: UPDATE_CONFIG.NOTIFICATION.TITLE,
// description: UPDATE_CONFIG.NOTIFICATION.DOWNLOADING_TEXT,
// mime: "application/vnd.android.package-archive",
// mediaScannable: true,
// },
}).fetch("GET", url);
// expo-file-system 把下载进度回调参数统一为 `{totalBytesWritten, totalBytesExpectedToWrite}`
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{
// Android 需要在 AndroidManifest 中声明 INTERNET、WRITE_EXTERNAL_STORAGE (API 33+ 使用 MANAGE_EXTERNAL_STORAGE)
// 这里不使用系统下载管理器,因为我们想自己控制进度回调。
},
progress => {
if (onProgress && progress.totalBytesExpectedToWrite) {
const percent = Math.floor(
(progress.totalBytesWritten / progress.totalBytesExpectedToWrite) * 100,
);
onProgress(percent);
}
},
);
// 监听下载进度
if (onProgress) {
task.progress((received: string, total: string) => {
const receivedNum = parseInt(received, 10);
const totalNum = parseInt(total, 10);
const progress = Math.floor((receivedNum / totalNum) * 100);
onProgress(progress);
const result = await downloadResumable.downloadAsync();
if (result && result.uri) {
logger.debug(`APK downloaded to ${result.uri}`);
return result.uri;
} else {
throw new Error('Download failed: No URI available');
}
} catch (e) {
logger.warn(`downloadApk attempt ${attempt}/${maxRetries}`, e);
if (attempt === maxRetries) {
Toast.show({
type: 'error',
text1: '下载失败',
text2: 'APK 下载出现错误,请检查网络',
});
throw e;
}
// 指数退避
await new Promise(r => setTimeout(r, 3_000 * attempt));
}
}
// 同上,理论不会到这里
throw new Error('Download failed');
}
/** --------------------------------------------------------------
* 4⃣ 安装 APK只在 Android 可用,使用 expo-intent-launcher
* --------------------------------------------------------------- */
async installApk(fileUri: string): Promise<void> {
// ① 先确认文件存在
const exists = await FileSystem.getInfoAsync(fileUri);
if (!exists.exists) {
throw new Error(`APK not found at ${fileUri}`);
}
// ② 把 file:// 转成 content://ExpoFileSystem 已经实现了 FileProvider
const contentUri = await FileSystem.getContentUriAsync(fileUri);
// ③ 只在 Android 里执行
if (Platform.OS === 'android') {
try {
// FLAG_ACTIVITY_NEW_TASK = 0x10000000 (1)
// FLAG_GRANT_READ_URI_PERMISSION = 0x00000010
const flags = 1 | 0x00000010; // 1 | 16
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri, // 必须是 content://
type: ANDROID_MIME_TYPE, // application/vnd.android.package-archive
flags,
});
} catch (e: any) {
// 统一错误提示
if (e.message?.includes('Activity not found')) {
Toast.show({
type: 'error',
text1: '安装失败',
text2: '系统没有找到可以打开 APK 的应用,请检查系统设置',
});
} else if (e.message?.includes('permission')) {
Toast.show({
type: 'error',
text1: '安装失败',
text2: '请在设置里允许“未知来源”安装',
});
} else {
Toast.show({
type: 'error',
text1: '安装失败',
text2: '未知错误,请稍后重试',
});
}
const res = await task;
logger.debug(`APK downloaded successfully: ${filePath}`);
return res.path();
} catch (error) {
retries++;
logger.info(`Error downloading APK (attempt ${retries}/${maxRetries}):`, error);
if (retries === maxRetries) {
Toast.show({ type: "error", text1: "下载失败", text2: "APK下载失败请检查网络连接" });
throw new Error(`Download failed after ${maxRetries} attempts: ${error}`);
}
// 等待一段时间后重试
await new Promise(resolve => setTimeout(resolve, 3000 * retries));
throw e;
}
}
throw new Error("Maximum retry attempts exceeded for download");
}
async installApk(filePath: string): Promise<void> {
try {
// 首先检查文件是否存在
const exists = await ReactNativeBlobUtil.fs.exists(filePath);
if (!exists) {
throw new Error(`APK file not found: ${filePath}`);
}
// 使用FileViewer打开APK文件进行安装
// 这会触发Android的包安装器
await FileViewer.open(filePath, {
showOpenWithDialog: true, // 显示选择应用对话框
showAppsSuggestions: true, // 显示应用建议
displayName: "OrionTV Update",
} else {
// iOS 设备不支持直接安装 APK
Toast.show({
type: 'error',
text1: '安装失败',
text2: 'iOS 设备无法直接安装 APK',
});
} catch (error) {
logger.info("Error installing APK:", error);
// 提供更详细的错误信息
if (error instanceof Error) {
if (error.message.includes('No app found')) {
Toast.show({ type: "error", text1: "安装失败", text2: "未找到可安装APK的应用请确保允许安装未知来源的应用" });
throw new Error('未找到可安装APK的应用请确保允许安装未知来源的应用');
} else if (error.message.includes('permission')) {
Toast.show({ type: "error", text1: "安装失败", text2: "没有安装权限,请在设置中允许此应用安装未知来源的应用" });
throw new Error('没有安装权限,请在设置中允许此应用安装未知来源的应用');
} else {
Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现错误" });
}
} else {
Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现未知错误" });
}
throw error;
throw new Error('APK install not supported on iOS');
}
}
/** --------------------------------------------------------------
* 5⃣ 版本比对工具(保持原来的实现)
* --------------------------------------------------------------- */
compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 > part2) return 1;
if (part1 < part2) return -1;
const p1 = v1.split('.').map(Number);
const p2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(p1.length, p2.length); i++) {
const n1 = p1[i] ?? 0;
const n2 = p2[i] ?? 0;
if (n1 > n2) return 1;
if (n1 < n2) return -1;
}
return 0;
}
getCurrentVersion(): string {
return currentVersion;
}
isUpdateAvailable(remoteVersion: string): boolean {
return this.compareVersions(remoteVersion, currentVersion) > 0;
}
}
/* 单例导出 */
export default UpdateService.getInstance();

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore";
import Toast from "react-native-toast-message";
@@ -30,14 +30,14 @@ const useAuthStore = create<AuthState>((set) => ({
// Wait for server config to be loaded if it's currently loading
const settingsState = useSettingsStore.getState();
let serverConfig = settingsState.serverConfig;
// If server config is loading, wait a bit for it to complete
if (settingsState.isLoadingServerConfig) {
// Wait up to 3 seconds for server config to load
const maxWaitTime = 3000;
const checkInterval = 100;
let waitTime = 0;
while (waitTime < maxWaitTime) {
await new Promise(resolve => setTimeout(resolve, checkInterval));
waitTime += checkInterval;
@@ -48,7 +48,7 @@ const useAuthStore = create<AuthState>((set) => ({
}
}
}
if (!serverConfig?.StorageType) {
// Only show error if we're not loading and have tried to fetch the config
if (!settingsState.isLoadingServerConfig) {
@@ -56,20 +56,21 @@ const useAuthStore = create<AuthState>((set) => ({
}
return;
}
const cookies = await Cookies.get(api.baseURL);
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) {
const loginResult = await api.login().catch(() => {
const authToken = await AsyncStorage.getItem('authCookies');
if (!authToken) {
if (serverConfig && serverConfig.StorageType === "localstorage") {
const loginResult = await api.login().catch(() => {
set({ isLoggedIn: false, isLoginModalVisible: true });
});
if (loginResult && loginResult.ok) {
set({ isLoggedIn: true });
}
} else {
set({ isLoggedIn: false, isLoginModalVisible: true });
});
if (loginResult && loginResult.ok) {
set({ isLoggedIn: true });
}
} else {
const isLoggedIn = cookies && !!cookies.auth;
set({ isLoggedIn });
if (!isLoggedIn) {
set({ isLoginModalVisible: true });
}
set({ isLoggedIn: true, isLoginModalVisible: false });
}
} catch (error) {
logger.error("Failed to check login status:", error);
@@ -82,7 +83,7 @@ const useAuthStore = create<AuthState>((set) => ({
},
logout: async () => {
try {
await Cookies.clearAll();
await api.logout();
set({ isLoggedIn: false, isLoginModalVisible: true });
} catch (error) {
logger.error("Failed to logout:", error);

View File

@@ -55,6 +55,26 @@ const initialCategories: Category[] = [
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
];
// 添加缓存项接口
interface CacheItem {
data: RowItem[];
timestamp: number;
type: 'movie' | 'tv' | 'record';
hasMore: boolean;
}
const CACHE_EXPIRE_TIME = 5 * 60 * 1000; // 5分钟过期
const MAX_CACHE_SIZE = 10; // 最大缓存容量
const MAX_ITEMS_PER_CACHE = 40; // 每个缓存最大条目数
const getCacheKey = (category: Category) => {
return `${category.type || 'unknown'}-${category.title}-${category.tag || ''}`;
};
const isValidCache = (cacheItem: CacheItem) => {
return Date.now() - cacheItem.timestamp < CACHE_EXPIRE_TIME;
};
interface HomeState {
categories: Category[];
selectedCategory: Category;
@@ -72,7 +92,7 @@ interface HomeState {
}
// 内存缓存,应用生命周期内有效
const dataCache = new Map<string, RowItem[]>();
const dataCache = new Map<string, CacheItem>();
const useHomeStore = create<HomeState>((set, get) => ({
categories: initialCategories,
@@ -87,29 +107,30 @@ const useHomeStore = create<HomeState>((set, get) => ({
fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
const { selectedCategory } = get();
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
const cacheKey = getCacheKey(selectedCategory);
// 最近播放不缓存,始终实时获取
if (selectedCategory.type === 'record') {
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
return;
}
// 检查缓存
if (dataCache.has(cacheKey)) {
set({
loading: false,
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
error: null
if (dataCache.has(cacheKey) && isValidCache(dataCache.get(cacheKey)!)) {
const cachedData = dataCache.get(cacheKey)!;
set({
loading: false,
contentData: cachedData.data,
pageStart: cachedData.data.length,
hasMore: cachedData.hasMore,
error: null
});
return;
}
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
},
@@ -151,34 +172,74 @@ const useHomeStore = create<HomeState>((set, get) => ({
set({ contentData: rowItems, hasMore: false });
} else if (selectedCategory.type && selectedCategory.tag) {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) {
set({ hasMore: false });
} else {
const newItems = result.list.map((item) => ({
...item,
id: item.title,
source: "douban",
})) as RowItem[];
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
if (pageStart === 0) {
// 缓存新数据
dataCache.set(cacheKey, newItems);
set({
contentData: newItems,
pageStart: result.list.length,
hasMore: true,
});
} else {
// 增量加载时不缓存,直接追加
set((state) => ({
contentData: [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
const result = await api.getDoubanData(
selectedCategory.type,
selectedCategory.tag,
20,
pageStart
);
const newItems = result.list.map((item) => ({
...item,
id: item.title,
source: "douban",
})) as RowItem[];
const cacheKey = getCacheKey(selectedCategory);
if (pageStart === 0) {
// 清理过期缓存
for (const [key, value] of dataCache.entries()) {
if (!isValidCache(value)) {
dataCache.delete(key);
}
}
// 如果缓存太大,删除最旧的项
if (dataCache.size >= MAX_CACHE_SIZE) {
const oldestKey = Array.from(dataCache.keys())[0];
dataCache.delete(oldestKey);
}
// 限制缓存的数据条目数,但不限制显示的数据
const cacheItems = newItems.slice(0, MAX_ITEMS_PER_CACHE);
// 存储新缓存
dataCache.set(cacheKey, {
data: cacheItems,
timestamp: Date.now(),
type: selectedCategory.type,
hasMore: true // 始终为 true因为我们允许继续加载
});
set({
contentData: newItems, // 使用完整的新数据
pageStart: newItems.length,
hasMore: result.list.length !== 0,
});
} else {
// 增量加载时更新缓存
const existingCache = dataCache.get(cacheKey);
if (existingCache) {
// 只有当缓存数据少于最大限制时才更新缓存
if (existingCache.data.length < MAX_ITEMS_PER_CACHE) {
const updatedData = [...existingCache.data, ...newItems];
const limitedCacheData = updatedData.slice(0, MAX_ITEMS_PER_CACHE);
dataCache.set(cacheKey, {
...existingCache,
data: limitedCacheData,
hasMore: true // 始终为 true因为我们允许继续加载
});
}
}
// 更新状态时使用所有数据
set((state) => ({
contentData: [...state.contentData, ...newItems],
pageStart: state.pageStart + newItems.length,
hasMore: result.list.length !== 0,
}));
}
} else if (selectedCategory.tags) {
// It's a container category, do not load content, but clear current content
@@ -188,11 +249,12 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
} catch (err: any) {
let errorMessage = "加载失败,请重试";
if (err.message === "API_URL_NOT_SET") {
errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
} else if (err.message === "UNAUTHORIZED") {
errorMessage = "认证失败,请重新登录";
useAuthStore.setState({ isLoggedIn: false, isLoginModalVisible: true });
} else if (err.message.includes("Network")) {
errorMessage = "网络连接失败,请检查网络连接";
} else if (err.message.includes("timeout")) {
@@ -204,7 +266,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
} else if (err.message.includes("403")) {
errorMessage = "访问被拒绝,请检查权限设置";
}
set({ error: errorMessage });
} finally {
set({ loading: false, loadingMore: false });
@@ -213,27 +275,35 @@ const useHomeStore = create<HomeState>((set, get) => ({
selectCategory: (category: Category) => {
const currentCategory = get().selectedCategory;
const cacheKey = `${category.title}-${category.tag || ''}`;
// 只有当分类或标签真正变化时才处理
const cacheKey = getCacheKey(category);
if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) {
set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null });
// 最近播放始终实时获取
set({
selectedCategory: category,
contentData: [],
pageStart: 0,
hasMore: true,
error: null
});
if (category.type === 'record') {
get().fetchInitialData();
return;
}
// 检查缓存,有则直接使用,无则请求
if (dataCache.has(cacheKey)) {
set({
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
loading: false
const cachedData = dataCache.get(cacheKey);
if (cachedData && isValidCache(cachedData)) {
set({
contentData: cachedData.data,
pageStart: cachedData.data.length,
hasMore: cachedData.hasMore,
loading: false
});
} else {
// 删除过期缓存
if (cachedData) {
dataCache.delete(cacheKey);
}
get().fetchInitialData();
}
}
@@ -273,10 +343,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
return {};
});
get().fetchInitialData();
},
clearError: () => {
set({ error: null });
},

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { SettingsManager } from "@/services/storage";
import { api, ServerConfig } from "@/services/api";
import { storageConfig } from "@/services/storageConfig";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Logger from "@/utils/Logger";
const logger = Logger.withTag('SettingsStore');
@@ -79,7 +80,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
const currentSettings = await SettingsManager.get()
const currentApiBaseUrl = currentSettings.apiBaseUrl;
let processedApiBaseUrl = apiBaseUrl.trim();
if (processedApiBaseUrl.endsWith("/")) {
processedApiBaseUrl = processedApiBaseUrl.slice(0, -1);
@@ -105,6 +107,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
remoteInputEnabled,
videoSource,
});
if ( currentApiBaseUrl !== processedApiBaseUrl) {
await AsyncStorage.setItem('authCookies', '');
}
api.setBaseUrl(processedApiBaseUrl);
// Also update the URL in the state so the input field shows the processed URL
set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl });

View File

@@ -4592,6 +4592,11 @@ expo-font@~12.0.10, expo-font@~12.0.7:
dependencies:
fontfaceobserver "^2.1.0"
expo-intent-launcher@~11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-11.0.1.tgz#297dc4d084b1e3e2fab431afc847800f87cd1dc2"
integrity sha512-nUmTTa/HG4jUyRc5YHngdpP5bMyGSRZPi2RX9kpILd3vbMWQeVnwzqAfC+uI34W8uKhEk+9b9Dytzmm7bBND1Q==
expo-keep-awake@~13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"
@@ -7775,7 +7780,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -7944,6 +7949,19 @@ react-native-helmet-async@2.0.4:
react-fast-compare "^3.2.2"
shallowequal "^1.1.0"
react-native-iphone-x-helper@^1.0.3:
version "1.3.1"
resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010"
integrity sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==
react-native-keyboard-aware-scroll-view@^0.9.5:
version "0.9.5"
resolved "https://registry.yarnpkg.com/react-native-keyboard-aware-scroll-view/-/react-native-keyboard-aware-scroll-view-0.9.5.tgz#e2e9665d320c188e6b1f22f151b94eb358bf9b71"
integrity sha512-XwfRn+T/qBH9WjTWIBiJD2hPWg0yJvtaEw6RtPCa5/PYHabzBaWxYBOl0usXN/368BL1XktnZPh8C2lmTpOREA==
dependencies:
prop-types "^15.6.2"
react-native-iphone-x-helper "^1.0.3"
react-native-media-console@*:
version "2.2.4"
resolved "https://registry.yarnpkg.com/react-native-media-console/-/react-native-media-console-2.2.4.tgz#76a232cdcb645cfdb25bacddee514f360eb4947d"
@@ -8832,7 +8850,16 @@ string-length@^5.0.1:
char-regex "^2.0.0"
strip-ansi "^7.0.1"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8923,7 +8950,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8937,6 +8964,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -9768,7 +9802,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9786,6 +9820,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"