feat(zen-box): 新增云端配置同步功能,完善应用构建与配置
本次提交实现了观空禅音应用的核心云端同步功能,包括: 1. 添加用户登录验证接口与本地存储管理 2. 新增云端配置存储与拉取接口,支持跨设备同步歌单、播放进度等设置 3. 新增登录页面与设置页账号管理模块 4. 更新依赖包版本与安卓、iOS打包配置 5. 优化缓存下载逻辑,修复OSS链接编码问题 6. 新增离线模式支持,自动过滤无缓存歌单 7. 新增歌曲排序偏好本地与云端同步功能
This commit is contained in:
+125
-5
@@ -6,12 +6,13 @@ import { Audio, AVPlaybackStatus } from 'expo-av';
|
|||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { theme } from './theme';
|
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 API from './api';
|
||||||
import * as Cache from './utils/cache';
|
import * as Cache from './utils/cache';
|
||||||
import { PlayerScreen } from './screens/PlayerScreen';
|
import { PlayerScreen } from './screens/PlayerScreen';
|
||||||
import { PlaylistScreen } from './screens/PlaylistScreen';
|
import { PlaylistScreen } from './screens/PlaylistScreen';
|
||||||
import { SettingsScreen } from './screens/SettingsScreen';
|
import { SettingsScreen } from './screens/SettingsScreen';
|
||||||
|
import { LoginScreen } from './screens/LoginScreen';
|
||||||
|
|
||||||
// 安全地编码 URL 中的路径部分
|
// 安全地编码 URL 中的路径部分
|
||||||
const encodeUrlPath = (url: string): string => {
|
const encodeUrlPath = (url: string): string => {
|
||||||
@@ -79,8 +80,6 @@ const StartupLoadingScreen = () => (
|
|||||||
</ImageBackground>
|
</ImageBackground>
|
||||||
);
|
);
|
||||||
|
|
||||||
type SortMode = 'original' | 'asc' | 'desc';
|
|
||||||
|
|
||||||
const TabNavigator = () => {
|
const TabNavigator = () => {
|
||||||
const [tracks, setTracks] = useState<Track[]>([]);
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
const [cacheInfo, setCacheInfo] = useState<CacheInfo>({});
|
const [cacheInfo, setCacheInfo] = useState<CacheInfo>({});
|
||||||
@@ -98,6 +97,11 @@ const TabNavigator = () => {
|
|||||||
const [playlistFiles, setPlaylistFiles] = useState<string[]>(['playlist.json']);
|
const [playlistFiles, setPlaylistFiles] = useState<string[]>(['playlist.json']);
|
||||||
const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json');
|
const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json');
|
||||||
const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true);
|
const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true);
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('original');
|
||||||
|
const [showLoginScreen, setShowLoginScreen] = useState(false);
|
||||||
|
const [loggedInUser, setLoggedInUser] = useState<string | null>(null);
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const [offlineAvailablePlaylists, setOfflineAvailablePlaylists] = useState<string[]>([]);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const soundRef = useRef<Audio.Sound | null>(null);
|
const soundRef = useRef<Audio.Sound | null>(null);
|
||||||
@@ -158,6 +162,57 @@ const TabNavigator = () => {
|
|||||||
const refreshCacheInfo = () => {
|
const refreshCacheInfo = () => {
|
||||||
setCacheInfo(Cache.getCacheInfo());
|
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 (
|
const persistPlaybackPosition = async (
|
||||||
trackIndex = currentIndexRef.current,
|
trackIndex = currentIndexRef.current,
|
||||||
positionSeconds = positionRef.current,
|
positionSeconds = positionRef.current,
|
||||||
@@ -269,16 +324,42 @@ const TabNavigator = () => {
|
|||||||
staysActiveInBackground: true,
|
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();
|
const cacheOnlySetting = await Cache.getCacheOnlySetting();
|
||||||
setCacheOnlyState(cacheOnlySetting);
|
setCacheOnlyState(cacheOnlySetting);
|
||||||
const resumePlaybackSetting = await API.getResumePlaybackEnabled();
|
const resumePlaybackSetting = await API.getResumePlaybackEnabled();
|
||||||
setResumePlaybackEnabledState(resumePlaybackSetting);
|
setResumePlaybackEnabledState(resumePlaybackSetting);
|
||||||
|
const savedSort = await API.getSortPreference();
|
||||||
|
setSortMode(savedSort);
|
||||||
|
|
||||||
const files = await API.getPlaylistFiles();
|
let files = await API.getPlaylistFiles();
|
||||||
setPlaylistFiles(files);
|
setPlaylistFiles(files);
|
||||||
|
|
||||||
|
// 离线模式:反推可用歌单
|
||||||
|
if (!connected) {
|
||||||
|
const available = await API.getOfflineAvailablePlaylists((src) => Cache.isCached(src));
|
||||||
|
setOfflineAvailablePlaylists(available);
|
||||||
|
files = available;
|
||||||
|
}
|
||||||
|
|
||||||
const savedPlaylist = await API.getSavedSelectedPlaylist();
|
const savedPlaylist = await API.getSavedSelectedPlaylist();
|
||||||
const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json';
|
const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json';
|
||||||
await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: true });
|
await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: !connected });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Init failed:', error);
|
console.error('Init failed:', error);
|
||||||
Alert.alert('错误', '无法加载播放列表');
|
Alert.alert('错误', '无法加载播放列表');
|
||||||
@@ -584,6 +665,7 @@ const TabNavigator = () => {
|
|||||||
setCacheOnlyState(value);
|
setCacheOnlyState(value);
|
||||||
try {
|
try {
|
||||||
await Cache.setCacheOnlySetting(value);
|
await Cache.setCacheOnlySetting(value);
|
||||||
|
await pushConfigIfNeeded();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCacheOnlyState(!value);
|
setCacheOnlyState(!value);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -593,6 +675,7 @@ const TabNavigator = () => {
|
|||||||
const handlePlaylistChange = async (playlistFile: string) => {
|
const handlePlaylistChange = async (playlistFile: string) => {
|
||||||
try {
|
try {
|
||||||
await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true });
|
await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true });
|
||||||
|
await pushConfigIfNeeded();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('切换歌单失败:', error);
|
console.error('切换歌单失败:', error);
|
||||||
Alert.alert('错误', '切换歌单失败');
|
Alert.alert('错误', '切换歌单失败');
|
||||||
@@ -604,6 +687,7 @@ const TabNavigator = () => {
|
|||||||
setResumePlaybackEnabledState(value);
|
setResumePlaybackEnabledState(value);
|
||||||
try {
|
try {
|
||||||
await API.saveResumePlaybackEnabled(value);
|
await API.saveResumePlaybackEnabled(value);
|
||||||
|
await pushConfigIfNeeded();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setResumePlaybackEnabledState(!value);
|
setResumePlaybackEnabledState(!value);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -662,6 +746,34 @@ const TabNavigator = () => {
|
|||||||
navigation.navigate('Playlist');
|
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 (
|
||||||
|
<LoginScreen
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
onBack={() => setShowLoginScreen(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <StartupLoadingScreen />;
|
return <StartupLoadingScreen />;
|
||||||
}
|
}
|
||||||
@@ -712,10 +824,15 @@ const TabNavigator = () => {
|
|||||||
downloadingTrackSrcs={downloadingTrackSrcs}
|
downloadingTrackSrcs={downloadingTrackSrcs}
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
|
playlistFiles={playlistFiles}
|
||||||
|
currentPlaylistFile={currentPlaylistFile}
|
||||||
|
offlineAvailablePlaylists={offlineAvailablePlaylists}
|
||||||
|
isOffline={isOffline}
|
||||||
onPlaybackOrderChange={handlePlaybackOrderChange}
|
onPlaybackOrderChange={handlePlaybackOrderChange}
|
||||||
onSelectTrack={(index) => loadTrack(index, true)}
|
onSelectTrack={(index) => loadTrack(index, true)}
|
||||||
onDownloadTrack={handleDownloadTrack}
|
onDownloadTrack={handleDownloadTrack}
|
||||||
onDownloadAll={downloadAllTracks}
|
onDownloadAll={downloadAllTracks}
|
||||||
|
onPlaylistChange={handlePlaylistChange}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
downloadProgress={downloadProgress}
|
downloadProgress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
@@ -739,6 +856,9 @@ const TabNavigator = () => {
|
|||||||
onPlaylistChange={handlePlaylistChange}
|
onPlaylistChange={handlePlaylistChange}
|
||||||
cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length}
|
cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length}
|
||||||
trackCount={tracks.length}
|
trackCount={tracks.length}
|
||||||
|
loggedInUser={loggedInUser}
|
||||||
|
onLoginPress={() => setShowLoginScreen(true)}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:usesCleartextTraffic="true">
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
@@ -16,8 +14,6 @@ buildscript {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url 'https://www.jitpack.io' }
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
|||||||
@@ -1,13 +1,65 @@
|
|||||||
# Project-wide Gradle settings.
|
# 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
|
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
|
android.useAndroidX=true
|
||||||
|
|
||||||
# React Native
|
# Enable AAPT2 PNG crunching
|
||||||
hermesEnabled=true
|
android.enablePngCrunchInReleaseBuilds=true
|
||||||
|
|
||||||
|
# Use this property to specify which architecture you want to build.
|
||||||
|
# You can also override it from the CLI using
|
||||||
|
# ./gradlew <task> -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
|
newArchEnabled=true
|
||||||
|
|
||||||
# Disable toolchain auto-provisioning, use local JDK
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
org.gradle.java.installations.auto-download=false
|
# If set to false, you will be using JSC instead.
|
||||||
org.gradle.java.installations.paths=C\:\\Program Files\\Java\\jdk-17.0.19+10
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
pluginManagement {
|
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(
|
def reactNativeGradlePlugin = new File(
|
||||||
providers.exec {
|
providers.exec {
|
||||||
workingDir(rootDir)
|
workingDir(rootDir)
|
||||||
|
|||||||
+160
-1
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 API_BASE_URL = 'https://traesite.umersoft.com:8975';
|
||||||
const PLAYLIST_LIST_PATH = '/api/zen_box/playlists';
|
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 SELECTED_PLAYLIST_KEY = '@zen_box_selected_playlist';
|
||||||
const RESUME_ENABLED_KEY = '@zen_box_resume_enabled';
|
const RESUME_ENABLED_KEY = '@zen_box_resume_enabled';
|
||||||
const PLAYBACK_POSITION_KEY_PREFIX = '@zen_box_playback_position:';
|
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({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -18,6 +22,16 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 网络状态检测
|
||||||
|
export const isNetworkConnected = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
return state.isConnected ?? false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const formatError = (error: any): string => {
|
export const formatError = (error: any): string => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}`;
|
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()}`;
|
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<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(LOGGED_IN_USER_KEY);
|
||||||
|
await AsyncStorage.removeItem(CLOUD_CONFIG_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoggedInUser = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem(LOGGED_IN_USER_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 云端配置同步 ==========
|
||||||
|
|
||||||
|
export const pushCloudConfig = async (config: UserConfig): Promise<void> => {
|
||||||
|
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<UserConfig | null> => {
|
||||||
|
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<string[]> => {
|
export const getPlaylistFiles = async (): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
|
const connected = await isNetworkConnected();
|
||||||
|
if (!connected) {
|
||||||
|
return await getLocalPlaylistFiles();
|
||||||
|
}
|
||||||
const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH);
|
const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH);
|
||||||
const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : [];
|
const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : [];
|
||||||
const files = raw.map(item => String(item || '')).filter(Boolean);
|
const files = raw.map(item => String(item || '')).filter(Boolean);
|
||||||
@@ -57,6 +149,20 @@ export const getPlaylistFiles = async (): Promise<string[]> => {
|
|||||||
return files;
|
return files;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('读取歌单列表失败:', error);
|
console.error('读取歌单列表失败:', error);
|
||||||
|
return await getLocalPlaylistFiles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalPlaylistFiles = async (): Promise<string[]> => {
|
||||||
|
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];
|
return [DEFAULT_PLAYLIST_FILE];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -137,6 +243,10 @@ export const clearPlaybackPosition = async (playlistFile: string, trackSrc: stri
|
|||||||
|
|
||||||
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
|
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
|
||||||
try {
|
try {
|
||||||
|
const connected = await isNetworkConnected();
|
||||||
|
if (!connected) {
|
||||||
|
throw new Error('网络不可用');
|
||||||
|
}
|
||||||
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
|
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,3 +313,52 @@ export const getPlaylistWithFallback = async (fileName?: string): Promise<{
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== 离线歌单反推 ==========
|
||||||
|
|
||||||
|
export const getOfflineAvailablePlaylists = async (
|
||||||
|
isCached: (src: string) => boolean
|
||||||
|
): Promise<string[]> => {
|
||||||
|
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<SortMode> => {
|
||||||
|
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<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SORT_PREF_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存排序偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
],
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"bundleIdentifier": "com.yangdafe.zenbox",
|
"bundleIdentifier": "com.yangdafe.zenbox",
|
||||||
|
"buildNumber": "2",
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"audio"
|
"audio"
|
||||||
]
|
],
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 15.0.0",
|
"version": ">= 15.0.0",
|
||||||
"appVersionSource": "remote",
|
"appVersionSource": "local",
|
||||||
"promptToConfigurePushNotifications": false
|
"promptToConfigurePushNotifications": false
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -10,14 +10,20 @@
|
|||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "store",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+17
-15
@@ -10,12 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@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/bottom-tabs": "^6.5.0",
|
||||||
"@react-navigation/native": "^6.1.0",
|
"@react-navigation/native": "^6.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-file-system": "~17.0.1",
|
"expo-file-system": "~19.0.23",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -4610,6 +4611,16 @@
|
|||||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
"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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -7559,12 +7570,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "17.0.1",
|
"version": "19.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
|
||||||
"integrity": "sha512-dYpnZJqTGj6HCYJyXAgpFkQWsiCH3HY1ek2cFZVHFoEc5tLz9gmdEgTF6nFHurvmvfmXqxi7a5CXyVm0aFYJBw==",
|
"integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*"
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-font": {
|
"node_modules/expo-font": {
|
||||||
@@ -8019,16 +8031,6 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/expo/node_modules/getenv": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@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/bottom-tabs": "^6.5.0",
|
||||||
"@react-navigation/native": "^6.1.0",
|
"@react-navigation/native": "^6.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-file-system": "~17.0.1",
|
"expo-file-system": "~19.0.23",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -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<LoginScreenProps> = ({ 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 (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||||
|
<Text style={styles.backText}>← 返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title}>观空禅音</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.label}>账号</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
placeholder="请输入账号"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>密码</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
placeholderTextColor={theme.colors.textSecondary}
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.loginBtn, loading && styles.loginBtnDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.loginBtnText}>登录</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
登录后可同步歌单选择、播放进度等配置到云端,跨设备使用。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native';
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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 Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import { MarqueeText } from '../components/MarqueeText';
|
import { MarqueeText } from '../components/MarqueeText';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
import { Track, CacheInfo } from '../types';
|
import { Track, CacheInfo, SortMode } from '../types';
|
||||||
import * as Cache from '../utils/cache';
|
import * as Cache from '../utils/cache';
|
||||||
|
import * as API from '../api';
|
||||||
const SORT_PREF_KEY = '@zen_box_sort_pref';
|
|
||||||
type SortMode = 'original' | 'asc' | 'desc';
|
|
||||||
|
|
||||||
interface PlaylistScreenProps {
|
interface PlaylistScreenProps {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
@@ -17,10 +14,15 @@ interface PlaylistScreenProps {
|
|||||||
downloadingTrackSrcs: string[];
|
downloadingTrackSrcs: string[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
playlistFiles: string[];
|
||||||
|
currentPlaylistFile: string;
|
||||||
|
offlineAvailablePlaylists?: string[];
|
||||||
|
isOffline?: boolean;
|
||||||
onPlaybackOrderChange: (order: number[]) => void;
|
onPlaybackOrderChange: (order: number[]) => void;
|
||||||
onSelectTrack: (index: number) => void;
|
onSelectTrack: (index: number) => void;
|
||||||
onDownloadTrack: (track: Track) => Promise<void>;
|
onDownloadTrack: (track: Track) => Promise<void>;
|
||||||
onDownloadAll?: () => void;
|
onDownloadAll?: () => void;
|
||||||
|
onPlaylistChange?: (fileName: string) => void;
|
||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
downloadProgress?: { completed: number; total: number; title: string };
|
downloadProgress?: { completed: number; total: number; title: string };
|
||||||
}
|
}
|
||||||
@@ -142,10 +144,15 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
|||||||
downloadingTrackSrcs,
|
downloadingTrackSrcs,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
playlistFiles,
|
||||||
|
currentPlaylistFile,
|
||||||
|
offlineAvailablePlaylists,
|
||||||
|
isOffline,
|
||||||
onPlaybackOrderChange,
|
onPlaybackOrderChange,
|
||||||
onSelectTrack,
|
onSelectTrack,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
|
onPlaylistChange,
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -157,10 +164,8 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSortPref = async () => {
|
const loadSortPref = async () => {
|
||||||
try {
|
try {
|
||||||
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
|
const saved = await API.getSortPreference();
|
||||||
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
|
setSortMode(saved);
|
||||||
setSortMode(saved as SortMode);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载排序偏好失败:', error);
|
console.error('加载排序偏好失败:', error);
|
||||||
}
|
}
|
||||||
@@ -171,7 +176,7 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
|||||||
// 保存排序偏好
|
// 保存排序偏好
|
||||||
const saveSortPref = async (newMode: SortMode) => {
|
const saveSortPref = async (newMode: SortMode) => {
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
|
await API.saveSortPreference(newMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存排序偏好失败:', error);
|
console.error('保存排序偏好失败:', error);
|
||||||
}
|
}
|
||||||
@@ -241,17 +246,112 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
|||||||
return originalTracks.findIndex(t => t.src === track.src);
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerLeft}>
|
||||||
<Text style={styles.headerTitle}>播放列表</Text>
|
<Text style={styles.headerTitle}>播放列表</Text>
|
||||||
<Text style={styles.headerSubtitle}>
|
<View style={styles.headerSubtitleRow}>
|
||||||
{cachedCount}/{tracks.length} 首已缓存
|
<Text style={styles.headerSubtitle}>
|
||||||
</Text>
|
{cachedCount}/{tracks.length} 首已缓存
|
||||||
|
</Text>
|
||||||
|
{isOffline && (
|
||||||
|
<Text style={styles.offlineBadge}> 离线模式</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 歌单切换区域 */}
|
||||||
|
{displayPlaylistFiles.length > 1 && (
|
||||||
|
<View style={styles.playlistSwitchSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playlistSwitchBtn}
|
||||||
|
onPress={() => setPlaylistExpanded(prev => !prev)}
|
||||||
|
disabled={isSwitching}
|
||||||
|
>
|
||||||
|
<Text style={styles.playlistSwitchText}>
|
||||||
|
当前歌单:{currentPlaylistFile}
|
||||||
|
</Text>
|
||||||
|
{isSwitching ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.playlistSwitchArrow}>{playlistExpanded ? '▲' : '▼'}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{playlistExpanded && (
|
||||||
|
<View style={styles.playlistOptions}>
|
||||||
|
{displayPlaylistFiles.map((fileName) => {
|
||||||
|
const isActive = fileName === currentPlaylistFile;
|
||||||
|
const count = playlistCacheCounts[fileName];
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={fileName}
|
||||||
|
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
|
||||||
|
onPress={() => { void handlePlaylistPress(fileName); }}
|
||||||
|
disabled={isSwitching}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</Text>
|
||||||
|
{count && (
|
||||||
|
<Text style={[styles.playlistCacheBadge, isActive && styles.playlistCacheBadgeActive]}>
|
||||||
|
{count.cached}/{count.total}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isActive && <Text style={styles.playlistOptionBadgeText}>当前</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isOffline && (
|
||||||
|
<Text style={styles.playlistHint}>
|
||||||
|
离线模式下,仅显示有缓存歌曲的歌单。
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
|
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
|
||||||
<View style={styles.progressSection}>
|
<View style={styles.progressSection}>
|
||||||
<View style={styles.progressTextRow}>
|
<View style={styles.progressTextRow}>
|
||||||
@@ -343,6 +443,10 @@ const styles = StyleSheet.create({
|
|||||||
headerLeft: {
|
headerLeft: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
headerSubtitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -353,6 +457,85 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: theme.colors.textSecondary,
|
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: {
|
progressSection: {
|
||||||
paddingHorizontal: theme.spacing.md,
|
paddingHorizontal: theme.spacing.md,
|
||||||
paddingBottom: theme.spacing.sm,
|
paddingBottom: theme.spacing.sm,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
@@ -66,10 +66,13 @@ interface SettingsScreenProps {
|
|||||||
currentPlaylistFile: string;
|
currentPlaylistFile: string;
|
||||||
cachedTrackCount: number;
|
cachedTrackCount: number;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
|
loggedInUser: string | null;
|
||||||
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
||||||
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
||||||
onCacheChanged: () => Promise<void> | void;
|
onCacheChanged: () => Promise<void> | void;
|
||||||
onPlaylistChange: (fileName: string) => Promise<void>;
|
onPlaylistChange: (fileName: string) => Promise<void>;
|
||||||
|
onLoginPress: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||||
@@ -79,10 +82,13 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
currentPlaylistFile,
|
currentPlaylistFile,
|
||||||
cachedTrackCount,
|
cachedTrackCount,
|
||||||
trackCount,
|
trackCount,
|
||||||
|
loggedInUser,
|
||||||
onCacheOnlyChange,
|
onCacheOnlyChange,
|
||||||
onResumePlaybackChange,
|
onResumePlaybackChange,
|
||||||
onCacheChanged,
|
onCacheChanged,
|
||||||
onPlaylistChange,
|
onPlaylistChange,
|
||||||
|
onLoginPress,
|
||||||
|
onLogout,
|
||||||
}) => {
|
}) => {
|
||||||
const [cacheSize, setCacheSize] = useState<string>('0 B');
|
const [cacheSize, setCacheSize] = useState<string>('0 B');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -163,12 +169,32 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'退出登录',
|
||||||
|
'确定要退出登录吗?退出后云端配置将不再同步。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '退出',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
onLogout();
|
||||||
|
Alert.alert('已退出', '已退出登录');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>设置</Text>
|
<Text style={styles.headerTitle}>设置</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollContentInner}>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>播放设置</Text>
|
<Text style={styles.sectionTitle}>播放设置</Text>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
@@ -249,6 +275,33 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>账号</Text>
|
||||||
|
{loggedInUser ? (
|
||||||
|
<>
|
||||||
|
<SettingItem
|
||||||
|
title="已登录"
|
||||||
|
subtitle={`当前账号:${loggedInUser}`}
|
||||||
|
rightElement={
|
||||||
|
<TouchableOpacity onPress={handleLogout} style={styles.logoutBtn}>
|
||||||
|
<Text style={styles.logoutText}>退出</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={styles.syncHint}>
|
||||||
|
歌单选择、播放进度等配置已同步到云端,跨设备可用。
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SettingItem
|
||||||
|
title="登录以同步配置"
|
||||||
|
subtitle="跨设备同步歌单选择、播放进度等"
|
||||||
|
onPress={onLoginPress}
|
||||||
|
showChevron={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>关于</Text>
|
<Text style={styles.sectionTitle}>关于</Text>
|
||||||
<View style={styles.aboutCard}>
|
<View style={styles.aboutCard}>
|
||||||
@@ -263,9 +316,10 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="版本"
|
title="版本"
|
||||||
subtitle="1.0.0"
|
subtitle="1.0.0 (2)"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -275,6 +329,12 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContentInner: {
|
||||||
|
paddingBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: theme.spacing.md,
|
paddingHorizontal: theme.spacing.md,
|
||||||
paddingVertical: theme.spacing.sm,
|
paddingVertical: theme.spacing.sm,
|
||||||
@@ -399,4 +459,22 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
color: theme.colors.textSecondary,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,3 +25,13 @@ export interface CacheInfo {
|
|||||||
downloadedAt: number;
|
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';
|
||||||
|
|||||||
@@ -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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { CacheInfo, Track } from '../types';
|
import { CacheInfo, Track } from '../types';
|
||||||
|
|
||||||
@@ -102,16 +102,17 @@ export const downloadAndCache = async (
|
|||||||
const cacheKey = generateCacheKey(url);
|
const cacheKey = generateCacheKey(url);
|
||||||
const cachePath = `${CACHE_DIR}${cacheKey}`;
|
const cachePath = `${CACHE_DIR}${cacheKey}`;
|
||||||
|
|
||||||
// 对 URL 进行编码,特别处理中文路径
|
// 直接使用原始 URL,不做额外编码
|
||||||
const encodedUrl = encodeUrlPath(url);
|
// OSS URL 通常已经正确编码,二次编码会破坏签名验证
|
||||||
|
const downloadUrl = url;
|
||||||
|
|
||||||
while (retryCount < maxRetries) {
|
while (retryCount < maxRetries) {
|
||||||
try {
|
try {
|
||||||
console.log(`[下载] 开始下载: ${encodedUrl}`);
|
console.log(`[下载] 开始下载: ${downloadUrl}`);
|
||||||
|
|
||||||
// 添加更多下载选项
|
// 添加更多下载选项
|
||||||
const downloadResumable = FileSystem.createDownloadResumable(
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
encodedUrl,
|
downloadUrl,
|
||||||
cachePath,
|
cachePath,
|
||||||
{
|
{
|
||||||
// 设置一些常用的请求头,兼容 OSS
|
// 设置一些常用的请求头,兼容 OSS
|
||||||
@@ -131,7 +132,7 @@ export const downloadAndCache = async (
|
|||||||
const downloadResult = await downloadResumable.downloadAsync();
|
const downloadResult = await downloadResumable.downloadAsync();
|
||||||
|
|
||||||
if (downloadResult && downloadResult.status === 200) {
|
if (downloadResult && downloadResult.status === 200) {
|
||||||
console.log(`[下载] 成功: ${encodedUrl}`);
|
console.log(`[下载] 成功: ${downloadUrl}`);
|
||||||
|
|
||||||
const fileInfo = await FileSystem.getInfoAsync(cachePath);
|
const fileInfo = await FileSystem.getInfoAsync(cachePath);
|
||||||
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
|
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
|
||||||
@@ -153,7 +154,7 @@ export const downloadAndCache = async (
|
|||||||
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
|
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
|
||||||
|
|
||||||
if (retryCount >= maxRetries) {
|
if (retryCount >= maxRetries) {
|
||||||
console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`);
|
console.error(`[下载] 达到最大重试次数,放弃: ${downloadUrl}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -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
|
传递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
|
||||||
|
|
||||||
|
|
||||||
|
.
|
||||||
@@ -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
|
// Static: keep weekly tool assets always fresh
|
||||||
app.use(express.static(path.join(process.cwd(), 'public'), {
|
app.use(express.static(path.join(process.cwd(), 'public'), {
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user