diff --git a/.claude/commands/add-doc.md b/.claude/commands/add-doc.md
new file mode 100644
index 0000000..e41cc9e
--- /dev/null
+++ b/.claude/commands/add-doc.md
@@ -0,0 +1,3 @@
+给代码加注释 $FILE_NAME
+1.每一行代码上方加上注释
+2.注释内容需要简洁,使用中文
\ No newline at end of file
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 69ee5c2..47ddb9a 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,9 +1,10 @@
-import { Tabs } from 'expo-router';
-import { Ionicons } from '@expo/vector-icons';
+import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { View } from 'react-native';
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
+import { MiniPlayer } from '../components/MiniPlayer';
export default function RootLayout() {
const restore = useAuthStore(s => s.restore);
@@ -15,28 +16,10 @@ export default function RootLayout() {
return (
-
- (
-
- ),
- }}
- />
-
-
+
+
+
+
);
}
diff --git a/app/index.tsx b/app/index.tsx
index 7d11dad..c41bd13 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,9 +1,9 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import {
- View, FlatList, StyleSheet,
- Text, TouchableOpacity, ActivityIndicator, Dimensions
+ View, StyleSheet,
+ Text, TouchableOpacity, ActivityIndicator, Animated, Image,
} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard';
@@ -12,11 +12,26 @@ import { useVideoList } from '../hooks/useVideoList';
import { useAuthStore } from '../store/authStore';
import type { VideoItem } from '../services/types';
+const HEADER_H = 44;
+const TAB_H = 38;
+const NAV_H = HEADER_H + TAB_H;
+
export default function HomeScreen() {
const router = useRouter();
const { videos, loading, refreshing, load, refresh } = useVideoList();
- const { isLoggedIn, logout } = useAuthStore();
+ const { isLoggedIn, face, logout } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
+ const insets = useSafeAreaInsets();
+
+ const scrollY = useRef(new Animated.Value(0)).current;
+ const clampedScroll = useRef(
+ Animated.diffClamp(scrollY, 0, NAV_H)
+ ).current;
+ const headerTranslate = clampedScroll.interpolate({
+ inputRange: [0, NAV_H],
+ outputRange: [0, -NAV_H],
+ extrapolate: 'clamp',
+ });
useEffect(() => { load(); }, []);
@@ -30,41 +45,59 @@ export default function HomeScreen() {
);
return (
-
-
- 哔哩哔哩
-
-
-
-
- isLoggedIn ? logout() : setShowLogin(true)}
- >
-
-
-
-
-
-
- 热门
-
-
-
-
+ `${item.bvid}-${index}`}
numColumns={2}
columnWrapperStyle={styles.row}
- contentContainerStyle={styles.list}
+ contentContainerStyle={{ paddingTop: insets.top + NAV_H + 8, paddingBottom: insets.bottom + 16 }}
renderItem={renderItem}
onRefresh={refresh}
refreshing={refreshing}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
ListFooterComponent={loading ? : null}
+ onScroll={Animated.event(
+ [{ nativeEvent: { contentOffset: { y: scrollY } } }],
+ { useNativeDriver: true }
+ )}
+ scrollEventThrottle={16}
/>
+ {/* 绝对定位导航栏:paddingTop 手动适配刘海/状态栏 */}
+
+
+ 哔哩哔哩
+
+
+
+
+ isLoggedIn ? logout() : setShowLogin(true)}
+ >
+ {isLoggedIn && face ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ 热门
+
+
+
+
setShowLogin(false)} />
);
@@ -72,15 +105,54 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' },
- header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
+ listContainer: { flex: 1 },
+ navBar: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: 10,
+ backgroundColor: '#fff',
+ overflow: 'hidden',
+ // 安卓投影
+ elevation: 2,
+ // iOS 投影
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.06,
+ shadowRadius: 2,
+ },
+ header: {
+ height: HEADER_H,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#eee',
+ },
logo: { fontSize: 20, fontWeight: '800', color: '#00AEEC', letterSpacing: -0.5 },
headerRight: { flexDirection: 'row', gap: 8 },
- headerBtn: { padding: 4 },
- tabRow: { backgroundColor: '#fff', paddingHorizontal: 16, paddingBottom: 0, flexDirection: 'row', alignItems: 'center', position: 'relative' },
- tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC', paddingVertical: 10 },
- tabUnderline: { position: 'absolute', bottom: 0, left: 16, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 },
+ headerBtn: { padding: 6 },
+ userAvatar: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#eee' },
+ tabRow: {
+ height: TAB_H,
+ backgroundColor: '#fff',
+ paddingHorizontal: 16,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC' },
+ tabUnderline: {
+ position: 'absolute',
+ bottom: 0,
+ left: 16,
+ width: 24,
+ height: 2,
+ backgroundColor: '#00AEEC',
+ borderRadius: 1,
+ },
row: { paddingHorizontal: 8 },
- list: { paddingTop: 8, paddingBottom: 80 },
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
footer: { marginVertical: 16 },
diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx
index ee1b37c..91f4f8a 100644
--- a/app/video/[bvid].tsx
+++ b/app/video/[bvid].tsx
@@ -10,6 +10,7 @@ import { VideoPlayer } from '../../components/VideoPlayer';
import { CommentItem } from '../../components/CommentItem';
import { useVideoDetail } from '../../hooks/useVideoDetail';
import { useComments } from '../../hooks/useComments';
+import { useVideoStore } from '../../store/videoStore';
import { formatCount } from '../../utils/format';
type Tab = 'intro' | 'comments';
@@ -17,14 +18,26 @@ type Tab = 'intro' | 'comments';
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
const router = useRouter();
- const { video, streamUrl, loading: videoLoading } = useVideoDetail(bvid as string);
+ const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string);
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
const [tab, setTab] = useState('comments');
+ const { setVideo, clearVideo } = useVideoStore();
+
+ useEffect(() => {
+ clearVideo();
+ }, [bvid]);
useEffect(() => {
if (video?.aid) loadComments();
}, [video?.aid]);
+ function handleMiniPlayer() {
+ if (video) {
+ setVideo(bvid as string, video.title, video.pic);
+ router.back();
+ }
+ }
+
return (
@@ -32,9 +45,18 @@ export default function VideoDetailScreen() {
{video?.title ?? '视频详情'}
+
+
+
-
+
{videoLoading ? (
@@ -111,6 +133,7 @@ const styles = StyleSheet.create({
topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
backBtn: { padding: 4 },
topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' },
+ miniBtn: { padding: 4 },
scroll: { flex: 1 },
loader: { marginVertical: 30 },
titleSection: { padding: 14 },
diff --git a/app/video/_layout.tsx b/app/video/_layout.tsx
index f3b4486..201f2af 100644
--- a/app/video/_layout.tsx
+++ b/app/video/_layout.tsx
@@ -1,9 +1,5 @@
-import { Stack } from 'expo-router';
+import { Slot } from 'expo-router';
export default function VideoLayout() {
- return (
-
-
-
- );
+ return ;
}
diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx
index 6f2bdd7..69b21c6 100644
--- a/components/LoginModal.tsx
+++ b/components/LoginModal.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
-import { generateQRCode, pollQRCode } from '../services/bilibili';
+import { generateQRCode, pollQRCode, getUserInfo } from '../services/bilibili';
import { useAuthStore } from '../store/authStore';
interface Props {
@@ -14,6 +14,7 @@ export function LoginModal({ visible, onClose }: Props) {
const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading');
const pollRef = useRef | null>(null);
const login = useAuthStore(s => s.login);
+ const setProfile = useAuthStore(s => s.setProfile);
useEffect(() => {
if (!visible) return;
@@ -39,6 +40,8 @@ export function LoginModal({ visible, onClose }: Props) {
clearInterval(pollRef.current!);
await login(result.cookie, '', '');
setStatus('done');
+ // 登录后异步拉取用户头像和昵称
+ getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {});
onClose();
}
}, 2000);
diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx
new file mode 100644
index 0000000..38510d9
--- /dev/null
+++ b/components/MiniPlayer.tsx
@@ -0,0 +1,90 @@
+import React, { useRef } from 'react';
+import {
+ View, Text, Image, StyleSheet, TouchableOpacity,
+ Animated, PanResponder,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Ionicons } from '@expo/vector-icons';
+import { useVideoStore } from '../store/videoStore';
+
+export function MiniPlayer() {
+ const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const pan = useRef(new Animated.ValueXY()).current;
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
+ onPanResponderRelease: () => {
+ pan.flattenOffset();
+ },
+ onPanResponderGrant: () => {
+ pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
+ pan.setValue({ x: 0, y: 0 });
+ },
+ })
+ ).current;
+
+ if (!isActive) return null;
+
+ const bottomOffset = insets.bottom + 16;
+
+ return (
+
+ router.push(`/video/${bvid}` as any)}
+ activeOpacity={0.85}
+ >
+
+ {title}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ right: 12,
+ width: 160,
+ height: 90,
+ borderRadius: 8,
+ backgroundColor: '#1a1a1a',
+ overflow: 'hidden',
+ elevation: 8,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.3,
+ shadowRadius: 4,
+ },
+ main: { flex: 1 },
+ cover: { width: '100%', height: 64, backgroundColor: '#333' },
+ title: {
+ color: '#fff',
+ fontSize: 11,
+ paddingHorizontal: 6,
+ paddingVertical: 4,
+ lineHeight: 14,
+ },
+ closeBtn: {
+ position: 'absolute',
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 9,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx
index 3931be7..811988b 100644
--- a/components/NativeVideoPlayer.tsx
+++ b/components/NativeVideoPlayer.tsx
@@ -1,38 +1,87 @@
-import React from 'react';
-import { View, StyleSheet, Dimensions } from 'react-native';
+import React, { useState } from 'react';
+import {
+ View, StyleSheet, Dimensions, TouchableOpacity,
+ Text, Modal, FlatList,
+} from 'react-native';
import { WebView } from 'react-native-webview';
+import { Ionicons } from '@expo/vector-icons';
+import { buildMpd } from '../utils/buildMpd';
+import type { PlayUrlResponse } from '../services/types';
const { width } = Dimensions.get('window');
const VIDEO_HEIGHT = width * 0.5625;
interface Props {
- uri: string;
+ playData: PlayUrlResponse | null;
+ qualities: { qn: number; desc: string }[];
+ currentQn: number;
+ onQualityChange: (qn: number) => void;
+ onFullscreen: () => void;
+ onMiniPlayer?: () => void;
+ style?: object;
}
-const buildHtml = (uri: string) => `
-
+function buildMp4Html(url: string): string {
+ return `
-
+
+
+
+`;
+}
+
+function buildDashHtml(mpdStr: string): string {
+ const mpdBase64 = `data:application/dash+xml;base64,${btoa(unescape(encodeURIComponent(mpdStr)))}`;
+ return `
+
+
+
+
+
+
+
+
-
-`;
+