diff --git a/CHANGELOG.md b/CHANGELOG.md index 425ff5e..d92c93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ --- +## [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/_layout.tsx b/app/_layout.tsx index dc7aee8..6c36a55 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'; import { useFonts } from 'expo-font'; @@ -87,6 +88,7 @@ function RootLayout() { + ); diff --git a/app/downloads.tsx b/app/downloads.tsx index 0f19eaf..f1620a3 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -6,10 +6,9 @@ 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 +18,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 +27,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,8 +35,6 @@ export default function DownloadsScreen() { const [playingUri, setPlayingUri] = useState(null); const [playingTitle, setPlayingTitle] = useState(''); const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null); - const { width, height } = useWindowDimensions(); - const isLandscape = width > height; async function openPlayer(uri: string, title: string) { setPlayingTitle(title); @@ -50,6 +47,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 +83,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 }} /> )} @@ -119,22 +132,18 @@ export default function DownloadsScreen() { {playingUri && ( @@ -143,98 +152,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 +268,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 +291,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/live/[roomId].tsx b/app/live/[roomId].tsx index 9881f34..5ba3731 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useLayoutEffect, useRef } from "react"; import { View, Text, @@ -18,14 +18,22 @@ 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"; export default function LiveDetailScreen() { const { roomId } = useLocalSearchParams<{ roomId: string }>(); + console.log("LiveDetailScreen params:", { roomId }); const router = useRouter(); const theme = useTheme(); const id = parseInt(roomId ?? "0", 10); + + // 进入详情页时立即清除小窗(useLayoutEffect 在绘制前同步执行) + useLayoutEffect(() => { + useLiveStore.getState().clearLive(); + }, []); + const { room, anchor, stream, loading, error, changeQuality } = useLiveDetail(id); const [tab, setTab] = useState("intro"); @@ -36,6 +44,8 @@ export default function LiveDetailScreen() { const qualities = stream?.qualities ?? []; const currentQn = stream?.qn ?? 0; + const setLive = useLiveStore(s => s.setLive); + 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/app/video/[bvid].tsx b/app/video/[bvid].tsx index 7e5ecdf..3da2dec 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useLayoutEffect, useRef } from "react"; import { View, Text, @@ -23,6 +23,7 @@ import { formatCount, formatDuration } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; import { DownloadSheet } from "../../components/DownloadSheet"; import { useTheme } from "../../utils/theme"; +import { useLiveStore } from "../../store/liveStore"; type Tab = "intro" | "comments" | "danmaku"; @@ -30,6 +31,11 @@ export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); const router = useRouter(); const theme = useTheme(); + + // 进入视频详情页时立即清除直播小窗 + useLayoutEffect(() => { + useLiveStore.getState().clearLive(); + }, []); const { video, playData, @@ -49,7 +55,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 +80,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 +208,8 @@ export default function VideoDetailScreen() { {uploaderStat && ( - {formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频 + {formatCount(uploaderStat.follower)}粉丝 ·{" "} + {formatCount(uploaderStat.archiveCount)}视频 )} @@ -465,7 +477,7 @@ function SeasonSection({ !isCurrent && onEpisodePress(ep.bvid)} @@ -564,10 +576,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/BigVideoCard.tsx b/components/BigVideoCard.tsx index 4199f06..f56143a 100644 --- a/components/BigVideoCard.tsx +++ b/components/BigVideoCard.tsx @@ -23,6 +23,7 @@ import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { coverImageUrl } from "../utils/imageUrl"; import { useSettingsStore } from "../store/settingsStore"; import { useTheme } from "../utils/theme"; +import { useLiveStore } from "../store/liveStore"; import { formatCount, formatDuration } from "../utils/format"; import type { VideoItem } from "../services/types"; @@ -57,6 +58,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ }: Props) { const { width: SCREEN_W } = useWindowDimensions(); const trafficSaving = useSettingsStore(s => s.trafficSaving); + const liveActive = useLiveStore(s => s.isActive); const theme = useTheme(); const THUMB_H = SCREEN_W * 0.5625; const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H }; @@ -92,7 +94,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ // Preload: fetch play URL on mount (before card is visible) useEffect(() => { - if (videoUrl || trafficSaving) return; + if (videoUrl || trafficSaving || liveActive) return; let cancelled = false; (async () => { try { @@ -127,8 +129,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ // Pause/resume based on visibility and scroll state useEffect(() => { if (!videoUrl) return; - if (!isVisible || trafficSaving) { - // Off-screen or traffic saving: pause, mute, show thumbnail + if (!isVisible || trafficSaving || liveActive) { setPaused(true); setMuted(true); Animated.timing(thumbOpacity, { @@ -137,10 +138,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({ useNativeDriver: true, }).start(); } else if (isScrolling) { - // Visible but scrolling: just pause (keep thumbnail hidden, keep mute state) setPaused(true); } else { - // Visible and not scrolling: play, fade out thumbnail setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, @@ -148,10 +147,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({ useNativeDriver: true, }).start(); } - }, [isVisible, isScrolling, videoUrl, trafficSaving]); + }, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]); const handleVideoReady = () => { - if (!isVisible || isScrolling || trafficSaving) return; + if (!isVisible || isScrolling || trafficSaving || liveActive) return; setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, @@ -229,7 +228,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ {/* Media area */} {/* Video player — rendered first so it sits behind the thumbnail */} - {videoUrl && ( + {videoUrl && !liveActive && ( )} @@ -275,7 +277,7 @@ export default function DanmakuList({ ]} > {item.text} @@ -450,6 +452,7 @@ const liveStyles = StyleSheet.create({ row: { flexDirection: "row", alignItems: "flex-start", + justifyContent: "space-between", paddingVertical: 5, }, time: { @@ -492,7 +495,6 @@ const liveStyles = StyleSheet.create({ paddingHorizontal: 3, }, medalLvBox: { - backgroundColor: "#e891ab", paddingHorizontal: 3, height: "100%", justifyContent: "center", diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx new file mode 100644 index 0000000..debc842 --- /dev/null +++ b/components/LiveMiniPlayer.tsx @@ -0,0 +1,206 @@ +import React, { useRef } from 'react'; +import { + View, + Text, + Image, + StyleSheet, + 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', +}; + +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); + + // 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值 + const storeRef = useRef({ roomId, clearLive, router }); + storeRef.current = { roomId, clearLive, router }; + + const panResponder = useRef( + PanResponder.create({ + 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: (_, 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; + const { roomId: rid, clearLive: clear, router: r } = storeRef.current; + if (locationX > MINI_W - 28 && locationY < 28) { + clear(); + } else { + r.push(`/live/${rid}` as any); + } + return; + } + const { width: sw, height: sh } = Dimensions.get('window'); + snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh); + }, + onPanResponderTerminate: () => { pan.flattenOffset(); }, + }), + ).current; + + if (!isActive) return null; + + const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0); + + // Web 端降级:封面图 + LIVE 徽标 + if (Platform.OS === 'web') { + return ( + + + + + LIVE + + {title} + + + + + ); + } + + // Native:实际 HLS 流播放 + const Video = require('react-native-video').default; + + return ( + + {/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */} + + + + + LIVE + + {title} + {/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */} + + + + + ); +} + +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, + }, + 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/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index 17a87b8..f445b6f 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,27 +17,54 @@ export function MiniPlayer() { const router = useRouter(); const insets = useSafeAreaInsets(); const pan = useRef(new Animated.ValueXY()).current; + const isDragging = useRef(false); + + // 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值 + const storeRef = useRef({ bvid, clearVideo, router }); + storeRef.current = { bvid, clearVideo, router }; const panResponder = useRef( PanResponder.create({ 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(); - // Clamp to screen bounds + if (!isDragging.current) { + const { locationX, locationY } = evt.nativeEvent; + const { bvid: vid, clearVideo: clear, router: r } = storeRef.current; + if (locationX > MINI_W - 28 && locationY < 28) { + clear(); + } else { + r.push(`/video/${vid}` 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 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(); }, + onPanResponderTerminate: () => { pan.flattenOffset(); }, }) ).current; @@ -50,17 +77,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 坐标判断处理 */} + - + ); } @@ -69,8 +91,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', @@ -80,7 +102,6 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 4, }, - main: { flex: 1 }, cover: { width: '100%', height: 64, backgroundColor: '#333' }, title: { color: '#fff', diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 3855936..2b7e83e 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -109,8 +109,10 @@ export const NativeVideoPlayer = forwardRef( const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); + const currentTimeRef = useRef(0); const [duration, setDuration] = useState(0); const durationRef = useRef(0); + const lastProgressUpdate = useRef(0); const [showQuality, setShowQuality] = useState(false); @@ -318,16 +320,6 @@ export const NativeVideoPlayer = forwardRef( const local = frameIdx % framesPerSheet; const col = local % img_x_len; const row = Math.floor(local / img_x_len); - console.log("[thumb]", { - seekTime, - duration, - indexLen: index?.length, - frameIdx, - totalFrames, - sheetIdx, - col, - row, - }); // 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比 const scale = THUMB_DISPLAY_W / TW; const DW = THUMB_DISPLAY_W; @@ -396,20 +388,32 @@ export const NativeVideoPlayer = forwardRef( resizeMode="contain" controls={false} paused={!!(forcePaused || paused)} + progressUpdateInterval={500} onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf, }) => { - setCurrentTime(ct); - if (dur > 0) setDuration(dur); - setBuffered(buf); + currentTimeRef.current = ct; onTimeUpdate?.(ct); + // 拖动进度条时跳过 UI 更新,避免与用户拖动冲突 + if (isSeekingRef.current) return; + const now = Date.now(); + if (now - lastProgressUpdate.current < 450) return; + lastProgressUpdate.current = now; + setCurrentTime(ct); + if (dur > 0 && Math.abs(dur - durationRef.current) > 1) setDuration(dur); + setBuffered(buf); }} onLoad={() => { if (initialTime && initialTime > 0) { videoRef.current?.seek(initialTime); } + // seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化 + if (!forcePaused) { + setPaused(true); + requestAnimationFrame(() => setPaused(false)); + } }} onError={(e) => { // 杜比视界播放失败时自动降级到 1080P diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 9e265f1..593de57 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -32,9 +32,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b }; const handleExitFullscreen = async () => { - // 退出全屏:同步进度,竖屏一律暂停 - portraitRef.current?.seek(lastTimeRef.current); - portraitRef.current?.setPaused(true); setFullscreen(false); if (Platform.OS !== 'web') await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); @@ -71,46 +68,49 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b return ( <> - {/* Portrait player: always mounted, force-paused while fullscreen is active */} - { lastTimeRef.current = t; onTimeUpdate?.(t); }} - /> + {/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */} + {!fullscreen && ( + { lastTimeRef.current = t; onTimeUpdate?.(t); }} + /> + )} - - + + )} ); } diff --git a/hooks/useLiveDetail.ts b/hooks/useLiveDetail.ts index ae3b249..b8be241 100644 --- a/hooks/useLiveDetail.ts +++ b/hooks/useLiveDetail.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili'; import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types'; @@ -19,35 +19,42 @@ export function useLiveDetail(roomId: number) { error: null, }); + // 用 ref 追踪最新的 roomId,避免 cancelled 闭包问题 + const latestRoomId = useRef(roomId); + latestRoomId.current = roomId; + useEffect(() => { if (!roomId) return; - let cancelled = false; - + setState({ room: null, anchor: null, stream: null, loading: true, error: null }); - async function fetch() { + const fetchId = roomId; // 捕获当前 roomId + + async function doFetch() { try { const [room, anchor] = await Promise.all([ - getLiveRoomDetail(roomId), - getLiveAnchorInfo(roomId), + getLiveRoomDetail(fetchId), + getLiveAnchorInfo(fetchId), ]); - if (cancelled) return; + + // 仅在 roomId 未变化时更新状态(替代 cancelled 模式) + if (latestRoomId.current !== fetchId) return; let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] }; - if (room.live_status === 1) { - stream = await getLiveStreamUrl(roomId); + if (room?.live_status === 1) { + stream = await getLiveStreamUrl(fetchId); } - if (cancelled) return; + + if (latestRoomId.current !== fetchId) return; setState({ room, anchor, stream, loading: false, error: null }); } catch (e: any) { - if (cancelled) return; + if (latestRoomId.current !== fetchId) return; setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' })); } } - fetch(); - return () => { cancelled = true; }; + doFetch(); }, [roomId]); const changeQuality = useCallback(async (qn: number) => { 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: '' }), +}));