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 && (
)}
- {!isLandscape && (
-
-
-
-
- {playingTitle}
-
- )}
+
+
+
+
+ {playingTitle}
+
@@ -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); }}
+ />
+ )}
-
-
-
-
- { lastTimeRef.current = t; onTimeUpdate?.(t); }}
- style={needsRotation ? { width: height, height: width } : { flex: 1 }}
- />
+ {fullscreen && (
+
+
+
+
+ { lastTimeRef.current = t; onTimeUpdate?.(t); }}
+ style={needsRotation ? { width: height, height: width } : { flex: 1 }}
+ />
+
-
-
+
+ )}
>
);
}
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: '' }),
+}));