diff --git a/apps/app_zen_box/App.tsx b/apps/app_zen_box/App.tsx index b09f842..e0f07ae 100644 --- a/apps/app_zen_box/App.tsx +++ b/apps/app_zen_box/App.tsx @@ -6,12 +6,13 @@ import { Audio, AVPlaybackStatus } from 'expo-av'; import * as SplashScreen from 'expo-splash-screen'; import Svg, { Path } from 'react-native-svg'; import { theme } from './theme'; -import { Track, RepeatMode, CacheInfo } from './types'; +import { Track, RepeatMode, CacheInfo, UserConfig, SortMode } from './types'; import * as API from './api'; import * as Cache from './utils/cache'; import { PlayerScreen } from './screens/PlayerScreen'; import { PlaylistScreen } from './screens/PlaylistScreen'; import { SettingsScreen } from './screens/SettingsScreen'; +import { LoginScreen } from './screens/LoginScreen'; // 安全地编码 URL 中的路径部分 const encodeUrlPath = (url: string): string => { @@ -79,8 +80,6 @@ const StartupLoadingScreen = () => ( ); -type SortMode = 'original' | 'asc' | 'desc'; - const TabNavigator = () => { const [tracks, setTracks] = useState([]); const [cacheInfo, setCacheInfo] = useState({}); @@ -98,6 +97,11 @@ const TabNavigator = () => { const [playlistFiles, setPlaylistFiles] = useState(['playlist.json']); const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json'); const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true); + const [sortMode, setSortMode] = useState('original'); + const [showLoginScreen, setShowLoginScreen] = useState(false); + const [loggedInUser, setLoggedInUser] = useState(null); + const [isOffline, setIsOffline] = useState(false); + const [offlineAvailablePlaylists, setOfflineAvailablePlaylists] = useState([]); const navigation = useNavigation(); const soundRef = useRef(null); @@ -158,6 +162,57 @@ const TabNavigator = () => { const refreshCacheInfo = () => { setCacheInfo(Cache.getCacheInfo()); }; + + const applyCloudConfig = async (config: UserConfig) => { + try { + if (config.selected_playlist) { + await API.saveSelectedPlaylist(config.selected_playlist); + setCurrentPlaylistFileState(config.selected_playlist); + } + if (config.resume_enabled !== undefined) { + await API.saveResumePlaybackEnabled(config.resume_enabled); + setResumePlaybackEnabledState(config.resume_enabled); + } + if (config.cache_only !== undefined) { + await Cache.setCacheOnlySetting(config.cache_only); + setCacheOnlyState(config.cache_only); + } + if (config.sort_mode) { + await API.saveSortPreference(config.sort_mode); + setSortMode(config.sort_mode); + } + // 恢复播放进度 + if (config.playback_positions) { + for (const [key, seconds] of Object.entries(config.playback_positions)) { + const [playlistFile, trackSrc] = key.split('::'); + if (playlistFile && trackSrc && seconds > 0) { + await API.savePlaybackPosition(playlistFile, trackSrc, seconds); + } + } + } + } catch (error) { + console.error('应用云端配置失败:', error); + } + }; + + const buildUserConfig = (): UserConfig => { + return { + selected_playlist: currentPlaylistFileRef.current, + resume_enabled: resumePlaybackEnabledRef.current, + cache_only: cacheOnlyRef.current, + sort_mode: sortMode, + playback_positions: {}, + }; + }; + + const pushConfigIfNeeded = async () => { + try { + const config = buildUserConfig(); + await API.pushCloudConfig(config); + } catch (error) { + console.error('推送云端配置失败:', error); + } + }; const persistPlaybackPosition = async ( trackIndex = currentIndexRef.current, positionSeconds = positionRef.current, @@ -269,16 +324,42 @@ const TabNavigator = () => { staysActiveInBackground: true, }); + // 检查登录态 + const user = await API.getLoggedInUser(); + setLoggedInUser(user); + + // 检查网络状态 + const connected = await API.isNetworkConnected(); + setIsOffline(!connected); + + // 如果已登录且有网络,拉取云端配置 + if (user && connected) { + const cloudConfig = await API.pullCloudConfig(); + if (cloudConfig) { + await applyCloudConfig(cloudConfig); + } + } + const cacheOnlySetting = await Cache.getCacheOnlySetting(); setCacheOnlyState(cacheOnlySetting); const resumePlaybackSetting = await API.getResumePlaybackEnabled(); setResumePlaybackEnabledState(resumePlaybackSetting); + const savedSort = await API.getSortPreference(); + setSortMode(savedSort); - const files = await API.getPlaylistFiles(); + let files = await API.getPlaylistFiles(); setPlaylistFiles(files); + + // 离线模式:反推可用歌单 + if (!connected) { + const available = await API.getOfflineAvailablePlaylists((src) => Cache.isCached(src)); + setOfflineAvailablePlaylists(available); + files = available; + } + const savedPlaylist = await API.getSavedSelectedPlaylist(); const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json'; - await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: true }); + await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: !connected }); } catch (error) { console.error('Init failed:', error); Alert.alert('错误', '无法加载播放列表'); @@ -584,6 +665,7 @@ const TabNavigator = () => { setCacheOnlyState(value); try { await Cache.setCacheOnlySetting(value); + await pushConfigIfNeeded(); } catch (error) { setCacheOnlyState(!value); throw error; @@ -593,6 +675,7 @@ const TabNavigator = () => { const handlePlaylistChange = async (playlistFile: string) => { try { await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true }); + await pushConfigIfNeeded(); } catch (error) { console.error('切换歌单失败:', error); Alert.alert('错误', '切换歌单失败'); @@ -604,6 +687,7 @@ const TabNavigator = () => { setResumePlaybackEnabledState(value); try { await API.saveResumePlaybackEnabled(value); + await pushConfigIfNeeded(); } catch (error) { setResumePlaybackEnabledState(!value); throw error; @@ -662,6 +746,34 @@ const TabNavigator = () => { navigation.navigate('Playlist'); }; + const handleLoginSuccess = async () => { + setShowLoginScreen(false); + const user = await API.getLoggedInUser(); + setLoggedInUser(user); + // 登录成功后拉取云端配置 + const connected = await API.isNetworkConnected(); + if (connected && user) { + const cloudConfig = await API.pullCloudConfig(); + if (cloudConfig) { + await applyCloudConfig(cloudConfig); + } + } + }; + + const handleLogout = async () => { + await API.logout(); + setLoggedInUser(null); + }; + + if (showLoginScreen) { + return ( + setShowLoginScreen(false)} + /> + ); + } + if (isLoading) { return ; } @@ -712,10 +824,15 @@ const TabNavigator = () => { downloadingTrackSrcs={downloadingTrackSrcs} currentIndex={currentIndex} isPlaying={isPlaying} + playlistFiles={playlistFiles} + currentPlaylistFile={currentPlaylistFile} + offlineAvailablePlaylists={offlineAvailablePlaylists} + isOffline={isOffline} onPlaybackOrderChange={handlePlaybackOrderChange} onSelectTrack={(index) => loadTrack(index, true)} onDownloadTrack={handleDownloadTrack} onDownloadAll={downloadAllTracks} + onPlaylistChange={handlePlaylistChange} isDownloading={isDownloading} downloadProgress={downloadProgress} /> @@ -739,6 +856,9 @@ const TabNavigator = () => { onPlaylistChange={handlePlaylistChange} cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length} trackCount={tracks.length} + loggedInUser={loggedInUser} + onLoginPress={() => setShowLoginScreen(true)} + onLogout={handleLogout} /> )} options={{ diff --git a/apps/app_zen_box/android/app/src/main/AndroidManifest.xml b/apps/app_zen_box/android/app/src/main/AndroidManifest.xml index cba6d81..1e2c107 100644 --- a/apps/app_zen_box/android/app/src/main/AndroidManifest.xml +++ b/apps/app_zen_box/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ - + diff --git a/apps/app_zen_box/android/build.gradle b/apps/app_zen_box/android/build.gradle index 5ca274f..0554dd1 100644 --- a/apps/app_zen_box/android/build.gradle +++ b/apps/app_zen_box/android/build.gradle @@ -2,8 +2,6 @@ buildscript { repositories { - maven { url 'https://maven.aliyun.com/repository/google' } - maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() } @@ -16,8 +14,6 @@ buildscript { allprojects { repositories { - maven { url 'https://maven.aliyun.com/repository/google' } - maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() maven { url 'https://www.jitpack.io' } diff --git a/apps/app_zen_box/android/gradle.properties b/apps/app_zen_box/android/gradle.properties index d11c822..8e39f82 100644 --- a/apps/app_zen_box/android/gradle.properties +++ b/apps/app_zen_box/android/gradle.properties @@ -1,13 +1,65 @@ # Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# React Native -hermesEnabled=true +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. newArchEnabled=true -# Disable toolchain auto-provisioning, use local JDK -org.gradle.java.installations.auto-download=false -org.gradle.java.installations.paths=C\:\\Program Files\\Java\\jdk-17.0.19+10 +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/apps/app_zen_box/android/gradle/wrapper/gradle-wrapper.properties b/apps/app_zen_box/android/gradle/wrapper/gradle-wrapper.properties index e600956..d4081da 100644 --- a/apps/app_zen_box/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/app_zen_box/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/apps/app_zen_box/android/settings.gradle b/apps/app_zen_box/android/settings.gradle index 52e0dfb..1aa78bd 100644 --- a/apps/app_zen_box/android/settings.gradle +++ b/apps/app_zen_box/android/settings.gradle @@ -1,11 +1,4 @@ pluginManagement { - repositories { - maven { url 'https://maven.aliyun.com/repository/google' } - maven { url 'https://maven.aliyun.com/repository/public' } - google() - gradlePluginPortal() - mavenCentral() - } def reactNativeGradlePlugin = new File( providers.exec { workingDir(rootDir) diff --git a/apps/app_zen_box/api.ts b/apps/app_zen_box/api.ts index 053b9af..78efe4f 100644 --- a/apps/app_zen_box/api.ts +++ b/apps/app_zen_box/api.ts @@ -1,6 +1,7 @@ import axios from 'axios'; +import NetInfo from '@react-native-community/netinfo'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Track } from './types'; +import { Track, UserConfig, SortMode } from './types'; const API_BASE_URL = 'https://traesite.umersoft.com:8975'; const PLAYLIST_LIST_PATH = '/api/zen_box/playlists'; @@ -9,6 +10,9 @@ const PLAYLIST_SNAPSHOT_KEY_PREFIX = '@zen_box_playlist_snapshot:'; const SELECTED_PLAYLIST_KEY = '@zen_box_selected_playlist'; const RESUME_ENABLED_KEY = '@zen_box_resume_enabled'; const PLAYBACK_POSITION_KEY_PREFIX = '@zen_box_playback_position:'; +const LOGGED_IN_USER_KEY = '@zen_box_logged_in_user'; +const CLOUD_CONFIG_KEY = '@zen_box_cloud_config'; +const SORT_PREF_KEY = '@zen_box_sort_pref'; const api = axios.create({ baseURL: API_BASE_URL, @@ -18,6 +22,16 @@ const api = axios.create({ }, }); +// 网络状态检测 +export const isNetworkConnected = async (): Promise => { + try { + const state = await NetInfo.fetch(); + return state.isConnected ?? false; + } catch { + return false; + } +}; + export const formatError = (error: any): string => { if (error.response) { return `服务器响应 ${error.response.status}: ${error.response.statusText}`; @@ -46,8 +60,86 @@ const getPlaybackPositionKey = (playlistFile?: string, trackSrc?: string): strin return `${PLAYBACK_POSITION_KEY_PREFIX}${normalizePlaylistFile(playlistFile)}::${String(trackSrc || '').trim()}`; }; +// ========== 登录相关 ========== + +export const login = async (username: string, password: string): Promise<{ ok: boolean; username?: string; error?: string }> => { + try { + const response = await api.post('/api/zen_box/auth/login', { username, password }); + if (response.data?.ok) { + await AsyncStorage.setItem(LOGGED_IN_USER_KEY, String(response.data.username || username)); + return { ok: true, username: response.data.username || username }; + } + return { ok: false, error: '登录失败' }; + } catch (error: any) { + const msg = error.response?.data?.error || formatError(error); + return { ok: false, error: msg }; + } +}; + +export const logout = async (): Promise => { + try { + await AsyncStorage.removeItem(LOGGED_IN_USER_KEY); + await AsyncStorage.removeItem(CLOUD_CONFIG_KEY); + } catch (error) { + console.error('登出失败:', error); + } +}; + +export const getLoggedInUser = async (): Promise => { + try { + return await AsyncStorage.getItem(LOGGED_IN_USER_KEY); + } catch { + return null; + } +}; + +// ========== 云端配置同步 ========== + +export const pushCloudConfig = async (config: UserConfig): Promise => { + try { + const user = await getLoggedInUser(); + if (!user) return; + const connected = await isNetworkConnected(); + if (!connected) { + await AsyncStorage.setItem(CLOUD_CONFIG_KEY, JSON.stringify(config)); + return; + } + await api.post('/api/zen_box/config', { username: user, config }); + } catch (error) { + console.error('上传云端配置失败:', error); + } +}; + +export const pullCloudConfig = async (): Promise => { + try { + const user = await getLoggedInUser(); + if (!user) return null; + const connected = await isNetworkConnected(); + if (!connected) { + const cached = await AsyncStorage.getItem(CLOUD_CONFIG_KEY); + return cached ? JSON.parse(cached) : null; + } + const response = await api.get('/api/zen_box/config', { params: { username: user } }); + if (response.data?.ok && response.data?.config) { + await AsyncStorage.setItem(CLOUD_CONFIG_KEY, JSON.stringify(response.data.config)); + return response.data.config as UserConfig; + } + return null; + } catch (error) { + console.error('拉取云端配置失败:', error); + const cached = await AsyncStorage.getItem(CLOUD_CONFIG_KEY); + return cached ? JSON.parse(cached) : null; + } +}; + +// ========== 歌单相关 ========== + export const getPlaylistFiles = async (): Promise => { try { + const connected = await isNetworkConnected(); + if (!connected) { + return await getLocalPlaylistFiles(); + } const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH); const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : []; const files = raw.map(item => String(item || '')).filter(Boolean); @@ -57,6 +149,20 @@ export const getPlaylistFiles = async (): Promise => { return files; } catch (error) { console.error('读取歌单列表失败:', error); + return await getLocalPlaylistFiles(); + } +}; + +const getLocalPlaylistFiles = async (): Promise => { + try { + const allKeys = await AsyncStorage.getAllKeys(); + const snapshotKeys = allKeys.filter(k => k.startsWith(PLAYLIST_SNAPSHOT_KEY_PREFIX)); + const files = snapshotKeys.map(k => k.replace(PLAYLIST_SNAPSHOT_KEY_PREFIX, '')); + if (!files.includes(DEFAULT_PLAYLIST_FILE)) { + files.unshift(DEFAULT_PLAYLIST_FILE); + } + return files; + } catch { return [DEFAULT_PLAYLIST_FILE]; } }; @@ -137,6 +243,10 @@ export const clearPlaybackPosition = async (playlistFile: string, trackSrc: stri export const getPlaylist = async (fileName?: string): Promise => { try { + const connected = await isNetworkConnected(); + if (!connected) { + throw new Error('网络不可用'); + } const response = await api.get(buildPlaylistPath(fileName)); return response.data; } catch (error) { @@ -203,3 +313,52 @@ export const getPlaylistWithFallback = async (fileName?: string): Promise<{ throw error; } }; + +// ========== 离线歌单反推 ========== + +export const getOfflineAvailablePlaylists = async ( + isCached: (src: string) => boolean +): Promise => { + const allKeys = await AsyncStorage.getAllKeys(); + const snapshotKeys = allKeys.filter(k => k.startsWith(PLAYLIST_SNAPSHOT_KEY_PREFIX)); + const available: string[] = []; + + for (const key of snapshotKeys) { + const fileName = key.replace(PLAYLIST_SNAPSHOT_KEY_PREFIX, ''); + const snapshot = await getPlaylistSnapshot(fileName); + if (!snapshot.length) continue; + + const cachedCount = snapshot.filter(track => isCached(track.src)).length; + if (cachedCount > 0) { + available.push(fileName); + } + } + + if (!available.includes(DEFAULT_PLAYLIST_FILE)) { + available.unshift(DEFAULT_PLAYLIST_FILE); + } + + return available; +}; + +// ========== 排序偏好 ========== + +export const getSortPreference = async (): Promise => { + try { + const saved = await AsyncStorage.getItem(SORT_PREF_KEY); + if (saved && ['original', 'asc', 'desc'].includes(saved)) { + return saved as SortMode; + } + return 'original'; + } catch { + return 'original'; + } +}; + +export const saveSortPreference = async (mode: SortMode): Promise => { + try { + await AsyncStorage.setItem(SORT_PREF_KEY, mode); + } catch (error) { + console.error('保存排序偏好失败:', error); + } +}; diff --git a/apps/app_zen_box/app.json b/apps/app_zen_box/app.json index 2865edf..9e8a3fc 100644 --- a/apps/app_zen_box/app.json +++ b/apps/app_zen_box/app.json @@ -16,11 +16,13 @@ ], "ios": { "bundleIdentifier": "com.yangdafe.zenbox", + "buildNumber": "2", "supportsTablet": true, "infoPlist": { "UIBackgroundModes": [ "audio" - ] + ], + "ITSAppUsesNonExemptEncryption": false } }, "android": { diff --git a/apps/app_zen_box/eas.json b/apps/app_zen_box/eas.json index ea42723..a76b425 100644 --- a/apps/app_zen_box/eas.json +++ b/apps/app_zen_box/eas.json @@ -1,7 +1,7 @@ { "cli": { "version": ">= 15.0.0", - "appVersionSource": "remote", + "appVersionSource": "local", "promptToConfigurePushNotifications": false }, "build": { @@ -10,14 +10,20 @@ "distribution": "internal" }, "preview": { - "distribution": "internal", + "distribution": "store", "android": { "buildType": "apk" + }, + "ios": { + "simulator": false } }, "production": { "android": { "buildType": "apk" + }, + "ios": { + "simulator": false } } }, diff --git a/apps/app_zen_box/package-lock.json b/apps/app_zen_box/package-lock.json index a16f466..80c6907 100644 --- a/apps/app_zen_box/package-lock.json +++ b/apps/app_zen_box/package-lock.json @@ -10,12 +10,13 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@react-navigation/bottom-tabs": "^6.5.0", "@react-navigation/native": "^6.1.0", "axios": "^1.6.0", "expo": "~54.0.0", "expo-av": "~16.0.8", - "expo-file-system": "~17.0.1", + "expo-file-system": "~19.0.23", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.9", "react": "19.1.0", @@ -4610,6 +4611,16 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -7559,12 +7570,13 @@ } }, "node_modules/expo-file-system": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz", - "integrity": "sha512-dYpnZJqTGj6HCYJyXAgpFkQWsiCH3HY1ek2cFZVHFoEc5tLz9gmdEgTF6nFHurvmvfmXqxi7a5CXyVm0aFYJBw==", + "version": "19.0.23", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", + "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", "license": "MIT", "peerDependencies": { - "expo": "*" + "expo": "*", + "react-native": "*" } }, "node_modules/expo-font": { @@ -8019,16 +8031,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.23", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", - "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", diff --git a/apps/app_zen_box/package.json b/apps/app_zen_box/package.json index 0665394..5fdbb9b 100644 --- a/apps/app_zen_box/package.json +++ b/apps/app_zen_box/package.json @@ -11,12 +11,13 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@react-navigation/bottom-tabs": "^6.5.0", "@react-navigation/native": "^6.1.0", "axios": "^1.6.0", "expo": "~54.0.0", "expo-av": "~16.0.8", - "expo-file-system": "~17.0.1", + "expo-file-system": "~19.0.23", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.9", "react": "19.1.0", diff --git a/apps/app_zen_box/screens/LoginScreen.tsx b/apps/app_zen_box/screens/LoginScreen.tsx new file mode 100644 index 0000000..2e93518 --- /dev/null +++ b/apps/app_zen_box/screens/LoginScreen.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { theme } from '../theme'; +import * as API from '../api'; + +interface LoginScreenProps { + onLoginSuccess: () => void; + onBack: () => void; +} + +export const LoginScreen: React.FC = ({ onLoginSuccess, onBack }) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + if (!username.trim() || !password.trim()) { + Alert.alert('提示', '请输入账号和密码'); + return; + } + + setLoading(true); + try { + const result = await API.login(username.trim(), password); + if (result.ok) { + Alert.alert('登录成功', `欢迎,${result.username}`, [ + { text: '好的', onPress: onLoginSuccess } + ]); + } else { + Alert.alert('登录失败', result.error || '账号或密码错误'); + } + } catch (error) { + Alert.alert('错误', '登录请求失败,请检查网络'); + } finally { + setLoading(false); + } + }; + + return ( + + + + ← 返回 + + 观空禅音 + + + + 账号 + + + 密码 + + + + {loading ? ( + + ) : ( + 登录 + )} + + + + 登录后可同步歌单选择、播放进度等配置到云端,跨设备使用。 + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.sm, + }, + backBtn: { + padding: theme.spacing.xs, + }, + backText: { + fontSize: 15, + color: theme.colors.primary, + }, + title: { + flex: 1, + textAlign: 'center', + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + marginRight: 40, + }, + form: { + paddingHorizontal: theme.spacing.xl, + paddingTop: theme.spacing.xl, + }, + label: { + fontSize: 14, + color: theme.colors.textSecondary, + marginBottom: 6, + }, + input: { + backgroundColor: theme.colors.surface, + borderRadius: 8, + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.sm, + fontSize: 16, + color: theme.colors.text, + marginBottom: theme.spacing.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + loginBtn: { + backgroundColor: theme.colors.primary, + borderRadius: 8, + paddingVertical: theme.spacing.sm, + alignItems: 'center', + marginTop: theme.spacing.md, + }, + loginBtnDisabled: { + opacity: 0.6, + }, + loginBtnText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, + hint: { + fontSize: 13, + color: theme.colors.textSecondary, + marginTop: theme.spacing.lg, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/apps/app_zen_box/screens/PlaylistScreen.tsx b/apps/app_zen_box/screens/PlaylistScreen.tsx index c5bc5ab..d79ffc2 100644 --- a/apps/app_zen_box/screens/PlaylistScreen.tsx +++ b/apps/app_zen_box/screens/PlaylistScreen.tsx @@ -1,15 +1,12 @@ import React, { useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import Svg, { Path, Circle } from 'react-native-svg'; import { MarqueeText } from '../components/MarqueeText'; import { theme } from '../theme'; -import { Track, CacheInfo } from '../types'; +import { Track, CacheInfo, SortMode } from '../types'; import * as Cache from '../utils/cache'; - -const SORT_PREF_KEY = '@zen_box_sort_pref'; -type SortMode = 'original' | 'asc' | 'desc'; +import * as API from '../api'; interface PlaylistScreenProps { tracks: Track[]; @@ -17,10 +14,15 @@ interface PlaylistScreenProps { downloadingTrackSrcs: string[]; currentIndex: number; isPlaying: boolean; + playlistFiles: string[]; + currentPlaylistFile: string; + offlineAvailablePlaylists?: string[]; + isOffline?: boolean; onPlaybackOrderChange: (order: number[]) => void; onSelectTrack: (index: number) => void; onDownloadTrack: (track: Track) => Promise; onDownloadAll?: () => void; + onPlaylistChange?: (fileName: string) => void; isDownloading?: boolean; downloadProgress?: { completed: number; total: number; title: string }; } @@ -142,10 +144,15 @@ export const PlaylistScreen: React.FC = ({ downloadingTrackSrcs, currentIndex, isPlaying, + playlistFiles, + currentPlaylistFile, + offlineAvailablePlaylists, + isOffline, onPlaybackOrderChange, onSelectTrack, onDownloadTrack, onDownloadAll, + onPlaylistChange, isDownloading = false, downloadProgress, }) => { @@ -157,10 +164,8 @@ export const PlaylistScreen: React.FC = ({ useEffect(() => { const loadSortPref = async () => { try { - const saved = await AsyncStorage.getItem(SORT_PREF_KEY); - if (saved && ['original', 'asc', 'desc'].includes(saved)) { - setSortMode(saved as SortMode); - } + const saved = await API.getSortPreference(); + setSortMode(saved); } catch (error) { console.error('加载排序偏好失败:', error); } @@ -171,7 +176,7 @@ export const PlaylistScreen: React.FC = ({ // 保存排序偏好 const saveSortPref = async (newMode: SortMode) => { try { - await AsyncStorage.setItem(SORT_PREF_KEY, newMode); + await API.saveSortPreference(newMode); } catch (error) { console.error('保存排序偏好失败:', error); } @@ -241,17 +246,112 @@ export const PlaylistScreen: React.FC = ({ return originalTracks.findIndex(t => t.src === track.src); }; + // 离线时使用过滤后的歌单列表 + const displayPlaylistFiles = isOffline ? (offlineAvailablePlaylists || []) : playlistFiles; + + // 计算每个歌单的缓存数 + const [playlistCacheCounts, setPlaylistCacheCounts] = useState<{ [file: string]: { cached: number; total: number } }>({}); + + useEffect(() => { + const counts: { [file: string]: { cached: number; total: number } } = {}; + for (const file of displayPlaylistFiles) { + const snapshotTracks = file === currentPlaylistFile ? tracks : []; + const total = snapshotTracks.length; + const cached = snapshotTracks.filter(t => !!cacheInfo[t.src]).length; + counts[file] = { cached, total }; + } + setPlaylistCacheCounts(counts); + }, [displayPlaylistFiles, currentPlaylistFile, tracks, cacheInfo]); + + const [playlistExpanded, setPlaylistExpanded] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + + const handlePlaylistPress = async (fileName: string) => { + if (fileName === currentPlaylistFile || isSwitching || !onPlaylistChange) { + setPlaylistExpanded(false); + return; + } + setIsSwitching(true); + try { + await onPlaylistChange(fileName); + setPlaylistExpanded(false); + } catch (error) { + console.error('切换歌单失败:', error); + } finally { + setIsSwitching(false); + } + }; + return ( 播放列表 - - {cachedCount}/{tracks.length} 首已缓存 - + + + {cachedCount}/{tracks.length} 首已缓存 + + {isOffline && ( + 离线模式 + )} + + {/* 歌单切换区域 */} + {displayPlaylistFiles.length > 1 && ( + + setPlaylistExpanded(prev => !prev)} + disabled={isSwitching} + > + + 当前歌单:{currentPlaylistFile} + + {isSwitching ? ( + + ) : ( + {playlistExpanded ? '▲' : '▼'} + )} + + {playlistExpanded && ( + + {displayPlaylistFiles.map((fileName) => { + const isActive = fileName === currentPlaylistFile; + const count = playlistCacheCounts[fileName]; + return ( + { void handlePlaylistPress(fileName); }} + disabled={isSwitching} + > + + {fileName} + + {count && ( + + {count.cached}/{count.total} + + )} + {isActive && 当前} + + ); + })} + {isOffline && ( + + 离线模式下,仅显示有缓存歌曲的歌单。 + + )} + + )} + + )} + {isDownloading && downloadProgress && downloadProgress.total > 0 && ( @@ -343,6 +443,10 @@ const styles = StyleSheet.create({ headerLeft: { flex: 1, }, + headerSubtitleRow: { + flexDirection: 'row', + alignItems: 'center', + }, headerTitle: { fontSize: 18, fontWeight: '600', @@ -353,6 +457,85 @@ const styles = StyleSheet.create({ fontSize: 13, color: theme.colors.textSecondary, }, + offlineBadge: { + fontSize: 12, + color: theme.colors.primary, + fontWeight: '600', + marginLeft: theme.spacing.sm, + }, + playlistSwitchSection: { + paddingHorizontal: theme.spacing.md, + paddingBottom: theme.spacing.sm, + }, + playlistSwitchBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: theme.spacing.sm, + paddingHorizontal: theme.spacing.md, + backgroundColor: theme.colors.surface, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + playlistSwitchText: { + fontSize: 14, + color: theme.colors.text, + }, + playlistSwitchArrow: { + fontSize: 12, + color: theme.colors.textSecondary, + }, + playlistOptions: { + marginTop: theme.spacing.xs, + paddingVertical: theme.spacing.xs, + paddingHorizontal: theme.spacing.sm, + backgroundColor: theme.colors.surface, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + gap: theme.spacing.xs, + }, + playlistOption: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.sm, + borderRadius: theme.borderRadius.sm, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + playlistOptionActive: { + borderWidth: 1, + borderColor: theme.colors.primary, + backgroundColor: theme.colors.primarySoft, + }, + playlistOptionText: { + flex: 1, + fontSize: 14, + color: theme.colors.text, + }, + playlistOptionTextActive: { + color: theme.colors.primary, + fontWeight: '600', + }, + playlistCacheBadge: { + fontSize: 12, + color: theme.colors.textSecondary, + }, + playlistCacheBadgeActive: { + color: theme.colors.primary, + }, + playlistOptionBadgeText: { + fontSize: 12, + color: theme.colors.primary, + }, + playlistHint: { + marginTop: theme.spacing.xs, + fontSize: 12, + lineHeight: 18, + color: theme.colors.textSecondary, + }, progressSection: { paddingHorizontal: theme.spacing.md, paddingBottom: theme.spacing.sm, diff --git a/apps/app_zen_box/screens/SettingsScreen.tsx b/apps/app_zen_box/screens/SettingsScreen.tsx index 68d9da1..1e3ce90 100644 --- a/apps/app_zen_box/screens/SettingsScreen.tsx +++ b/apps/app_zen_box/screens/SettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator, ScrollView } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Path } from 'react-native-svg'; import { theme } from '../theme'; @@ -66,10 +66,13 @@ interface SettingsScreenProps { currentPlaylistFile: string; cachedTrackCount: number; trackCount: number; + loggedInUser: string | null; onCacheOnlyChange: (value: boolean) => Promise; onResumePlaybackChange: (value: boolean) => Promise; onCacheChanged: () => Promise | void; onPlaylistChange: (fileName: string) => Promise; + onLoginPress: () => void; + onLogout: () => void; } export const SettingsScreen: React.FC = ({ @@ -79,10 +82,13 @@ export const SettingsScreen: React.FC = ({ currentPlaylistFile, cachedTrackCount, trackCount, + loggedInUser, onCacheOnlyChange, onResumePlaybackChange, onCacheChanged, onPlaylistChange, + onLoginPress, + onLogout, }) => { const [cacheSize, setCacheSize] = useState('0 B'); const [isLoading, setIsLoading] = useState(false); @@ -163,12 +169,32 @@ export const SettingsScreen: React.FC = ({ ); }; + const handleLogout = () => { + Alert.alert( + '退出登录', + '确定要退出登录吗?退出后云端配置将不再同步。', + [ + { text: '取消', style: 'cancel' }, + { + text: '退出', + style: 'destructive', + onPress: () => { + onLogout(); + Alert.alert('已退出', '已退出登录'); + }, + }, + ] + ); + }; + return ( 设置 + + 播放设置 = ({ /> + + 账号 + {loggedInUser ? ( + <> + + 退出 + + } + /> + + 歌单选择、播放进度等配置已同步到云端,跨设备可用。 + + + ) : ( + + )} + + 关于 @@ -263,9 +316,10 @@ export const SettingsScreen: React.FC = ({ + ); }; @@ -275,6 +329,12 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: theme.colors.background, }, + scrollContent: { + flex: 1, + }, + scrollContentInner: { + paddingBottom: theme.spacing.xl, + }, header: { paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm, @@ -399,4 +459,22 @@ const styles = StyleSheet.create({ lineHeight: 22, color: theme.colors.textSecondary, }, + logoutBtn: { + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.error, + }, + logoutText: { + fontSize: 13, + color: theme.colors.error, + }, + syncHint: { + fontSize: 12, + lineHeight: 18, + color: theme.colors.textSecondary, + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.xs, + }, }); diff --git a/apps/app_zen_box/types.ts b/apps/app_zen_box/types.ts index 21ca41e..a09157c 100644 --- a/apps/app_zen_box/types.ts +++ b/apps/app_zen_box/types.ts @@ -25,3 +25,13 @@ export interface CacheInfo { downloadedAt: number; }; } + +export interface UserConfig { + selected_playlist: string; + resume_enabled: boolean; + cache_only: boolean; + sort_mode: 'original' | 'asc' | 'desc'; + playback_positions: { [key: string]: number }; +} + +export type SortMode = 'original' | 'asc' | 'desc'; diff --git a/apps/app_zen_box/utils/cache.ts b/apps/app_zen_box/utils/cache.ts index 0660644..419fb81 100644 --- a/apps/app_zen_box/utils/cache.ts +++ b/apps/app_zen_box/utils/cache.ts @@ -1,4 +1,4 @@ -import * as FileSystem from 'expo-file-system'; +import * as FileSystem from 'expo-file-system/legacy'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { CacheInfo, Track } from '../types'; @@ -102,16 +102,17 @@ export const downloadAndCache = async ( const cacheKey = generateCacheKey(url); const cachePath = `${CACHE_DIR}${cacheKey}`; - // 对 URL 进行编码,特别处理中文路径 - const encodedUrl = encodeUrlPath(url); + // 直接使用原始 URL,不做额外编码 + // OSS URL 通常已经正确编码,二次编码会破坏签名验证 + const downloadUrl = url; while (retryCount < maxRetries) { try { - console.log(`[下载] 开始下载: ${encodedUrl}`); + console.log(`[下载] 开始下载: ${downloadUrl}`); // 添加更多下载选项 const downloadResumable = FileSystem.createDownloadResumable( - encodedUrl, + downloadUrl, cachePath, { // 设置一些常用的请求头,兼容 OSS @@ -131,7 +132,7 @@ export const downloadAndCache = async ( const downloadResult = await downloadResumable.downloadAsync(); if (downloadResult && downloadResult.status === 200) { - console.log(`[下载] 成功: ${encodedUrl}`); + console.log(`[下载] 成功: ${downloadUrl}`); const fileInfo = await FileSystem.getInfoAsync(cachePath); const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0; @@ -153,7 +154,7 @@ export const downloadAndCache = async ( console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error); if (retryCount >= maxRetries) { - console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`); + console.error(`[下载] 达到最大重试次数,放弃: ${downloadUrl}`); throw error; } diff --git a/public/root.html b/public/root.html index a424500..0c0e599 100644 --- a/public/root.html +++ b/public/root.html @@ -1 +1,6 @@ -https://traesite.umersoft.com:8975/?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGlhcyI6Im5hdi1wcml2YXRlIiwiaWF0IjoxNzY3NTg2MzM0LCJleHAiOjE3OTk3MjcxMjIsImp0aSI6Ijg5OWZkNzRkZTM2ZDQ4ZjdhYjliMmYxMmNhM2EyYWMzIiwicHdkIjoidUhCVEVFU2w3cUV5dFkySW1KSjNzaXZCSFRmbG5oT0dOTmJ0dUtWbEhSSSJ9.p3AOSY-xVPZX2QpIqX_-25Gvd1HVn5w9fasPxfnr6BSWsL4zifcvom7-lpHIlXYfmJ1n-WIPpXrEsiN0RqASj_kVdc-R2HrRaYZ9Yo34Hx6EyuqbXn_dARKz83qH_CmdRcBQncT31kTBpPHcU6IylFV5nG8CE92v-sheaeS_CtJWFcw8XNx81mue8p1DxFbgO6xh6ODwQZqjUdTbF_v2pheM4nqrO_nKLwrXClEjxT_edKf41EJjfOBIifVWPIVdgywfZkLdMz8GiJluq06JDbGqOUaiaF1-vsk_udSdMUD-bOhiM1HlxvkrsuUKP5KhCe6RUXYXIDv-psewrbprSw \ No newline at end of file +传递tools token用的。以后需要的时候用。 + +https://traesite.umersoft.com:8975/?token=dafe--eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGlhcyI6Im5hdi1wcml2YXRlIiwiaWF0IjoxNzY3NTg2MzM0LCJleHAiOjE3OTk3MjcxMjIsImp0aSI6Ijg5OWZkNzRkZTM2ZDQ4ZjdhYjliMmYxMmNhM2EyYWMzIiwicHdkIjoidUhCVEVFU2w3cUV5dFkySW1KSjNzaXZCSFRmbG5oT0dOTmJ0dUtWbEhSSSJ9.p3AOSY-xVPZX2QpIqX_-25Gvd1HVn5w9fasPxfnr6BSWsL4zifcvom7-lpHIlXYfmJ1n-WIPpXrEsiN0RqASj_kVdc-R2HrRaYZ9Yo34Hx6EyuqbXn_dARKz83qH_CmdRcBQncT31kTBpPHcU6IylFV5nG8CE92v-sheaeS_CtJWFcw8XNx81mue8p1DxFbgO6xh6ODwQZqjUdTbF_v2pheM4nqrO_nKLwrXClEjxT_edKf41EJjfOBIifVWPIVdgywfZkLdMz8GiJluq06JDbGqOUaiaF1-vsk_udSdMUD-bOhiM1HlxvkrsuUKP5KhCe6RUXYXIDv-psewrbprSw + + +. \ No newline at end of file diff --git a/src/server/index.js b/src/server/index.js index da7b4d6..9b9cd03 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -5760,6 +5760,52 @@ app.get('/api/zen_box/playlists', (req, res) => { } }) +// 观空禅音:登录验证(读环境变量,不走用户表) +app.post('/api/zen_box/auth/login', (req, res) => { + try { + const { username, password } = req.body || {} + const expectedUser = process.env.ZEN_BOX_USERNAME || '' + const expectedPass = process.env.ZEN_BOX_PASSWORD || '' + if (expectedUser && expectedPass && username === expectedUser && password === expectedPass) { + return res.json({ ok: true, username }) + } + return res.status(401).json({ ok: false, error: '账号或密码错误' }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } +}) + +// 观空禅音:云端配置存储(简易文件存储,按 username 隔离) +const ZEN_BOX_CONFIG_DIR = path.join(process.cwd(), 'data', 'zen_box_configs') +app.get('/api/zen_box/config', (req, res) => { + try { + const username = String(req.query.username || '').trim() + if (!username) return res.status(400).json({ ok: false, error: '缺少 username 参数' }) + const configFile = path.join(ZEN_BOX_CONFIG_DIR, `${username}.json`) + if (!fs.existsSync(configFile)) return res.json({ ok: true, config: null }) + const raw = fs.readFileSync(configFile, 'utf-8') + const config = JSON.parse(raw) + return res.json({ ok: true, config }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } +}) + +app.post('/api/zen_box/config', (req, res) => { + try { + const { username, config } = req.body || {} + if (!username || !config) return res.status(400).json({ ok: false, error: '缺少参数' }) + if (!fs.existsSync(ZEN_BOX_CONFIG_DIR)) { + fs.mkdirSync(ZEN_BOX_CONFIG_DIR, { recursive: true }) + } + const configFile = path.join(ZEN_BOX_CONFIG_DIR, `${username}.json`) + fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf-8') + return res.json({ ok: true }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } +}) + // Static: keep weekly tool assets always fresh app.use(express.static(path.join(process.cwd(), 'public'), { setHeaders: (res, filePath) => {