From e1fa8cc347d10c8928ef1e9cab1cdd80cab4c823 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 12:03:28 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E5=85=B3=E8=81=94=E5=BD=93=E5=89=8D=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E3=80=81UP=E4=B8=BB=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E7=9B=B4=E6=92=AD=E5=85=A8=E5=B1=8F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=8AUI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 推荐列表改用 getVideoRelated 基于当前 bvid 推荐,与首页 feed 解耦 - 视频详情页 UP主名称下方展示粉丝数和视频数 - 推荐视频点击改为 router.replace 避免页面堆叠 - 直播全屏改用 position:absolute,解决退出全屏视频重建暂停问题 - 退出全屏时直播自动暂停 - 直播画质选中状态修复,过滤杜比/4K 画质选项 - 直播画质面板改为居中 Modal 弹出框 - 视频详情 Tab 按钮左对齐,评论排序及设置页按钮统一实心背景风格 --- CHANGELOG.md | 20 ++- app/settings.tsx | 16 +-- app/video/[bvid].tsx | 59 ++++---- components/DanmakuList.tsx | 148 +++++++++++-------- components/LivePlayer.tsx | 284 ++++++++++++++++++++++--------------- hooks/useLiveDetail.ts | 2 +- hooks/useRelatedVideos.ts | 19 ++- services/bilibili.ts | 19 ++- 8 files changed, 340 insertions(+), 227 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab34285..425ff5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,25 @@ --- -## [1.0.8] - 2026-03-24 +## [1.0.12] - 2026-03-25 + +### 新增 +- **UP主信息**:视频详情页博主名称下方展示粉丝数和视频数(`getUploaderStat` → `/x/web-interface/card`) +- **视频相关推荐**:详情页推荐列表改为基于当前视频(`getVideoRelated` → `/x/web-interface/archive/related`),不再与首页 feed 共用 + +### 修复 +- **直播全屏退出暂停**:全屏改用 `position:absolute` 覆盖,Video 组件始终在同一棵 React 树中,不再因 Modal 切换导致重建暂停;退出全屏时直播自动暂停 +- **直播画质选中**:`changeQuality` 强制用请求的 `qn` 覆盖服务端协商值,画质面板高亮与用户选择一致 +- **直播画质过滤**:过滤 `qn > 10000` 的选项(杜比/4K),最高仅展示原画 +- **推荐视频导航**:点击推荐列表改用 `router.replace`,避免详情页无限堆叠 + +### 优化 +- **直播画质面板**:改为居中 Modal 弹出框 +- **视频详情 Tab**:按钮向左靠齐,移除均分宽度 +- **评论排序按钮**:统一为实心背景风格(`#f0f0f0` → `#00AEEC`),与直播分区 Tab 一致 +- **设置页按钮**:外观/流量选项按钮统一为实心背景风格 + +## [1.0.11] - 2026-03-24 ### 新增 - **暗黑模式**:全局主题系统(`utils/theme.ts`),支持亮色 / 暗色一键切换,覆盖所有页面和组件 diff --git a/app/settings.tsx b/app/settings.tsx index 7377caf..33a0463 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -140,16 +140,14 @@ const styles = StyleSheet.create({ sectionLabel: { fontSize: 13, marginBottom: 10 }, optionRow: { flexDirection: 'row', gap: 10 }, option: { - paddingHorizontal: 20, - paddingVertical: 6, - borderRadius: 20, - borderWidth: 1, - borderColor: '#e0e0e0', - backgroundColor: 'transparent', + paddingHorizontal: 10, + paddingVertical: 2, + borderRadius: 16, + backgroundColor: '#f0f0f0', }, - optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' }, - optionText: { fontSize: 14, color: '#666' }, - optionTextActive: { color: '#00AEEC', fontWeight: '600' }, + optionActive: { backgroundColor: '#00AEEC' }, + optionText: { fontSize: 13, color: '#333', fontWeight: '500' }, + optionTextActive: { color: '#fff', fontWeight: '600' }, hint: { fontSize: 12, marginTop: 8 }, versionRow: { flexDirection: 'row', diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index a8f4f13..7e5ecdf 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -13,7 +13,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { VideoPlayer } from "../../components/VideoPlayer"; import { CommentItem } from "../../components/CommentItem"; -import { getDanmaku } from "../../services/bilibili"; +import { getDanmaku, getUploaderStat } from "../../services/bilibili"; import { DanmakuItem } from "../../services/types"; import DanmakuList from "../../components/DanmakuList"; import { useVideoDetail } from "../../hooks/useVideoDetail"; @@ -49,11 +49,12 @@ export default function VideoDetailScreen() { const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); + const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number } | null>(null); const { videos: relatedVideos, loading: relatedLoading, load: loadRelated, - } = useRelatedVideos(); + } = useRelatedVideos(bvid as string); useEffect(() => { loadRelated(); @@ -68,6 +69,11 @@ export default function VideoDetailScreen() { getDanmaku(video.cid).then(setDanmakus); }, [video?.cid]); + useEffect(() => { + if (!video?.owner?.mid) return; + getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); + }, [video?.owner?.mid]); + return ( {/* TopBar */} @@ -178,10 +184,6 @@ export default function VideoDetailScreen() { data={relatedVideos} keyExtractor={(item) => item.bvid} showsVerticalScrollIndicator={false} - onEndReached={() => { - if (!relatedLoading) loadRelated(); - }} - onEndReachedThreshold={0.5} ListHeaderComponent={ <> @@ -189,9 +191,16 @@ export default function VideoDetailScreen() { source={{ uri: proxyImageUrl(video.owner.face) }} style={styles.avatar} /> - - {video.owner.name} - + + + {video.owner.name} + + {uploaderStat && ( + + {formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频 + + )} + + 关注 @@ -249,7 +258,7 @@ export default function VideoDetailScreen() { borderBottomColor: theme.border, }, ]} - onPress={() => router.push(`/video/${item.bvid}` as any)} + onPress={() => router.replace(`/video/${item.bvid}` as any)} activeOpacity={0.85} > - 排序 ({ length: 130, - offset: 12 + index * 140, + offset: 12 + index * 130, index, })} onScrollToIndexFailed={() => {}} @@ -528,8 +536,10 @@ const styles = StyleSheet.create({ paddingBottom: 0, paddingTop: 12, }, - avatar: { width: 48, height: 48, borderRadius: 30, marginRight: 10 }, - upName: { flex: 1, fontSize: 14, fontWeight: "500" }, + avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 }, + upInfo: { flex: 1, justifyContent: "center" }, + upName: { fontSize: 14, fontWeight: "500" }, + upStat: { fontSize: 11, color: "#999", marginTop: 2 }, followBtn: { backgroundColor: "#00AEEC", paddingHorizontal: 10, @@ -570,11 +580,12 @@ const styles = StyleSheet.create({ tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, + paddingLeft: 3, }, tabItem: { - flex: 1, alignItems: "center", paddingVertical: 12, + paddingHorizontal: 12, position: "relative", }, tabLabel: { fontSize: 13 }, @@ -593,7 +604,7 @@ const styles = StyleSheet.create({ danmakuTab: { flex: 1 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, relatedHeader: { - paddingLeft: 12, + paddingLeft: 13, paddingBottom: 8, paddingTop: 8, }, @@ -643,15 +654,13 @@ const styles = StyleSheet.create({ gap: 8, borderBottomWidth: StyleSheet.hairlineWidth, }, - sortLabel: { fontSize: 13, color: "#999", marginRight: 4 }, sortBtn: { - paddingHorizontal: 14, - paddingVertical: 3, - borderRadius: 20, - borderWidth: 1, - borderColor: "#e0e0e0", + paddingHorizontal: 10, + paddingVertical: 2, + borderRadius: 16, + backgroundColor: "#f0f0f0", }, - sortBtnActive: { borderColor: "#00AEEC", backgroundColor: "#e8f7fd" }, - sortBtnTxt: { fontSize: 12, color: "#666" }, - sortBtnTxtActive: { color: "#00AEEC", fontWeight: "600" as const }, + sortBtnActive: { backgroundColor: "#00AEEC" }, + sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" }, + sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const }, }); diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index f7c990f..e7677a7 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -211,58 +211,81 @@ export default function DanmakuList({ }, []); // ─── Live mode render (B站-style chat) ───────────────────────────────────── - const renderLiveItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { - const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null; - const timeStr = formatLiveTime(item.timeline); - return ( - - {timeStr ? ( - {timeStr} - ) : null} - - {guard && ( - - {guard.text} - - )} - {item.isAdmin && ( - - 房管 - - )} - {item.medalLevel != null && item.medalName && ( - - {item.medalName} - - {item.medalLevel} + const renderLiveItem = useCallback( + ({ item }: { item: DisplayedDanmaku }) => { + const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null; + const timeStr = formatLiveTime(item.timeline); + return ( + + {timeStr ? {timeStr} : null} + + {guard && ( + + {guard.text} - - )} - - {item.uname ?? "匿名"} - - - - {item.text} - - - - ); - }, [theme]); + )} + {item.isAdmin && ( + + 房管 + + )} + {item.medalLevel != null && item.medalName && ( + + {item.medalName} + + {item.medalLevel} + + + )} + + {item.uname ?? "匿名"} + + + + {item.text} + + + + ); + }, + [theme], + ); // ─── Video mode render (original bubble) ─────────────────────────────────── - const renderVideoItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { - const dotColor = danmakuColorToCss(item.color); - return ( - - - - {item.text} - - {formatTimestamp(item.time)} - - ); - }, [theme]); + const renderVideoItem = useCallback( + ({ item }: { item: DisplayedDanmaku }) => { + const dotColor = danmakuColorToCss(item.color); + return ( + + + {item.text} + + {formatTimestamp(item.time)} + + ); + }, + [theme], + ); const keyExtractor = useCallback( (item: DisplayedDanmaku) => String(item._key), @@ -270,7 +293,13 @@ export default function DanmakuList({ ); return ( - + {!hideHeader && ( )} - ); } @@ -370,13 +403,6 @@ const styles = StyleSheet.create({ marginVertical: 2, gap: 8, }, - colorDot: { - width: 6, - height: 6, - borderRadius: 3, - marginTop: 6, - flexShrink: 0, - }, bubbleText: { flex: 1, fontSize: 13, @@ -425,8 +451,6 @@ const liveStyles = StyleSheet.create({ flexDirection: "row", alignItems: "flex-start", paddingVertical: 5, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: "#f0f0f2", }, time: { fontSize: 10, diff --git a/components/LivePlayer.tsx b/components/LivePlayer.tsx index 2657b0e..7f6d355 100644 --- a/components/LivePlayer.tsx +++ b/components/LivePlayer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from "react"; import { View, Text, @@ -8,8 +8,8 @@ import { Modal, Platform, useWindowDimensions, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; interface Props { hlsUrl: string; @@ -23,16 +23,23 @@ interface Props { const HIDE_DELAY = 3000; const HEADERS = { - Referer: 'https://live.bilibili.com', - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Referer: "https://live.bilibili.com", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; -export function LivePlayer({ hlsUrl, flvUrl, isLive, qualities = [], currentQn = 0, onQualityChange }: Props) { +export function LivePlayer({ + hlsUrl, + flvUrl, + isLive, + qualities = [], + currentQn = 0, + onQualityChange, +}: Props) { const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); const VIDEO_H = SCREEN_W * 0.5625; - if (Platform.OS === 'web') { + if (Platform.OS === "web") { return ( 请在手机端观看直播 @@ -79,7 +86,7 @@ function NativeLivePlayer({ currentQn: number; onQualityChange?: (qn: number) => void; }) { - const Video = require('react-native-video').default; + const Video = require("react-native-video").default; const [showControls, setShowControls] = useState(true); const [paused, setPaused] = useState(false); @@ -97,14 +104,16 @@ function NativeLivePlayer({ useEffect(() => { resetHideTimer(); - return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; }, []); // Lock/unlock orientation on fullscreen toggle useEffect(() => { (async () => { try { - const ScreenOrientation = require('expo-screen-orientation'); + const ScreenOrientation = require("expo-screen-orientation"); if (isFullscreen) { await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, @@ -114,7 +123,9 @@ function NativeLivePlayer({ ScreenOrientation.OrientationLock.PORTRAIT_UP, ); } - } catch { /* graceful degradation in Expo Go */ } + } catch { + /* graceful degradation in Expo Go */ + } })(); }, [isFullscreen]); @@ -123,18 +134,23 @@ function NativeLivePlayer({ return () => { (async () => { try { - const ScreenOrientation = require('expo-screen-orientation'); + const ScreenOrientation = require("expo-screen-orientation"); await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.PORTRAIT_UP, ); - } catch { /* ignore */ } + } catch { + /* ignore */ + } })(); }; }, []); const handleTap = useCallback(() => { - setShowControls(prev => { - if (!prev) { resetHideTimer(); return true; } + setShowControls((prev) => { + if (!prev) { + resetHideTimer(); + return true; + } if (hideTimer.current) clearTimeout(hideTimer.current); return false; }); @@ -143,23 +159,27 @@ function NativeLivePlayer({ const fsW = Math.max(screenW, screenH); const fsH = Math.min(screenW, screenH); const containerStyle = isFullscreen - ? { width: fsW, height: fsH } + ? { position: 'absolute' as const, top: 0, left: 0, width: fsW, height: fsH, zIndex: 999, elevation: 999 } : { width: screenW, height: videoH }; - const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? ''; + const currentQnDesc = qualities.find((q) => q.qn === currentQn)?.desc ?? ""; const videoContent = ( ); - if (isFullscreen) { - return ( - setIsFullscreen(false)}> - {videoContent} - - ); - } - return videoContent; } const styles = StyleSheet.create({ container: { - backgroundColor: '#000', - alignItems: 'center', - justifyContent: 'center', + backgroundColor: "#000", + alignItems: "center", + justifyContent: "center", }, - webHint: { color: '#fff', fontSize: 15 }, - offlineText: { color: '#999', fontSize: 14, marginTop: 10 }, + webHint: { color: "#fff", fontSize: 15 }, + offlineText: { color: "#999", fontSize: 14, marginTop: 10 }, bufferingOverlay: { ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, - bufferingText: { color: '#fff', fontSize: 13, opacity: 0.8 }, + bufferingText: { color: "#fff", fontSize: 13, opacity: 0.8 }, liveBadge: { - position: 'absolute', + position: "absolute", top: 10, left: 12, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.5)', + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(0,0,0,0.5)", paddingHorizontal: 8, paddingVertical: 3, borderRadius: 4, @@ -289,79 +340,78 @@ const styles = StyleSheet.create({ width: 6, height: 6, borderRadius: 3, - backgroundColor: '#f00', + backgroundColor: "#f00", marginRight: 5, }, - liveText: { color: '#fff', fontSize: 11, fontWeight: '700', letterSpacing: 1 }, + liveText: { + color: "#fff", + fontSize: 11, + fontWeight: "700", + letterSpacing: 1, + }, centerBtn: { - position: 'absolute', - top: '50%', - left: '50%', + position: "absolute", + top: "50%", + left: "50%", transform: [{ translateX: -28 }, { translateY: -28 }], }, centerBtnBg: { width: 56, height: 56, borderRadius: 28, - backgroundColor: 'rgba(0,0,0,0.45)', - alignItems: 'center', - justifyContent: 'center', + backgroundColor: "rgba(0,0,0,0.45)", + alignItems: "center", + justifyContent: "center", }, bottomBar: { - position: 'absolute', + position: "absolute", bottom: 0, left: 0, right: 0, - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingHorizontal: 8, paddingBottom: 8, paddingTop: 24, - backgroundColor: 'rgba(0,0,0,0)', + backgroundColor: "rgba(0,0,0,0)", }, ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 }, qualityBtn: { paddingHorizontal: 8, paddingVertical: 4, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: "rgba(0,0,0,0.4)", borderRadius: 4, marginRight: 4, }, - qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' }, + qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" }, qualityOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.5)', - justifyContent: 'flex-end', + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "center", + alignItems: "center", }, qualityPanel: { - backgroundColor: 'rgba(20,20,20,0.95)', - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - paddingBottom: 16, + backgroundColor: "#fff", + borderRadius: 12, + paddingVertical: 8, + paddingHorizontal: 16, + minWidth: 180, }, qualityPanelTitle: { - color: '#fff', - fontSize: 13, - fontWeight: '600', - textAlign: 'center', - paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: 'rgba(255,255,255,0.1)', + fontSize: 15, + fontWeight: "700", + color: "#212121", + paddingVertical: 10, + textAlign: "center", }, qualityItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingVertical: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "#eee", }, - qualityItemActive: { backgroundColor: 'rgba(0,174,236,0.15)' }, - qualityItemText: { color: '#ccc', fontSize: 14 }, - qualityItemTextActive: { color: '#00AEEC', fontWeight: '600' }, - fsModal: { - flex: 1, - backgroundColor: '#000', - alignItems: 'center', - justifyContent: 'center', - }, + qualityItemText: { fontSize: 14, color: "#333" }, + qualityItemTextActive: { color: "#00AEEC", fontWeight: "700" }, }); diff --git a/hooks/useLiveDetail.ts b/hooks/useLiveDetail.ts index 1dbab95..ae3b249 100644 --- a/hooks/useLiveDetail.ts +++ b/hooks/useLiveDetail.ts @@ -53,7 +53,7 @@ export function useLiveDetail(roomId: number) { const changeQuality = useCallback(async (qn: number) => { try { const stream = await getLiveStreamUrl(roomId, qn); - setState(prev => ({ ...prev, stream })); + setState(prev => ({ ...prev, stream: { ...stream, qn } })); } catch { /* ignore */ } }, [roomId]); diff --git a/hooks/useRelatedVideos.ts b/hooks/useRelatedVideos.ts index 70b2638..d5406e2 100644 --- a/hooks/useRelatedVideos.ts +++ b/hooks/useRelatedVideos.ts @@ -1,29 +1,26 @@ -import { useState, useCallback, useRef, useMemo } from 'react'; -import { getRecommendFeed } from '../services/bilibili'; +import { useState, useCallback, useRef } from 'react'; +import { getVideoRelated } from '../services/bilibili'; import type { VideoItem } from '../services/types'; -export function useRelatedVideos() { - const [pages, setPages] = useState([]); +export function useRelatedVideos(bvid: string) { + const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(false); const loadingRef = useRef(false); - const freshIdxRef = useRef(0); const load = useCallback(async () => { if (loadingRef.current) return; loadingRef.current = true; setLoading(true); try { - const data = await getRecommendFeed(freshIdxRef.current); - setPages(prev => [...prev, data]); - freshIdxRef.current += 1; + const data = await getVideoRelated(bvid); + setVideos(data); } catch (e) { console.warn('useRelatedVideos: failed', e); } finally { loadingRef.current = false; setLoading(false); } - }, []); + }, [bvid]); - const videos = useMemo(() => pages.flat(), [pages]); - return { videos, loading, load }; + return { videos, loading, load, hasMore: false }; } diff --git a/services/bilibili.ts b/services/bilibili.ts index 7c94c9a..f6824fc 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -114,6 +114,12 @@ export async function getVideoDetail(bvid: string): Promise { return res.data.data as VideoItem; } +export async function getVideoRelated(bvid: string): Promise { + const res = await api.get('/x/web-interface/archive/related', { params: { bvid } }); + const items: any[] = res.data.data ?? []; + return items as VideoItem[]; +} + export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise { const isAndroid = Platform.OS === 'android'; // 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界) @@ -143,6 +149,15 @@ export async function getPlayUrlForDownload( return url; } +export async function getUploaderStat(mid: number): Promise<{ follower: number; archiveCount: number }> { + const res = await api.get('/x/web-interface/card', { params: { mid } }); + const data = res.data.data ?? {}; + return { + follower: data.follower ?? 0, + archiveCount: data.archive_count ?? 0, + }; +} + export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> { const res = await api.get('/x/web-interface/nav'); const { face, uname, mid } = res.data.data; @@ -286,7 +301,9 @@ export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise ({ qn: q.qn as number, desc: q.desc as string })); + const qualities = gQnDesc + .map((q: any) => ({ qn: q.qn as number, desc: q.desc as string })) + .filter(q => q.qn <= 10000); let hlsUrl = ''; let flvUrl = ''; From c7fbc1f44131c31ab0a163c08e7082a0b397d4d0 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 12:51:14 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=A1=B5UI=E4=BA=A4=E4=BA=92=E3=80=81=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9A=97=E9=BB=91=E4=B8=BB=E9=A2=98=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E9=A6=96=E9=A1=B5=20header=20=E5=BA=95=E8=BE=B9?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - downloads.tsx: 删除前弹 Alert 确认框(区分"取消下载"和"删除文件") - downloads.tsx: 下载出错时显示"重新下载"按钮,跳转视频详情页 - downloads.tsx: 下载中取消按钮改为 close-circle-outline 图标 - downloads.tsx: 已完成条目整行可点击播放,移除单独"播放"按钮 - downloads.tsx: 播放器横竖屏均显示顶部控制栏,点击切换显隐 - downloads.tsx: 移除进度条旁 ActivityIndicator,保留百分比文字 - downloads.tsx: 全部颜色跟随暗黑主题(bg/card/text/textSub/border) - index.tsx: 移除悬浮 header 底部分割线 --- app/downloads.tsx | 165 +++++++++++++++++++++++-------------- app/index.tsx | 3 - app/video/[bvid].tsx | 18 ++-- components/DanmakuList.tsx | 5 +- 4 files changed, 119 insertions(+), 72 deletions(-) diff --git a/app/downloads.tsx b/app/downloads.tsx index 0f19eaf..51dccf2 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -6,10 +6,10 @@ import { StyleSheet, TouchableOpacity, Image, - ActivityIndicator, Modal, StatusBar, useWindowDimensions, + Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -19,6 +19,8 @@ let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; try { ScreenOrientation = require('expo-screen-orientation'); } catch {} import { useDownloadStore, DownloadTask } from '../store/downloadStore'; import { LanShareModal } from '../components/LanShareModal'; +import { proxyImageUrl } from '../utils/imageUrl'; +import { useTheme } from '../utils/theme'; function formatFileSize(bytes?: number): string { if (!bytes || bytes <= 0) return ''; @@ -26,8 +28,6 @@ function formatFileSize(bytes?: number): string { if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } -import { proxyImageUrl } from '../utils/imageUrl'; -import { useTheme } from '../utils/theme'; export default function DownloadsScreen() { const router = useRouter(); @@ -36,12 +36,14 @@ export default function DownloadsScreen() { const [playingUri, setPlayingUri] = useState(null); const [playingTitle, setPlayingTitle] = useState(''); const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null); + const [showControls, setShowControls] = useState(true); const { width, height } = useWindowDimensions(); const isLandscape = width > height; async function openPlayer(uri: string, title: string) { setPlayingTitle(title); setPlayingUri(uri); + setShowControls(true); await ScreenOrientation?.unlockAsync(); } @@ -50,6 +52,18 @@ export default function DownloadsScreen() { await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); } + function confirmDelete(key: string, status: DownloadTask['status']) { + const isDownloading = status === 'downloading'; + Alert.alert( + isDownloading ? '取消下载' : '删除下载', + isDownloading ? '确定取消该下载任务?' : '确定删除该文件?删除后不可恢复。', + [ + { text: '取消', style: 'cancel' }, + { text: isDownloading ? '取消下载' : '删除', style: 'destructive', onPress: () => removeTask(key) }, + ], + ); + } + useEffect(() => { loadFromStorage(); }, []); @@ -74,29 +88,33 @@ export default function DownloadsScreen() { {sections.length === 0 ? ( - - 暂无下载记录 + + 暂无下载记录 ) : ( item.key} renderSectionHeader={({ section }) => ( - - {section.title} + + {section.title} )} renderItem={({ item }) => ( { if (item.localUri) openPlayer(item.localUri, item.title); }} - onDelete={() => removeTask(item.key)} + onDelete={() => confirmDelete(item.key, item.status)} onShare={() => setShareTask(item)} + onRetry={() => router.push(`/video/${item.bvid}` as any)} /> )} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => ( + + )} contentContainerStyle={{ paddingBottom: 32 }} /> )} @@ -115,27 +133,29 @@ export default function DownloadsScreen() { onRequestClose={closePlayer} > ); @@ -143,98 +163,114 @@ export default function DownloadsScreen() { function DownloadRow({ task, + theme, onPlay, onDelete, onShare, + onRetry, }: { task: DownloadTask & { key: string }; + theme: ReturnType; onPlay: () => void; onDelete: () => void; onShare: () => void; + onRetry: () => void; }) { - return ( - - + const isDone = task.status === 'done'; + const isError = task.status === 'error'; + const isDownloading = task.status === 'downloading'; + + const rowContent = ( + + - {task.title} - + {task.title} + {task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''} - {task.status === 'downloading' && ( + {isDownloading && ( - {Math.round(task.progress * 100)}% )} - {task.status === 'error' && ( - {task.error ?? '下载失败'} + {isError && ( + + {task.error ?? '下载失败'} + + 重新下载 + + )} - {task.status === 'done' && ( - <> - - - 播放 - - - - - + {isDone && ( + + + )} - - + + ); + + if (isDone) { + return ( + + {rowContent} + + ); + } + + return rowContent; } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#fff' }, + safe: { flex: 1 }, topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#eee', }, backBtn: { padding: 4 }, topTitle: { flex: 1, fontSize: 16, fontWeight: '700', - color: '#212121', marginLeft: 4, }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }, - emptyTxt: { fontSize: 14, color: '#bbb' }, + emptyTxt: { fontSize: 14 }, sectionHeader: { - backgroundColor: '#f4f4f4', paddingHorizontal: 16, paddingVertical: 8, }, - sectionTitle: { fontSize: 13, fontWeight: '600', color: '#555' }, + sectionTitle: { fontSize: 13, fontWeight: '600' }, row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, - backgroundColor: '#fff', gap: 12, }, - cover: { width: 80, height: 54, borderRadius: 6, backgroundColor: '#eee' }, + cover: { width: 80, height: 54, borderRadius: 6, backgroundColor: '#eee', flexShrink: 0 }, info: { flex: 1 }, - title: { fontSize: 13, color: '#212121', lineHeight: 18, marginBottom: 4 }, - qdesc: { fontSize: 12, color: '#999', marginBottom: 4 }, - progressWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + title: { fontSize: 13, lineHeight: 18, marginBottom: 4 }, + qdesc: { fontSize: 12, marginBottom: 4 }, + progressWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 2, gap: 6 }, progressTrack: { flex: 1, height: 3, @@ -243,14 +279,19 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, progressFill: { height: 3, backgroundColor: '#00AEEC', borderRadius: 2 }, - progressTxt: { fontSize: 11, color: '#999', marginLeft: 4 }, - errorTxt: { fontSize: 12, color: '#f44', marginTop: 2 }, - actions: { alignItems: 'center', gap: 8 }, - playBtn: { flexDirection: 'row', alignItems: 'center', gap: 3 }, - playTxt: { fontSize: 13, color: '#00AEEC' }, - shareBtn: { padding: 4 }, - deleteBtn: { padding: 4 }, - separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#f0f0f0', marginLeft: 108 }, + progressTxt: { fontSize: 11, color: '#999', minWidth: 30 }, + errorRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 2 }, + errorTxt: { fontSize: 12, color: '#f44', flex: 1 }, + retryBtn: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + backgroundColor: '#e8f7fd', + }, + retryTxt: { fontSize: 12, color: '#00AEEC', fontWeight: '600' }, + actions: { alignItems: 'center', gap: 12 }, + actionBtn: { padding: 4 }, + separator: { height: StyleSheet.hairlineWidth }, // player modal playerBg: { flex: 1, backgroundColor: '#000', justifyContent: 'center' }, playerBar: { @@ -261,6 +302,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, + backgroundColor: 'rgba(0,0,0,0.4)', + paddingVertical: 8, }, closeBtn: { padding: 6 }, playerTitle: { flex: 1, color: '#fff', fontSize: 14, fontWeight: '600', marginLeft: 4 }, diff --git a/app/index.tsx b/app/index.tsx index 0350335..1b34de5 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -422,7 +422,6 @@ export default function HomeScreen() { styles.header, { opacity: currentHeaderOpacity, - borderBottomColor: theme.border, }, ]} > @@ -507,8 +506,6 @@ const styles = StyleSheet.create({ alignItems: "center", paddingHorizontal: 16, gap: 10, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: "#eee", }, logo: { fontSize: 20, diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 7e5ecdf..aaa6ecb 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -49,7 +49,10 @@ export default function VideoDetailScreen() { const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); - const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number } | null>(null); + const [uploaderStat, setUploaderStat] = useState<{ + follower: number; + archiveCount: number; + } | null>(null); const { videos: relatedVideos, loading: relatedLoading, @@ -71,7 +74,9 @@ export default function VideoDetailScreen() { useEffect(() => { if (!video?.owner?.mid) return; - getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); + getUploaderStat(video.owner.mid) + .then(setUploaderStat) + .catch(() => {}); }, [video?.owner?.mid]); return ( @@ -197,7 +202,8 @@ export default function VideoDetailScreen() { {uploaderStat && ( - {formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频 + {formatCount(uploaderStat.follower)}粉丝 ·{" "} + {formatCount(uploaderStat.archiveCount)}视频 )} @@ -465,7 +471,7 @@ function SeasonSection({ !isCurrent && onEpisodePress(ep.bvid)} @@ -564,10 +570,10 @@ const styles = StyleSheet.create({ width: 120, borderRadius: 6, overflow: "hidden", - borderWidth: 1.5, + borderWidth: 1, borderColor: "transparent", }, - epCardActive: { borderColor: "#00AEEC" }, + epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 }, epThumb: { width: 120, height: 68 }, epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNumActive: { color: "#00AEEC", fontWeight: "600" }, diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index e7677a7..58fd626 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -222,7 +222,6 @@ export default function DanmakuList({ { opacity: item._fadeAnim, borderBottomColor: theme.border }, ]} > - {timeStr ? {timeStr} : null} {guard && ( + {timeStr ? {timeStr} : null} ); }, @@ -275,7 +275,7 @@ export default function DanmakuList({ ]} > {item.text} @@ -450,6 +450,7 @@ const liveStyles = StyleSheet.create({ row: { flexDirection: "row", alignItems: "flex-start", + justifyContent:"space-between", paddingVertical: 5, }, time: { From 13c73cdb25c3b10894b1863d681b0fe33d851ee8 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 12:55:31 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=E5=BC=B9=E5=B9=95tag=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/DanmakuList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index 58fd626..3892155 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -222,6 +222,7 @@ export default function DanmakuList({ { opacity: item._fadeAnim, borderBottomColor: theme.border }, ]} > + {timeStr ? {timeStr} : null} {guard && ( - {timeStr ? {timeStr} : null} + ); }, @@ -493,7 +494,6 @@ const liveStyles = StyleSheet.create({ paddingHorizontal: 3, }, medalLvBox: { - backgroundColor: "#e891ab", paddingHorizontal: 3, height: "100%", justifyContent: "center", From 33a3148b385fcb69801ef73ffff211451d25abf8 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 13:16:16 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=B0=8F?= =?UTF-8?q?=E7=AA=97=E6=92=AD=E6=94=BE=EF=BC=88PiP=EF=BC=89=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 store/liveStore.ts:Zustand store,存储小窗直播状态(roomId/title/cover/hlsUrl) - 新增 components/LiveMiniPlayer.tsx:可拖动悬浮迷你直播播放器 - 原生端实际播放 HLS 流(react-native-video,有声) - Web 端降级展示封面图 + LIVE 徽标 - LIVE 红点徽标 + 标题条底部 - 关闭按钮(×)和点击进入直播间 - HLS 出错时自动关闭(onError → clearLive) - 视频 MiniPlayer 激活时自动上移避免重叠 - _layout.tsx:全局挂载 - live/[roomId].tsx:顶部栏添加小窗按钮(browsers-outline) - 仅直播中且有流地址时显示,离线时占位保持 title 居中 - 点击后 setLive + router.back(),返回首页继续看直播 - 进入同房间时自动 clearLive() 避免双播 --- app/_layout.tsx | 2 + app/live/[roomId].tsx | 26 ++++- components/LiveMiniPlayer.tsx | 185 ++++++++++++++++++++++++++++++++++ store/liveStore.ts | 23 +++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 components/LiveMiniPlayer.tsx create mode 100644 store/liveStore.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 3058fb9..4dd43d7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { useDownloadStore } from '../store/downloadStore'; import { useSettingsStore } from '../store/settingsStore'; import { useTheme } from '../utils/theme'; import { MiniPlayer } from '../components/MiniPlayer'; +import { LiveMiniPlayer } from '../components/LiveMiniPlayer'; import * as Sentry from '@sentry/react-native'; import { ErrorBoundary } from '@sentry/react-native'; @@ -80,6 +81,7 @@ function RootLayout() { + ); diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx index 9881f34..f0652b8 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { View, Text, @@ -18,6 +18,7 @@ import DanmakuList from "../../components/DanmakuList"; import { formatCount } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; import { useTheme } from "../../utils/theme"; +import { useLiveStore } from "../../store/liveStore"; type Tab = "intro" | "danmaku"; @@ -36,6 +37,15 @@ export default function LiveDetailScreen() { const qualities = stream?.qualities ?? []; const currentQn = stream?.qn ?? 0; + const { roomId: miniRoomId, setLive, clearLive } = useLiveStore(); + + // 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播 + useEffect(() => { + if (miniRoomId === id) { + clearLive(); + } + }, [id, miniRoomId]); + const actualRoomId = room?.roomid ?? id; const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0); @@ -49,6 +59,19 @@ export default function LiveDetailScreen() { {room?.title ?? "直播间"} + {isLive && hlsUrl ? ( + { + setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl); + router.back(); + }} + > + + + ) : ( + + )} {/* Player */} @@ -177,6 +200,7 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, }, backBtn: { padding: 4 }, + pipBtn: { padding: 4, width: 32, alignItems: 'center' }, topTitle: { flex: 1, fontSize: 15, diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx new file mode 100644 index 0000000..50afa82 --- /dev/null +++ b/components/LiveMiniPlayer.tsx @@ -0,0 +1,185 @@ +import React, { useRef } from 'react'; +import { + View, + Text, + Image, + StyleSheet, + TouchableOpacity, + Animated, + PanResponder, + Dimensions, + Platform, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useLiveStore } from '../store/liveStore'; +import { useVideoStore } from '../store/videoStore'; +import { proxyImageUrl } from '../utils/imageUrl'; + +const MINI_W = 160; +const MINI_H = 90; + +const LIVE_HEADERS = { + Referer: 'https://live.bilibili.com', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +}; + +export function LiveMiniPlayer() { + const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore(); + const videoMiniActive = useVideoStore(s => s.isActive); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const pan = useRef(new Animated.ValueXY()).current; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); + pan.setValue({ x: 0, y: 0 }); + }, + onPanResponderMove: Animated.event( + [null, { dx: pan.x, dy: pan.y }], + { useNativeDriver: false }, + ), + onPanResponderRelease: () => { + pan.flattenOffset(); + const { width: sw, height: sh } = Dimensions.get('window'); + const curX = (pan.x as any)._value; + const curY = (pan.y as any)._value; + const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX)); + const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); + if (curX !== clampedX || curY !== clampedY) { + Animated.spring(pan, { + toValue: { x: clampedX, y: clampedY }, + useNativeDriver: false, + }).start(); + } + }, + }), + ).current; + + if (!isActive) return null; + + // 视频 MiniPlayer 激活时,直播小窗叠放其上方(避免重叠) + const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0); + + const handlePress = () => { + router.push(`/live/${roomId}` as any); + }; + + // Web 端降级:展示封面图 + LIVE 徽标 + if (Platform.OS === 'web') { + return ( + + + + + + LIVE + + {title} + + + + + + ); + } + + // Native:实际 HLS 流播放 + const Video = require('react-native-video').default; + + return ( + + + + + + + LIVE + + {title} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + right: 12, + width: MINI_W, + height: MINI_H, + borderRadius: 8, + backgroundColor: '#1a1a1a', + overflow: 'hidden', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }, + main: { flex: 1 }, + videoArea: { + width: '100%', + height: 66, + backgroundColor: '#111', + }, + liveBadge: { + position: 'absolute', + top: 4, + left: 6, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', + paddingHorizontal: 5, + paddingVertical: 2, + borderRadius: 3, + gap: 3, + }, + liveDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#f00' }, + liveText: { color: '#fff', fontSize: 9, fontWeight: '700', letterSpacing: 0.5 }, + titleText: { + color: '#fff', + fontSize: 11, + paddingHorizontal: 6, + paddingVertical: 3, + lineHeight: 14, + height: 24, + backgroundColor: '#1a1a1a', + }, + 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/store/liveStore.ts b/store/liveStore.ts new file mode 100644 index 0000000..444b49f --- /dev/null +++ b/store/liveStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +interface LiveStore { + isActive: boolean; + roomId: number; + title: string; + cover: string; // room.keyframe(直播截图)用于 web 端降级封面 + hlsUrl: string; + setLive: (roomId: number, title: string, cover: string, hlsUrl: string) => void; + clearLive: () => void; +} + +export const useLiveStore = create(set => ({ + isActive: false, + roomId: 0, + title: '', + cover: '', + hlsUrl: '', + setLive: (roomId, title, cover, hlsUrl) => + set({ isActive: true, roomId, title, cover, hlsUrl }), + clearLive: () => + set({ isActive: false, roomId: 0, title: '', cover: '', hlsUrl: '' }), +})); From 2afe5839f9fca07f9e137125585ee97fccee699c Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 13:20:58 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E5=B0=8F=E7=AA=97=E6=8C=89=E9=92=AE=E7=82=B9=E5=87=BB=E5=90=8E?= =?UTF-8?q?=E5=B0=8F=E7=AA=97=E4=B8=8D=E6=98=BE=E7=A4=BA=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useEffect 依赖 miniRoomId 导致 setLive 触发 clearLive 立即清除状态。 改为仅挂载时运行,用 getState() 读取当前值而非订阅响应式变化。 --- app/live/[roomId].tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx index f0652b8..decdc58 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -37,14 +37,15 @@ export default function LiveDetailScreen() { const qualities = stream?.qualities ?? []; const currentQn = stream?.qn ?? 0; - const { roomId: miniRoomId, setLive, clearLive } = useLiveStore(); + const { setLive, clearLive } = useLiveStore(); // 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播 + // 仅在挂载时运行一次(用 getState 读值,不创建响应式依赖) useEffect(() => { - if (miniRoomId === id) { + if (useLiveStore.getState().roomId === id) { clearLive(); } - }, [id, miniRoomId]); + }, []); const actualRoomId = room?.roomid ?? id; const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0); From 0ffb331971b95023f4373502c8a01b374722a788 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 13:25:34 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E5=B0=8F=E7=AA=97=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E5=90=8E=E8=87=AA=E5=8A=A8=E5=90=B8=E9=99=84=E5=88=B0?= =?UTF-8?q?=E5=B7=A6/=E5=8F=B3=E5=B1=8F=E5=B9=95=E8=BE=B9=E7=BC=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/LiveMiniPlayer.tsx | 22 +++++++++++++++------- components/MiniPlayer.tsx | 18 +++++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx index 50afa82..9be127c 100644 --- a/components/LiveMiniPlayer.tsx +++ b/components/LiveMiniPlayer.tsx @@ -49,14 +49,22 @@ export function LiveMiniPlayer() { const { width: sw, height: sh } = Dimensions.get('window'); const curX = (pan.x as any)._value; const curY = (pan.y as any)._value; - const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX)); + + // 吸附到左边缘或右边缘(取最近的一侧) + // container 默认 right:12,pan.x=0 为右侧,-(sw-MINI_W-24) 为左侧贴边 + const snapRight = 0; + const snapLeft = -(sw - MINI_W - 24); + const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; + + // Y 轴仅做越界回弹,不吸附 const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - if (curX !== clampedX || curY !== clampedY) { - Animated.spring(pan, { - toValue: { x: clampedX, y: clampedY }, - useNativeDriver: false, - }).start(); - } + + Animated.spring(pan, { + toValue: { x: snapX, y: clampedY }, + useNativeDriver: false, + tension: 120, + friction: 10, + }).start(); }, }), ).current; diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index 17a87b8..a1a307e 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -28,15 +28,23 @@ export function MiniPlayer() { onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), onPanResponderRelease: () => { pan.flattenOffset(); - // Clamp to screen bounds const { width: sw, height: sh } = Dimensions.get('window'); const curX = (pan.x as any)._value; const curY = (pan.y as any)._value; - const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX)); + + // 吸附到左边缘或右边缘(取最近的一侧) + const snapRight = 0; + const snapLeft = -(sw - MINI_W - 24); + const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; + const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - if (curX !== clampedX || curY !== clampedY) { - Animated.spring(pan, { toValue: { x: clampedX, y: clampedY }, useNativeDriver: false }).start(); - } + + Animated.spring(pan, { + toValue: { x: snapX, y: clampedY }, + useNativeDriver: false, + tension: 120, + friction: 10, + }).start(); }, }) ).current; From 2bfc9f8110f41b96581aabffe5f75d38b867c81a Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 13:28:49 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B0=8F=E7=AA=97?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=8B=96=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onStartShouldSetPanResponder 被内部 TouchableOpacity 抢占响应权。 改用 onMoveShouldSetPanResponderCapture:位移 >3px 时从子组件夺回响应权, 点击仍正常透传到 TouchableOpacity。 --- components/LiveMiniPlayer.tsx | 14 +++++++++++--- components/MiniPlayer.tsx | 11 +++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx index 9be127c..4237f30 100644 --- a/components/LiveMiniPlayer.tsx +++ b/components/LiveMiniPlayer.tsx @@ -35,7 +35,14 @@ export function LiveMiniPlayer() { const panResponder = useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => true, + // 不在 start 阶段抢夺,让 TouchableOpacity 的点击正常触发 + onStartShouldSetPanResponder: () => false, + onStartShouldSetPanResponderCapture: () => false, + // 有实际位移时,从子组件夺回响应权(capture 阶段,优先于子组件) + onMoveShouldSetPanResponder: (_, { dx, dy }) => + Math.abs(dx) > 3 || Math.abs(dy) > 3, + onMoveShouldSetPanResponderCapture: (_, { dx, dy }) => + Math.abs(dx) > 3 || Math.abs(dy) > 3, onPanResponderGrant: () => { pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); pan.setValue({ x: 0, y: 0 }); @@ -51,12 +58,10 @@ export function LiveMiniPlayer() { const curY = (pan.y as any)._value; // 吸附到左边缘或右边缘(取最近的一侧) - // container 默认 right:12,pan.x=0 为右侧,-(sw-MINI_W-24) 为左侧贴边 const snapRight = 0; const snapLeft = -(sw - MINI_W - 24); const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; - // Y 轴仅做越界回弹,不吸附 const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); Animated.spring(pan, { @@ -66,6 +71,9 @@ export function LiveMiniPlayer() { friction: 10, }).start(); }, + onPanResponderTerminate: () => { + pan.flattenOffset(); + }, }), ).current; diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index a1a307e..0d01f56 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -20,7 +20,12 @@ export function MiniPlayer() { const panResponder = useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponder: () => false, + onStartShouldSetPanResponderCapture: () => false, + onMoveShouldSetPanResponder: (_, { dx, dy }) => + Math.abs(dx) > 3 || Math.abs(dy) > 3, + onMoveShouldSetPanResponderCapture: (_, { dx, dy }) => + Math.abs(dx) > 3 || Math.abs(dy) > 3, onPanResponderGrant: () => { pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); pan.setValue({ x: 0, y: 0 }); @@ -32,7 +37,6 @@ export function MiniPlayer() { const curX = (pan.x as any)._value; const curY = (pan.y as any)._value; - // 吸附到左边缘或右边缘(取最近的一侧) const snapRight = 0; const snapLeft = -(sw - MINI_W - 24); const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; @@ -46,6 +50,9 @@ export function MiniPlayer() { friction: 10, }).start(); }, + onPanResponderTerminate: () => { + pan.flattenOffset(); + }, }) ).current; From 68b8b7d665718346bceabfb593510e308ae23a2f Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 13:36:28 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E5=BD=BB=E5=BA=95=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B0=8F=E7=AA=97=E6=97=A0=E6=B3=95=E6=8B=96=E5=8A=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因:TouchableOpacity 内嵌在 PanResponder Animated.View 内, JS层 TouchableOpacity 与 Video 原生层共同阻断了 PanResponder 响应权。 修复方案: - onStartShouldSetPanResponder: true,从 start 阶段独占响应权 - 移除主内容区的 TouchableOpacity,改用纯 View - isDragging ref 区分点击与拖动:位移 >5px 为拖动 - onPanResponderRelease 通过 locationX/Y 坐标判断点击目标(关闭 or 跳转) - Video 外层 View 加 pointerEvents='none' 防止原生层吞噬触摸 --- components/LiveMiniPlayer.tsx | 144 +++++++++++++++++----------------- components/MiniPlayer.tsx | 60 +++++++------- 2 files changed, 104 insertions(+), 100 deletions(-) diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx index 4237f30..6b73cb8 100644 --- a/components/LiveMiniPlayer.tsx +++ b/components/LiveMiniPlayer.tsx @@ -4,7 +4,6 @@ import { Text, Image, StyleSheet, - TouchableOpacity, Animated, PanResponder, Dimensions, @@ -26,84 +25,86 @@ const LIVE_HEADERS = { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }; +function snapRelease( + pan: Animated.ValueXY, + curX: number, + curY: number, + sw: number, + sh: number, +) { + const snapRight = 0; + const snapLeft = -(sw - MINI_W - 24); + const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; + const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); + Animated.spring(pan, { + toValue: { x: snapX, y: clampedY }, + useNativeDriver: false, + tension: 120, + friction: 10, + }).start(); +} + export function LiveMiniPlayer() { const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore(); const videoMiniActive = useVideoStore(s => s.isActive); const router = useRouter(); const insets = useSafeAreaInsets(); const pan = useRef(new Animated.ValueXY()).current; + const isDragging = useRef(false); const panResponder = useRef( PanResponder.create({ - // 不在 start 阶段抢夺,让 TouchableOpacity 的点击正常触发 - onStartShouldSetPanResponder: () => false, - onStartShouldSetPanResponderCapture: () => false, - // 有实际位移时,从子组件夺回响应权(capture 阶段,优先于子组件) - onMoveShouldSetPanResponder: (_, { dx, dy }) => - Math.abs(dx) > 3 || Math.abs(dy) > 3, - onMoveShouldSetPanResponderCapture: (_, { dx, dy }) => - Math.abs(dx) > 3 || Math.abs(dy) > 3, + onStartShouldSetPanResponder: () => true, onPanResponderGrant: () => { + isDragging.current = false; pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); pan.setValue({ x: 0, y: 0 }); }, - onPanResponderMove: Animated.event( - [null, { dx: pan.x, dy: pan.y }], - { useNativeDriver: false }, - ), - onPanResponderRelease: () => { + onPanResponderMove: (_, gs) => { + if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) { + isDragging.current = true; + } + pan.x.setValue(gs.dx); + pan.y.setValue(gs.dy); + }, + onPanResponderRelease: (evt) => { pan.flattenOffset(); + if (!isDragging.current) { + const { locationX, locationY } = evt.nativeEvent; + if (locationX > MINI_W - 28 && locationY < 28) { + clearLive(); + } else { + router.push(`/live/${roomId}` as any); + } + return; + } const { width: sw, height: sh } = Dimensions.get('window'); - const curX = (pan.x as any)._value; - const curY = (pan.y as any)._value; - - // 吸附到左边缘或右边缘(取最近的一侧) - const snapRight = 0; - const snapLeft = -(sw - MINI_W - 24); - const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; - - const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - - Animated.spring(pan, { - toValue: { x: snapX, y: clampedY }, - useNativeDriver: false, - tension: 120, - friction: 10, - }).start(); - }, - onPanResponderTerminate: () => { - pan.flattenOffset(); + snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh); }, + onPanResponderTerminate: () => { pan.flattenOffset(); }, }), ).current; if (!isActive) return null; - // 视频 MiniPlayer 激活时,直播小窗叠放其上方(避免重叠) const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0); - const handlePress = () => { - router.push(`/live/${roomId}` as any); - }; - - // Web 端降级:展示封面图 + LIVE 徽标 + // Web 端降级:封面图 + LIVE 徽标 if (Platform.OS === 'web') { return ( - - - - - LIVE - - {title} - - + + + + LIVE + + {title} + - + ); } @@ -116,29 +117,29 @@ export function LiveMiniPlayer() { style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]} {...panResponder.panHandlers} > - - - - - - LIVE - - {title} - - + {/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */} + + + + + LIVE + + {title} + {/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */} + - + ); } @@ -158,7 +159,6 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 4, }, - main: { flex: 1 }, videoArea: { width: '100%', height: 66, diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index 0d01f56..84817d8 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import { - View, Text, Image, StyleSheet, TouchableOpacity, + View, Text, Image, StyleSheet, Animated, PanResponder, Dimensions, } from 'react-native'; import { useRouter } from 'expo-router'; @@ -17,32 +17,44 @@ export function MiniPlayer() { const router = useRouter(); const insets = useSafeAreaInsets(); const pan = useRef(new Animated.ValueXY()).current; + const isDragging = useRef(false); const panResponder = useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => false, - onStartShouldSetPanResponderCapture: () => false, - onMoveShouldSetPanResponder: (_, { dx, dy }) => - Math.abs(dx) > 3 || Math.abs(dy) > 3, - onMoveShouldSetPanResponderCapture: (_, { dx, dy }) => - Math.abs(dx) > 3 || Math.abs(dy) > 3, + // 从 start 阶段即抢占响应权,确保拖动可靠触发 + onStartShouldSetPanResponder: () => true, onPanResponderGrant: () => { + isDragging.current = false; pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); pan.setValue({ x: 0, y: 0 }); }, - onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), - onPanResponderRelease: () => { + onPanResponderMove: (_, gs) => { + if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) { + isDragging.current = true; + } + pan.x.setValue(gs.dx); + pan.y.setValue(gs.dy); + }, + onPanResponderRelease: (evt) => { pan.flattenOffset(); + if (!isDragging.current) { + // 点击:通过坐标判断是关闭还是跳转 + const { locationX, locationY } = evt.nativeEvent; + if (locationX > MINI_W - 28 && locationY < 28) { + clearVideo(); + } else { + router.push(`/video/${bvid}` as any); + } + return; + } + // 拖动:吸附到最近边缘 const { width: sw, height: sh } = Dimensions.get('window'); const curX = (pan.x as any)._value; const curY = (pan.y as any)._value; - const snapRight = 0; const snapLeft = -(sw - MINI_W - 24); const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; - const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, @@ -50,9 +62,7 @@ export function MiniPlayer() { friction: 10, }).start(); }, - onPanResponderTerminate: () => { - pan.flattenOffset(); - }, + onPanResponderTerminate: () => { pan.flattenOffset(); }, }) ).current; @@ -65,17 +75,12 @@ export function MiniPlayer() { style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]} {...panResponder.panHandlers} > - router.push(`/video/${bvid}` as any)} - activeOpacity={0.85} - > - - {title} - - + + {title} + {/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */} + - + ); } @@ -84,8 +89,8 @@ const styles = StyleSheet.create({ container: { position: 'absolute', right: 12, - width: 160, - height: 90, + width: MINI_W, + height: MINI_H, borderRadius: 8, backgroundColor: '#1a1a1a', overflow: 'hidden', @@ -95,7 +100,6 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 4, }, - main: { flex: 1 }, cover: { width: '100%', height: 64, backgroundColor: '#333' }, title: { color: '#fff', From e3def7d01b47dea7daf42ed1959c9664a169bcca Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 25 Mar 2026 15:03:49 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B0=8F=E7=AA=97?= =?UTF-8?q?=E9=97=AD=E5=8C=85=E8=BF=87=E6=9C=9F=E3=80=81=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=20loading=20=E5=8D=A1=E6=AD=BB=E3=80=81=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=92=AD=E6=94=BE=E5=99=A8=E6=80=A7=E8=83=BD=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 小窗 PanResponder 用 storeRef 替代闭包捕获,修复 roomId/bvid 始终为初始值 - useLiveDetail 用 ref 比对替代 cancelled 标志,防止 fetch 被意外取消 - 详情页 useLayoutEffect 同步清除小窗,BigVideoCard 小窗活跃时跳过播放 - 视频播放器竖屏/全屏互斥渲染,减半解码器占用 - onProgress 节流 + 退出全屏强制恢复播放 --- CHANGELOG.md | 17 +++++++ app/downloads.tsx | 31 ++++-------- app/live/[roomId].tsx | 19 ++++---- app/video/[bvid].tsx | 8 +++- components/BigVideoCard.tsx | 15 +++--- components/DanmakuList.tsx | 9 ++-- components/LiveMiniPlayer.tsx | 9 +++- components/MiniPlayer.tsx | 12 +++-- components/NativeVideoPlayer.tsx | 30 +++++++----- components/VideoPlayer.tsx | 82 ++++++++++++++++---------------- hooks/useLiveDetail.ts | 33 ++++++++----- 11 files changed, 147 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425ff5e..5971114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ --- +## [1.0.13] - 2026-03-25 + +### 修复 +- **小窗 PanResponder 闭包过期**:`useRef(PanResponder.create(...))` 捕获初始 `roomId=0` / `bvid=""`,导致点击小窗跳转到错误页面;改用 `storeRef` 模式保持最新值 +- **直播小窗进入详情无限 loading**:`useLiveDetail` 使用 `cancelled` 闭包标志,effect cleanup 后 fetch 被静默丢弃;改用 `latestRoomId` ref 比对替代 cancelled 模式 +- **进入播放器页面小窗不关闭**:视频/直播详情页进入时通过 `useLayoutEffect` + `getState().clearLive()` 同步清除小窗,避免双播和资源竞争 +- **BigVideoCard 与直播小窗冲突**:首页 BigVideoCard 自动播放与直播小窗竞争解码器资源;小窗活跃时跳过 Video 渲染,仅显示封面图 +- **退出全屏视频暂停**:互斥渲染后竖屏播放器重新挂载,react-native-video seek 后不自动恢复播放;`onLoad` 中强制 `paused` 状态切换触发播放 + +### 优化 +- **视频播放器单实例**:竖屏/全屏互斥渲染(`{!fullscreen && ...}` / `{fullscreen && ...}`),不再同时挂载两个 Video 解码器,减半 GPU/内存占用 +- **onProgress 节流**:`progressUpdateInterval` 从 250ms 调为 500ms,回调内增加 450ms 节流和 seeking 跳过,减少重渲染 +- **移除调试日志**:清理 NativeVideoPlayer 中遗留的 `console.log` +- **下载页 UI 优化**:下载管理页交互和暗黑主题适配 + +--- + ## [1.0.12] - 2026-03-25 ### 新增 diff --git a/app/downloads.tsx b/app/downloads.tsx index 51dccf2..f1620a3 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -8,7 +8,6 @@ import { Image, Modal, StatusBar, - useWindowDimensions, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -36,14 +35,10 @@ export default function DownloadsScreen() { const [playingUri, setPlayingUri] = useState(null); const [playingTitle, setPlayingTitle] = useState(''); const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null); - const [showControls, setShowControls] = useState(true); - const { width, height } = useWindowDimensions(); - const isLandscape = width > height; async function openPlayer(uri: string, title: string) { setPlayingTitle(title); setPlayingUri(uri); - setShowControls(true); await ScreenOrientation?.unlockAsync(); } @@ -133,29 +128,23 @@ export default function DownloadsScreen() { onRequestClose={closePlayer} >