9 Commits

Author SHA1 Message Date
zimplexing
09c3931117 chore(version): bump version to 1.3.1 2025-08-14 14:08:30 +08:00
zimplexing
10a806a657 feat(api): implement API configuration validation and error handling in home screen and update section 2025-08-14 14:08:11 +08:00
zimplexing
cb3f694cdc refactor(build): simplify prebuild and build commands in workflow configuration 2025-08-14 11:14:01 +08:00
zimplexing
1cf3733ee2 refactor(config): clean up and standardize code formatting in configuration files 2025-08-14 11:08:54 +08:00
zimplexing
108c20cd26 refactor(build): streamline prebuild and build scripts for consistency 2025-08-13 21:23:51 +08:00
Xin
250c42e1ff Update package.json 2025-08-13 21:04:12 +08:00
Xin
68a1bc2081 Update app.json 2025-08-13 20:21:13 +08:00
Xin
d8e47dee7b Update build-apk.yml 2025-08-13 20:16:36 +08:00
Xin
5bf0d05820 Merge pull request #107 from zimplexing/v1.3.0
feat: Enhance mobile and tablet support with responsive layout and auto update
2025-08-13 20:11:34 +08:00
13 changed files with 363 additions and 231 deletions

View File

@@ -7,7 +7,7 @@ permissions:
contents: write contents: write
jobs: jobs:
build_tv: direct_build:
name: Build Android TV APK name: Build Android TV APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -34,97 +34,24 @@ jobs:
java-version: "17" java-version: "17"
- name: Prebuild TV App - name: Prebuild TV App
run: yarn prebuild-tv run: yarn prebuild
- name: Build TV APK - name: Build TV APK
run: yarn build-tv run: yarn build
- name: Upload TV APK - name: Rename APK file
uses: actions/upload-artifact@v3
with:
name: orion-tv-apk
path: android/app/build/outputs/apk/release/app-release.apk
build_mobile:
name: Build Android Mobile APK
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get version from package.json
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Setup JDK
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "17"
- name: Prebuild Mobile App
run: yarn prebuild-mobile
- name: Build Mobile APK
run: yarn build-mobile
- name: Upload Mobile APK
uses: actions/upload-artifact@v3
with:
name: orion-mobile-apk
path: android/app/build/outputs/apk/release/app-release.apk
release:
name: Create Release
needs: [build_tv, build_mobile]
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Get version from package.json
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Download TV APK
uses: actions/download-artifact@v3
with:
name: orion-tv-apk
path: artifacts/tv
- name: Download Mobile APK
uses: actions/download-artifact@v3
with:
name: orion-mobile-apk
path: artifacts/mobile
- name: Rename APK files
run: | run: |
mv artifacts/tv/app-release.apk artifacts/orionTV-tv.${{ steps.package-version.outputs.version }}.apk mkdir -p artifacts
mv artifacts/mobile/app-release.apk artifacts/orionTV-mobile.${{ steps.package-version.outputs.version }}.apk cp android/app/build/outputs/apk/release/app-release.apk artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
- name: Create Release and Upload APKs - name: Create Release and Upload APK
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ steps.package-version.outputs.version }} tag_name: v${{ steps.package-version.outputs.version }}
name: Release v${{ steps.package-version.outputs.version }} name: Release v${{ steps.package-version.outputs.version }}
body: | body: Automated release for version v${{ steps.package-version.outputs.version }}.
Automated release for version v${{ steps.package-version.outputs.version }}.
- orionTV-tv.${{ steps.package-version.outputs.version }}.apk - Android TV版本
- orionTV-mobile.${{ steps.package-version.outputs.version }}.apk - 手机/平板版本
draft: false draft: false
prerelease: false prerelease: false
files: | files: artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
artifacts/orionTV-tv.${{ steps.package-version.outputs.version }}.apk
artifacts/orionTV-mobile.${{ steps.package-version.outputs.version }}.apk
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -40,11 +40,6 @@
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#000000"
},
"assetBundlePatterns": [ "assetBundlePatterns": [
"**/*" "**/*"
], ],
@@ -54,10 +49,6 @@
"hardwareAcceleration": true, "hardwareAcceleration": true,
"networkSecurityConfig": "@xml/network_security_config", "networkSecurityConfig": "@xml/network_security_config",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#000000"
},
"permissions": [ "permissions": [
"android.permission.INTERNET", "android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE", "android.permission.ACCESS_NETWORK_STATE",
@@ -93,11 +84,6 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
} }
}, },
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"scheme": "oriontv", "scheme": "oriontv",
"extra": { "extra": {
"router": { "router": {

View File

@@ -10,11 +10,11 @@ import { Search, Settings, LogOut, Heart } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton"; import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore"; import useAuthStore from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
import CustomScrollView from "@/components/CustomScrollView"; import CustomScrollView from "@/components/CustomScrollView";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout"; import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles"; import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation"; import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
const LOAD_MORE_THRESHOLD = 200; const LOAD_MORE_THRESHOLD = 200;
@@ -44,7 +44,7 @@ export default function HomeScreen() {
clearError, clearError,
} = useHomeStore(); } = useHomeStore();
const { isLoggedIn, logout } = useAuthStore(); const { isLoggedIn, logout } = useAuthStore();
const { apiBaseUrl } = useSettingsStore(); const apiConfigStatus = useApiConfig();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -52,34 +52,44 @@ export default function HomeScreen() {
}, [refreshPlayRecords]) }, [refreshPlayRecords])
); );
// 统一的数据获取逻辑
useEffect(() => { useEffect(() => {
// 只有在 apiBaseUrl 存在时才调用 fetchInitialData避免时序问题 if (!selectedCategory) return;
if (selectedCategory && !selectedCategory.tags && apiBaseUrl) {
fetchInitialData(); // 如果是容器分类且没有选择标签,设置默认标签
} else if (selectedCategory?.tags && !selectedCategory.tag) { if (selectedCategory.tags && !selectedCategory.tag) {
const defaultTag = selectedCategory.tags[0]; const defaultTag = selectedCategory.tags[0];
setSelectedTag(defaultTag); setSelectedTag(defaultTag);
selectCategory({ ...selectedCategory, tag: defaultTag }); selectCategory({ ...selectedCategory, tag: defaultTag });
return;
} }
}, [selectedCategory, fetchInitialData, selectCategory, apiBaseUrl]);
useEffect(() => { // 只有在API配置完成且分类有效时才获取数据
// 只有在 apiBaseUrl 存在时才调用 fetchInitialData避免时序问题 if (apiConfigStatus.isConfigured && !apiConfigStatus.needsConfiguration) {
if (selectedCategory && selectedCategory.tag && apiBaseUrl) { // 对于有标签的分类,需要确保有标签才获取数据
fetchInitialData(); if (selectedCategory.tags && selectedCategory.tag) {
fetchInitialData();
}
// 对于无标签的分类,直接获取数据
else if (!selectedCategory.tags) {
fetchInitialData();
}
} }
}, [fetchInitialData, selectedCategory, selectedCategory.tag, apiBaseUrl]); }, [
selectedCategory,
selectedCategory?.tag,
apiConfigStatus.isConfigured,
apiConfigStatus.needsConfiguration,
fetchInitialData,
selectCategory,
]);
// 检查是否需要显示API配置提示 // 清除错误状态的逻辑
const shouldShowApiConfig = !apiBaseUrl && selectedCategory && !selectedCategory.tags;
// 清除错误状态当API未配置时
useEffect(() => { useEffect(() => {
if (shouldShowApiConfig && error) { if (apiConfigStatus.needsConfiguration && error) {
// 如果需要显示API配置提示清除之前的错误状态
clearError(); clearError();
} }
}, [shouldShowApiConfig, error, clearError]); }, [apiConfigStatus.needsConfiguration, error, clearError]);
useEffect(() => { useEffect(() => {
if (!loading && contentData.length > 0) { if (!loading && contentData.length > 0) {
@@ -119,7 +129,7 @@ export default function HomeScreen() {
); );
}; };
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => ( const renderContentItem = ({ item }: { item: RowItem; index: number }) => (
<VideoCard <VideoCard
id={item.id} id={item.id}
source={item.source} source={item.source}
@@ -142,6 +152,9 @@ export default function HomeScreen() {
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />; return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
}; };
// 检查是否需要显示API配置提示
const shouldShowApiConfig = apiConfigStatus.needsConfiguration && selectedCategory && !selectedCategory.tags;
// TV端和平板端的顶部导航 // TV端和平板端的顶部导航
const renderHeader = () => { const renderHeader = () => {
if (deviceType === "mobile") { if (deviceType === "mobile") {
@@ -280,8 +293,21 @@ export default function HomeScreen() {
{/* 内容网格 */} {/* 内容网格 */}
{shouldShowApiConfig ? ( {shouldShowApiConfig ? (
<View style={commonStyles.center}> <View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: 'center' }}> <ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
{getApiConfigErrorMessage(apiConfigStatus)}
</ThemedText>
</View>
) : apiConfigStatus.isValidating ? (
<View style={commonStyles.center}>
<ActivityIndicator size="large" />
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
...
</ThemedText>
</View>
) : apiConfigStatus.error && !apiConfigStatus.isValid ? (
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
{apiConfigStatus.error}
</ThemedText> </ThemedText>
</View> </View>
) : loading ? ( ) : loading ? (

View File

@@ -195,7 +195,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
{isContinueWatching && ( {isContinueWatching && (
<View style={styles.infoRow}> <View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}> <ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}% {episodeIndex} {Math.round((progress || 0) * 100)}%
</ThemedText> </ThemedText>
</View> </View>
)} )}
@@ -343,4 +343,4 @@ const styles = StyleSheet.create({
color: Colors.dark.primary, color: Colors.dark.primary,
fontSize: 12, fontSize: 12,
}, },
}); });

View File

@@ -3,11 +3,19 @@ import { View, StyleSheet, Platform, ActivityIndicator } from "react-native";
import { ThemedText } from "../ThemedText"; import { ThemedText } from "../ThemedText";
import { StyledButton } from "../StyledButton"; import { StyledButton } from "../StyledButton";
import { useUpdateStore } from "@/stores/updateStore"; import { useUpdateStore } from "@/stores/updateStore";
import { UPDATE_CONFIG } from "@/constants/UpdateConfig"; // import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
export function UpdateSection() { export function UpdateSection() {
const { currentVersion, remoteVersion, updateAvailable, downloading, downloadProgress, checkForUpdate } = const {
useUpdateStore(); currentVersion,
remoteVersion,
updateAvailable,
downloading,
downloadProgress,
checkForUpdate,
isLatestVersion,
error
} = useUpdateStore();
const [checking, setChecking] = React.useState(false); const [checking, setChecking] = React.useState(false);
@@ -36,6 +44,20 @@ export function UpdateSection() {
</View> </View>
)} )}
{isLatestVersion && remoteVersion && (
<View style={styles.row}>
<ThemedText style={styles.label}></ThemedText>
<ThemedText style={[styles.value, styles.latestVersion]}></ThemedText>
</View>
)}
{error && (
<View style={styles.row}>
<ThemedText style={styles.label}></ThemedText>
<ThemedText style={[styles.value, styles.errorText]}>{error}</ThemedText>
</View>
)}
{downloading && ( {downloading && (
<View style={styles.row}> <View style={styles.row}>
<ThemedText style={styles.label}></ThemedText> <ThemedText style={styles.label}></ThemedText>
@@ -96,6 +118,14 @@ const styles = StyleSheet.create({
color: "#00bb5e", color: "#00bb5e",
fontWeight: "bold", fontWeight: "bold",
}, },
latestVersion: {
color: "#00bb5e",
fontWeight: "500",
},
errorText: {
color: "#ff6b6b",
fontWeight: "500",
},
buttonContainer: { buttonContainer: {
flexDirection: "row", flexDirection: "row",
gap: 12, gap: 12,

View File

@@ -1,39 +1,36 @@
import { DeviceUtils } from '@/utils/DeviceUtils';
export const UPDATE_CONFIG = { export const UPDATE_CONFIG = {
// 自动检查更新 // 自动检查更新
AUTO_CHECK: true, AUTO_CHECK: true,
// 检查更新间隔(毫秒) // 检查更新间隔(毫秒)
CHECK_INTERVAL: 12 * 60 * 60 * 1000, // 12小时 CHECK_INTERVAL: 12 * 60 * 60 * 1000, // 12小时
// GitHub相关URL // GitHub相关URL
GITHUB_RAW_URL: 'https://ghfast.top/https://raw.githubusercontent.com/zimplexing/OrionTV/refs/heads/master/package.json', GITHUB_RAW_URL:
"https://gh-proxy.com/https://raw.githubusercontent.com/zimplexing/OrionTV/refs/heads/master/package.json",
// 获取平台特定的下载URL // 获取平台特定的下载URL
getDownloadUrl(version: string): string { getDownloadUrl(version: string): string {
const isTV = DeviceUtils.isTV(); return `https://gh-proxy.com/https://github.com/zimplexing/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
const platform = isTV ? 'tv' : 'mobile';
return `https://ghfast.top/https://github.com/zimplexing/OrionTV/releases/download/v${version}/orionTV-${platform}.${version}.apk`;
}, },
// 是否显示更新日志 // 是否显示更新日志
SHOW_RELEASE_NOTES: true, SHOW_RELEASE_NOTES: true,
// 是否允许跳过版本 // 是否允许跳过版本
ALLOW_SKIP_VERSION: true, ALLOW_SKIP_VERSION: true,
// 下载超时时间(毫秒) // 下载超时时间(毫秒)
DOWNLOAD_TIMEOUT: 10 * 60 * 1000, // 10分钟 DOWNLOAD_TIMEOUT: 10 * 60 * 1000, // 10分钟
// 是否在WIFI下自动下载 // 是否在WIFI下自动下载
AUTO_DOWNLOAD_ON_WIFI: false, AUTO_DOWNLOAD_ON_WIFI: false,
// 更新通知设置 // 更新通知设置
NOTIFICATION: { NOTIFICATION: {
ENABLED: true, ENABLED: true,
TITLE: 'OrionTV 更新', TITLE: "OrionTV 更新",
DOWNLOADING_TEXT: '正在下载新版本...', DOWNLOADING_TEXT: "正在下载新版本...",
DOWNLOAD_COMPLETE_TEXT: '下载完成,点击安装', DOWNLOAD_COMPLETE_TEXT: "下载完成,点击安装",
}, },
}; };

139
hooks/useApiConfig.ts Normal file
View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { useSettingsStore } from '@/stores/settingsStore';
import { api } from '@/services/api';
export interface ApiConfigStatus {
isConfigured: boolean;
isValidating: boolean;
isValid: boolean | null;
error: string | null;
needsConfiguration: boolean;
}
export const useApiConfig = () => {
const { apiBaseUrl, serverConfig, isLoadingServerConfig } = useSettingsStore();
const [validationState, setValidationState] = useState<{
isValidating: boolean;
isValid: boolean | null;
error: string | null;
}>({
isValidating: false,
isValid: null,
error: null,
});
const isConfigured = Boolean(apiBaseUrl && apiBaseUrl.trim());
const needsConfiguration = !isConfigured;
// Validate API configuration when it changes
useEffect(() => {
if (!isConfigured) {
setValidationState({
isValidating: false,
isValid: false,
error: null,
});
return;
}
const validateConfig = async () => {
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
try {
await api.getServerConfig();
setValidationState({
isValidating: false,
isValid: true,
error: null,
});
} catch (error) {
let errorMessage = '服务器连接失败';
if (error instanceof Error) {
switch (error.message) {
case 'API_URL_NOT_SET':
errorMessage = 'API地址未设置';
break;
case 'UNAUTHORIZED':
errorMessage = '服务器认证失败';
break;
default:
if (error.message.includes('Network')) {
errorMessage = '网络连接失败,请检查网络或服务器地址';
} else if (error.message.includes('timeout')) {
errorMessage = '连接超时,请检查服务器地址';
} else if (error.message.includes('404')) {
errorMessage = '服务器地址无效请检查API路径';
} else if (error.message.includes('500')) {
errorMessage = '服务器内部错误';
}
break;
}
}
setValidationState({
isValidating: false,
isValid: false,
error: errorMessage,
});
}
};
// Only validate if not already loading server config
if (!isLoadingServerConfig) {
validateConfig();
}
}, [apiBaseUrl, isConfigured, isLoadingServerConfig]);
// Reset validation when server config loading state changes
useEffect(() => {
if (isLoadingServerConfig) {
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
}
}, [isLoadingServerConfig]);
// Update validation state based on server config
useEffect(() => {
if (!isLoadingServerConfig && isConfigured) {
if (serverConfig) {
setValidationState(prev => ({ ...prev, isValid: true, error: null }));
} else {
setValidationState(prev => ({
...prev,
isValid: false,
error: prev.error || '无法获取服务器配置'
}));
}
}
}, [serverConfig, isLoadingServerConfig, isConfigured]);
const status: ApiConfigStatus = {
isConfigured,
isValidating: validationState.isValidating || isLoadingServerConfig,
isValid: validationState.isValid,
error: validationState.error,
needsConfiguration,
};
return status;
};
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

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Dimensions, Platform } from 'react-native'; import { Dimensions, Platform } from "react-native";
export type DeviceType = 'mobile' | 'tablet' | 'tv'; export type DeviceType = "mobile" | "tablet" | "tv";
export interface ResponsiveConfig { export interface ResponsiveConfig {
deviceType: DeviceType; deviceType: DeviceType;
@@ -17,40 +17,44 @@ export interface ResponsiveConfig {
const BREAKPOINTS = { const BREAKPOINTS = {
mobile: { min: 0, max: 767 }, mobile: { min: 0, max: 767 },
tablet: { min: 768, max: 1023 }, tablet: { min: 768, max: 1023 },
tv: { min: 1024, max: Infinity } tv: { min: 1024, max: Infinity },
}; };
const getDeviceType = (width: number): DeviceType => { const getDeviceType = (width: number): DeviceType => {
const isTV = process.env.EXPO_TV === '1' || Platform.isTV; if (Platform.isTV) return "tv";
if (isTV) return 'tv';
if (width >= BREAKPOINTS.tv.min) return "tv";
if (width >= BREAKPOINTS.tv.min) return 'tv'; if (width >= BREAKPOINTS.tablet.min) return "tablet";
if (width >= BREAKPOINTS.tablet.min) return 'tablet'; return "mobile";
return 'mobile';
}; };
const getLayoutConfig = (deviceType: DeviceType, width: number, height: number, isPortrait: boolean): ResponsiveConfig => { const getLayoutConfig = (
const spacing = deviceType === 'mobile' ? 8 : deviceType === 'tablet' ? 12 : 16; deviceType: DeviceType,
width: number,
height: number,
isPortrait: boolean
): ResponsiveConfig => {
const spacing = deviceType === "mobile" ? 8 : deviceType === "tablet" ? 12 : 16;
let columns: number; let columns: number;
let cardWidth: number; let cardWidth: number;
let cardHeight: number; let cardHeight: number;
switch (deviceType) { switch (deviceType) {
case 'mobile': case "mobile":
columns = isPortrait ? 3 : 4; columns = isPortrait ? 3 : 4;
// 使用flex布局卡片可以更大一些来填充空间 // 使用flex布局卡片可以更大一些来填充空间
cardWidth = (width - spacing) / columns * 0.85; // 增大到85% cardWidth = ((width - spacing) / columns) * 0.85; // 增大到85%
cardHeight = cardWidth * 1.2; // 5:6 aspect ratio (reduced from 2:3) cardHeight = cardWidth * 1.2; // 5:6 aspect ratio (reduced from 2:3)
break; break;
case 'tablet': case "tablet":
columns = isPortrait ? 3 : 4; columns = isPortrait ? 3 : 4;
cardWidth = (width - spacing) / columns * 0.85; // 增大到85% cardWidth = ((width - spacing) / columns) * 0.85; // 增大到85%
cardHeight = cardWidth * 1.4; // slightly less tall ratio cardHeight = cardWidth * 1.4; // slightly less tall ratio
break; break;
case 'tv': case "tv":
default: default:
columns = 5; columns = 5;
cardWidth = 160; // Fixed width for TV cardWidth = 160; // Fixed width for TV
@@ -72,12 +76,12 @@ const getLayoutConfig = (deviceType: DeviceType, width: number, height: number,
export const useResponsiveLayout = (): ResponsiveConfig => { export const useResponsiveLayout = (): ResponsiveConfig => {
const [dimensions, setDimensions] = useState(() => { const [dimensions, setDimensions] = useState(() => {
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get("window");
return { width, height }; return { width, height };
}); });
useEffect(() => { useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => { const subscription = Dimensions.addEventListener("change", ({ window }) => {
setDimensions({ width: window.width, height: window.height }); setDimensions({ width: window.width, height: window.height });
}); });
@@ -87,7 +91,7 @@ export const useResponsiveLayout = (): ResponsiveConfig => {
const { width, height } = dimensions; const { width, height } = dimensions;
const isPortrait = height > width; const isPortrait = height > width;
const deviceType = getDeviceType(width); const deviceType = getDeviceType(width);
return getLayoutConfig(deviceType, width, height, isPortrait); return getLayoutConfig(deviceType, width, height, isPortrait);
}; };
@@ -100,31 +104,31 @@ export const useResponsiveValue = <T>(values: { mobile: T; tablet: T; tv: T }):
// Utility hook for responsive styles // Utility hook for responsive styles
export const useResponsiveStyles = () => { export const useResponsiveStyles = () => {
const config = useResponsiveLayout(); const config = useResponsiveLayout();
return { return {
// Common responsive styles // Common responsive styles
container: { container: {
paddingHorizontal: config.spacing, paddingHorizontal: config.spacing,
}, },
// Card styles // Card styles
cardContainer: { cardContainer: {
width: config.cardWidth, width: config.cardWidth,
height: config.cardHeight, height: config.cardHeight,
marginBottom: config.spacing, marginBottom: config.spacing,
}, },
// Grid styles // Grid styles
gridContainer: { gridContainer: {
paddingHorizontal: config.spacing / 2, paddingHorizontal: config.spacing / 2,
}, },
// Typography // Typography
titleFontSize: config.deviceType === 'mobile' ? 18 : config.deviceType === 'tablet' ? 22 : 28, titleFontSize: config.deviceType === "mobile" ? 18 : config.deviceType === "tablet" ? 22 : 28,
bodyFontSize: config.deviceType === 'mobile' ? 14 : config.deviceType === 'tablet' ? 16 : 18, bodyFontSize: config.deviceType === "mobile" ? 14 : config.deviceType === "tablet" ? 16 : 18,
// Spacing // Spacing
sectionSpacing: config.deviceType === 'mobile' ? 16 : config.deviceType === 'tablet' ? 20 : 24, sectionSpacing: config.deviceType === "mobile" ? 16 : config.deviceType === "tablet" ? 20 : 24,
itemSpacing: config.spacing, itemSpacing: config.spacing,
}; };
}; };

View File

@@ -1,6 +1,6 @@
// Learn more https://docs.expo.io/guides/customizing-metro // Learn more https://docs.expo.io/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config'); const { getDefaultConfig } = require("expo/metro-config");
const path = require('path'); const path = require("path");
// Find the project and workspace directories // Find the project and workspace directories
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
@@ -16,24 +16,24 @@ const config = getDefaultConfig(projectRoot);
// Metro will still resolve source files with standard extensions // Metro will still resolve source files with standard extensions
// as usual if TV-specific files are not found for a module. // as usual if TV-specific files are not found for a module.
// //
if (process.env?.EXPO_TV === '1') { // if (process.env?.EXPO_TV === '1') {
const originalSourceExts = config.resolver.sourceExts; // const originalSourceExts = config.resolver.sourceExts;
const tvSourceExts = [ // const tvSourceExts = [
...originalSourceExts.map((e) => `tv.${e}`), // ...originalSourceExts.map((e) => `tv.${e}`),
...originalSourceExts, // ...originalSourceExts,
]; // ];
config.resolver.sourceExts = tvSourceExts; // config.resolver.sourceExts = tvSourceExts;
} // }
// This can be replaced with `find-yarn-workspace-root` // This can be replaced with `find-yarn-workspace-root`
const monorepoRoot = path.resolve(projectRoot, '../..'); const monorepoRoot = path.resolve(projectRoot, "../..");
// 1. Watch all files within the monorepo // 1. Watch all files within the monorepo
config.watchFolders = [monorepoRoot]; config.watchFolders = [monorepoRoot];
// 2. Let Metro know where to resolve packages and in what order // 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [ config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'), path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, 'node_modules'), path.resolve(monorepoRoot, "node_modules"),
]; ];
config.resolver.disableHierarchicalLookup = true; config.resolver.disableHierarchicalLookup = true;

View File

@@ -2,24 +2,14 @@
"name": "OrionTV", "name": "OrionTV",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.3.0", "version": "1.3.1",
"scripts": { "scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
"start-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start --tunnel", "ios": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
"android": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android", "prebuild": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean && yarn copy-config",
"android-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
"android-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android --device",
"ios": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
"ios-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
"ios-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios --device",
"prebuild": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean",
"prebuild-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean && yarn copy-config",
"prebuild-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean --platform android && yarn copy-config",
"copy-config": "cp -r xml/* android/app/src/*", "copy-config": "cp -r xml/* android/app/src/*",
"build": "cd android && ./gradlew assembleRelease", "build": "EXPO_TV=1 yarn prebuild && cd android && ./gradlew assembleRelease",
"build-tv": "EXPO_TV=1 yarn prebuild-tv && cd android && ./gradlew assembleRelease",
"build-mobile": "yarn prebuild-mobile && cd android && ./gradlew assembleRelease",
"build-debug": "cd android && ./gradlew assembleDebug", "build-debug": "cd android && ./gradlew assembleDebug",
"test": "jest --watchAll", "test": "jest --watchAll",
"test-ci": "jest --ci --coverage --no-cache", "test-ci": "jest --ci --coverage --no-cache",

View File

@@ -166,11 +166,11 @@ const useHomeStore = create<HomeState>((set, get) => ({
if (pageStart === 0) { if (pageStart === 0) {
// 缓存新数据 // 缓存新数据
dataCache.set(cacheKey, newItems); dataCache.set(cacheKey, newItems);
set((state) => ({ set({
contentData: newItems, contentData: newItems,
pageStart: result.list.length, pageStart: result.list.length,
hasMore: true, hasMore: true,
})); });
} else { } else {
// 增量加载时不缓存,直接追加 // 增量加载时不缓存,直接追加
set((state) => ({ set((state) => ({
@@ -187,11 +187,25 @@ const useHomeStore = create<HomeState>((set, get) => ({
set({ hasMore: false }); set({ hasMore: false });
} }
} catch (err: any) { } catch (err: any) {
let errorMessage = "加载失败,请重试";
if (err.message === "API_URL_NOT_SET") { if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的服务器地址" }); errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
} else { } else if (err.message === "UNAUTHORIZED") {
set({ error: "加载失败,请重试" }); errorMessage = "认证失败,请重新登录";
} else if (err.message.includes("Network")) {
errorMessage = "网络连接失败,请检查网络连接";
} else if (err.message.includes("timeout")) {
errorMessage = "请求超时,请检查网络或服务器状态";
} else if (err.message.includes("404")) {
errorMessage = "服务器API路径不正确请检查服务器配置";
} else if (err.message.includes("500")) {
errorMessage = "服务器内部错误,请联系管理员";
} else if (err.message.includes("403")) {
errorMessage = "访问被拒绝,请检查权限设置";
} }
set({ error: errorMessage });
} finally { } finally {
set({ loading: false, loadingMore: false }); set({ loading: false, loadingMore: false });
} }

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import updateService from '../services/updateService'; import updateService from '../services/updateService';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import Toast from 'react-native-toast-message';
interface UpdateState { interface UpdateState {
// 状态 // 状态
@@ -15,6 +16,7 @@ interface UpdateState {
lastCheckTime: number; lastCheckTime: number;
skipVersion: string | null; skipVersion: string | null;
showUpdateModal: boolean; showUpdateModal: boolean;
isLatestVersion: boolean; // 新增:是否已是最新版本
// 操作 // 操作
checkForUpdate: (silent?: boolean) => Promise<void>; checkForUpdate: (silent?: boolean) => Promise<void>;
@@ -43,11 +45,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
lastCheckTime: 0, lastCheckTime: 0,
skipVersion: null, skipVersion: null,
showUpdateModal: false, showUpdateModal: false,
isLatestVersion: false, // 新增初始为false
// 检查更新 // 检查更新
checkForUpdate: async (silent = false) => { checkForUpdate: async (silent = false) => {
try { try {
set({ error: null }); set({ error: null, isLatestVersion: false });
// 获取跳过的版本 // 获取跳过的版本
const skipVersion = await AsyncStorage.getItem(STORAGE_KEYS.SKIP_VERSION); const skipVersion = await AsyncStorage.getItem(STORAGE_KEYS.SKIP_VERSION);
@@ -58,6 +61,9 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// 如果有更新且不是要跳过的版本 // 如果有更新且不是要跳过的版本
const shouldShowUpdate = isUpdateAvailable && versionInfo.version !== skipVersion; const shouldShowUpdate = isUpdateAvailable && versionInfo.version !== skipVersion;
// 检查是否已经是最新版本
const isLatest = !isUpdateAvailable;
set({ set({
remoteVersion: versionInfo.version, remoteVersion: versionInfo.version,
downloadUrl: versionInfo.downloadUrl, downloadUrl: versionInfo.downloadUrl,
@@ -65,8 +71,19 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
lastCheckTime: Date.now(), lastCheckTime: Date.now(),
skipVersion, skipVersion,
showUpdateModal: shouldShowUpdate && !silent, showUpdateModal: shouldShowUpdate && !silent,
isLatestVersion: isLatest,
}); });
// 如果是手动检查且已是最新版本,显示提示
if (!silent && isLatest) {
Toast.show({
type: 'success',
text1: '已是最新版本',
text2: `当前版本 v${updateService.getCurrentVersion()} 已是最新版本`,
visibilityTime: 3000,
});
}
// 保存最后检查时间 // 保存最后检查时间
await AsyncStorage.setItem( await AsyncStorage.setItem(
STORAGE_KEYS.LAST_CHECK_TIME, STORAGE_KEYS.LAST_CHECK_TIME,
@@ -76,7 +93,8 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// console.info('检查更新失败:', error); // console.info('检查更新失败:', error);
set({ set({
error: error instanceof Error ? error.message : '检查更新失败', error: error instanceof Error ? error.message : '检查更新失败',
updateAvailable: false updateAvailable: false,
isLatestVersion: false,
}); });
} }
}, },
@@ -166,6 +184,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
downloadedPath: null, downloadedPath: null,
error: null, error: null,
showUpdateModal: false, showUpdateModal: false,
isLatestVersion: false, // 重置时也要重置这个状态
}); });
}, },
})); }));

View File

@@ -1,39 +1,39 @@
import { Platform, Dimensions } from 'react-native'; import { Platform, Dimensions } from "react-native";
import { DeviceType } from '@/hooks/useResponsiveLayout'; import { DeviceType } from "@/hooks/useResponsiveLayout";
export const DeviceUtils = { export const DeviceUtils = {
/** /**
* 检测当前设备类型 * 检测当前设备类型
*/ */
getDeviceType(): DeviceType { getDeviceType(): DeviceType {
const isTV = process.env.EXPO_TV === '1' || Platform.isTV; // if (Platform.isTV) return "tv";
if (isTV) return 'tv';
const { width } = Dimensions.get("window");
const { width } = Dimensions.get('window');
if (width >= 1024) return 'tv'; if (width >= 1024) return "tv";
if (width >= 768) return 'tablet'; if (width >= 768) return "tablet";
return 'mobile'; return "mobile";
}, },
/** /**
* 检测是否为TV环境 * 检测是否为TV环境
*/ */
isTV(): boolean { isTV(): boolean {
return this.getDeviceType() === 'tv'; return this.getDeviceType() === "tv";
}, },
/** /**
* 检测是否为移动设备 * 检测是否为移动设备
*/ */
isMobile(): boolean { isMobile(): boolean {
return this.getDeviceType() === 'mobile'; return this.getDeviceType() === "mobile";
}, },
/** /**
* 检测是否为平板设备 * 检测是否为平板设备
*/ */
isTablet(): boolean { isTablet(): boolean {
return this.getDeviceType() === 'tablet'; return this.getDeviceType() === "tablet";
}, },
/** /**
@@ -56,11 +56,11 @@ export const DeviceUtils = {
getMinTouchTargetSize(): number { getMinTouchTargetSize(): number {
const deviceType = this.getDeviceType(); const deviceType = this.getDeviceType();
switch (deviceType) { switch (deviceType) {
case 'mobile': case "mobile":
return 44; // iOS HIG minimum return 44; // iOS HIG minimum
case 'tablet': case "tablet":
return 48; // Material Design minimum return 48; // Material Design minimum
case 'tv': case "tv":
return 60; // TV optimized return 60; // TV optimized
default: default:
return 44; return 44;
@@ -77,7 +77,7 @@ export const DeviceUtils = {
tablet: 1.1, tablet: 1.1,
tv: 1.25, tv: 1.25,
}[deviceType]; }[deviceType];
return Math.round(baseSize * scaleFactor); return Math.round(baseSize * scaleFactor);
}, },
@@ -91,7 +91,7 @@ export const DeviceUtils = {
tablet: 1.0, tablet: 1.0,
tv: 1.5, tv: 1.5,
}[deviceType]; }[deviceType];
return Math.round(baseSpacing * scaleFactor); return Math.round(baseSpacing * scaleFactor);
}, },
@@ -99,7 +99,7 @@ export const DeviceUtils = {
* 检测设备是否处于横屏模式 * 检测设备是否处于横屏模式
*/ */
isLandscape(): boolean { isLandscape(): boolean {
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get("window");
return width > height; return width > height;
}, },
@@ -114,10 +114,10 @@ export const DeviceUtils = {
* 获取安全的网格列数 * 获取安全的网格列数
*/ */
getSafeColumnCount(preferredColumns: number): number { getSafeColumnCount(preferredColumns: number): number {
const { width } = Dimensions.get('window'); const { width } = Dimensions.get("window");
const minCardWidth = this.isMobile() ? 120 : this.isTablet() ? 140 : 160; const minCardWidth = this.isMobile() ? 120 : this.isTablet() ? 140 : 160;
const maxColumns = Math.floor(width / minCardWidth); const maxColumns = Math.floor(width / minCardWidth);
return Math.min(preferredColumns, maxColumns); return Math.min(preferredColumns, maxColumns);
}, },
@@ -132,7 +132,7 @@ export const DeviceUtils = {
tablet: 1.0, tablet: 1.0,
tv: 1.2, tv: 1.2,
}[deviceType]; }[deviceType];
return Math.round(baseDuration * scaleFactor); return Math.round(baseDuration * scaleFactor);
}, },
}; };