设置页面

This commit is contained in:
Developer
2026-03-19 16:34:56 +08:00
parent 965a6b411f
commit 462a090599
12 changed files with 586 additions and 156 deletions

View File

@@ -5,15 +5,18 @@ import { View } from 'react-native';
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore';
import { MiniPlayer } from '../components/MiniPlayer';
export default function RootLayout() {
const restore = useAuthStore(s => s.restore);
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
const restoreSettings = useSettingsStore(s => s.restore);
useEffect(() => {
restore();
loadDownloads();
restoreSettings();
}, []);
return (
@@ -53,6 +56,14 @@ export default function RootLayout() {
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="settings"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
</Stack>
<MiniPlayer />
</View>

View File

@@ -76,7 +76,7 @@ export default function HomeScreen() {
load: liveLoad,
refresh: liveRefresh,
} = useLiveList();
const { isLoggedIn, face, logout } = useAuthStore();
const { isLoggedIn, face } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabKey>("hot");
@@ -411,7 +411,7 @@ export default function HomeScreen() {
<View style={styles.headerRight}>
<TouchableOpacity
style={styles.headerBtn}
onPress={() => (isLoggedIn ? logout() : setShowLogin(true))}
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
>
{isLoggedIn && face ? (
<Image source={{ uri: face }} style={styles.userAvatar} />

View File

@@ -155,6 +155,7 @@ export default function LiveDetailScreen() {
onToggle={() => {}}
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
hideHeader
isLive
maxItems={500}
giftCounts={giftCounts}
/>

115
app/settings.tsx Normal file
View File

@@ -0,0 +1,115 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
export default function SettingsScreen() {
const router = useRouter();
const { isLoggedIn, logout } = useAuthStore();
const { coverQuality, setCoverQuality } = useSettingsStore();
const handleLogout = async () => {
await logout();
router.back();
};
return (
<SafeAreaView style={styles.safe}>
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" />
</TouchableOpacity>
<Text style={styles.topTitle}></Text>
<View style={styles.spacer} />
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, coverQuality === 'hd' && styles.optionActive]}
onPress={() => setCoverQuality('hd')}
activeOpacity={0.7}
>
<Text style={[styles.optionText, coverQuality === 'hd' && styles.optionTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, coverQuality === 'normal' && styles.optionActive]}
onPress={() => setCoverQuality('normal')}
activeOpacity={0.7}
>
<Text style={[styles.optionText, coverQuality === 'normal' && styles.optionTextActive]}>
</Text>
</TouchableOpacity>
</View>
</View>
{isLoggedIn && (
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
<Text style={styles.logoutText}>退</Text>
</TouchableOpacity>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' },
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
backBtn: { padding: 4, width: 32 },
spacer: { width: 32 },
topTitle: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#212121',
textAlign: 'center',
},
section: {
backgroundColor: '#fff',
marginTop: 16,
paddingHorizontal: 16,
paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
sectionLabel: { fontSize: 13, color: '#999', marginBottom: 10 },
optionRow: { flexDirection: 'row', gap: 10 },
option: {
paddingHorizontal: 20,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
borderColor: '#e0e0e0',
backgroundColor: '#fff',
},
optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' },
optionText: { fontSize: 14, color: '#666' },
optionTextActive: { color: '#00AEEC', fontWeight: '600' },
logoutBtn: {
margin: 24,
paddingVertical: 12,
borderRadius: 8,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ff4757',
alignItems: 'center',
},
logoutText: { fontSize: 15, color: '#ff4757', fontWeight: '600' },
});

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
import {
View,
Text,
ScrollView,
FlatList,
StyleSheet,
TouchableOpacity,
@@ -19,7 +18,8 @@ import { DanmakuItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList";
import { useVideoDetail } from "../../hooks/useVideoDetail";
import { useComments } from "../../hooks/useComments";
import { formatCount } from "../../utils/format";
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet";
@@ -47,6 +47,15 @@ export default function VideoDetailScreen() {
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false);
const {
videos: relatedVideos,
loading: relatedLoading,
load: loadRelated,
} = useRelatedVideos();
useEffect(() => {
loadRelated();
}, []);
useEffect(() => {
if (video?.aid) loadComments();
@@ -141,110 +150,185 @@ export default function VideoDetailScreen() {
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
tab === "intro" ? (
// 简介:视频信息 + 合集 + 简介文本
<ScrollView
style={styles.tabScroll}
showsVerticalScrollIndicator={false}
>
<View style={styles.upRow}>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
/>
<Text style={styles.upName}>{video.owner.name}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
<View style={styles.titleSection}>
<Text style={styles.title}>{video.title}</Text>
<View style={styles.statsRow}>
<StatBadge icon="play" count={video.stat.view} />
<StatBadge icon="heart" count={video.stat.like} />
<StatBadge icon="star" count={video.stat.favorite} />
<StatBadge icon="chatbubble" count={video.stat.reply} />
</View>
</View>
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
/>
)}
<View style={styles.descBox}>
<Text style={styles.descText}>{video.desc || "暂无简介"}</Text>
</View>
</ScrollView>
) : tab === "danmaku" ? (
<>
{tab === "intro" && (
<FlatList<import("../../services/types").VideoItem>
style={styles.tabScroll}
data={relatedVideos}
keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false}
onEndReached={() => {
if (!relatedLoading) loadRelated();
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<>
<View style={styles.upRow}>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
/>
<Text style={styles.upName}>{video.owner.name}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
<View style={styles.titleSection}>
<Text style={styles.title}>{video.title}</Text>
<View style={styles.statsRow}>
<StatBadge icon="play" count={video.stat.view} />
<StatBadge icon="heart" count={video.stat.like} />
<StatBadge icon="star" count={video.stat.favorite} />
<StatBadge icon="chatbubble" count={video.stat.reply} />
</View>
</View>
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) =>
router.replace(`/video/${epBvid}`)
}
/>
)}
<View style={styles.descBox}>
<Text style={styles.descText}>
{video.desc || "暂无简介"}
</Text>
</View>
<View style={styles.relatedHeader}>
<Text style={styles.relatedHeaderText}></Text>
</View>
</>
}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.relatedCard}
onPress={() => router.push(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View style={styles.relatedThumbWrap}>
<Image
source={{ uri: proxyImageUrl(item.pic) }}
style={styles.relatedThumb}
resizeMode="cover"
/>
<View style={styles.relatedDuration}>
<Text style={styles.relatedDurationText}>
{formatDuration(item.duration)}
</Text>
</View>
</View>
<View style={styles.relatedInfo}>
<Text style={styles.relatedTitle} numberOfLines={2}>
{item.title}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={styles.relatedOwner} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
<Text style={styles.relatedView}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
!relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
ListFooterComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
/>
)}
{tab === "comments" && (
<FlatList
style={styles.tabScroll}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (cmtHasMore && !cmtLoading) loadComments();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.sortRow}>
<Text style={styles.sortLabel}></Text>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 2 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(2)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 2 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 0 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(0)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 0 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
</View>
}
ListFooterComponent={
cmtLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !cmtHasMore && comments.length > 0 ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
ListEmptyComponent={
!cmtLoading ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
/>
)}
{/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */}
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible={true}
visible={tab === "danmaku"}
onToggle={() => {}}
style={styles.danmakuTab}
hideHeader={true}
style={[
styles.danmakuTab,
tab !== "danmaku" && { display: "none" },
]}
/>
) : (
<FlatList
style={styles.tabScroll}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (cmtHasMore && !cmtLoading) loadComments();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.sortRow}>
<Text style={styles.sortLabel}></Text>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 2 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(2)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 2 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 0 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(0)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 0 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
</View>
}
ListFooterComponent={
cmtLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !cmtHasMore && comments.length > 0 ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
ListEmptyComponent={
!cmtLoading ? <Text style={styles.emptyTxt}></Text> : null
}
/>
)
</>
) : null}
</SafeAreaView>
);
@@ -444,6 +528,55 @@ const styles = StyleSheet.create({
descText: { fontSize: 14, color: "#555", lineHeight: 22 },
danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
relatedHeader: {
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#f0f0f0",
backgroundColor: "#f4f4f4",
},
relatedHeaderText: {
fontSize: 13,
fontWeight: "600" as const,
color: "#212121",
},
relatedCard: {
flexDirection: "row",
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: "#fff",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#f0f0f0",
gap: 10,
},
relatedThumbWrap: {
position: "relative",
width: 120,
height: 68,
borderRadius: 4,
overflow: "hidden",
backgroundColor: "#eee",
flexShrink: 0,
},
relatedThumb: { width: 120, height: 68 },
relatedDuration: {
position: "absolute",
bottom: 3,
right: 3,
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 3,
paddingHorizontal: 4,
paddingVertical: 1,
},
relatedDurationText: { color: "#fff", fontSize: 10 },
relatedInfo: {
flex: 1,
justifyContent: "space-between",
paddingVertical: 2,
},
relatedTitle: { fontSize: 13, color: "#212121", lineHeight: 18 },
relatedOwner: { fontSize: 12, color: "#999" },
relatedView: { fontSize: 11, color: "#bbb" },
sortRow: {
flexDirection: "row",
alignItems: "center",