mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
fix: 修复小窗闭包过期、详情页 loading 卡死、视频播放器性能问题
- 小窗 PanResponder 用 storeRef 替代闭包捕获,修复 roomId/bvid 始终为初始值 - useLiveDetail 用 ref 比对替代 cancelled 标志,防止 fetch 被意外取消 - 详情页 useLayoutEffect 同步清除小窗,BigVideoCard 小窗活跃时跳过播放 - 视频播放器竖屏/全屏互斥渲染,减半解码器占用 - onProgress 节流 + 退出全屏强制恢复播放
This commit is contained in:
17
CHANGELOG.md
17
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
|
## [1.0.12] - 2026-03-25
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Modal,
|
Modal,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
useWindowDimensions,
|
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -36,14 +35,10 @@ export default function DownloadsScreen() {
|
|||||||
const [playingUri, setPlayingUri] = useState<string | null>(null);
|
const [playingUri, setPlayingUri] = useState<string | null>(null);
|
||||||
const [playingTitle, setPlayingTitle] = useState('');
|
const [playingTitle, setPlayingTitle] = useState('');
|
||||||
const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null);
|
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) {
|
async function openPlayer(uri: string, title: string) {
|
||||||
setPlayingTitle(title);
|
setPlayingTitle(title);
|
||||||
setPlayingUri(uri);
|
setPlayingUri(uri);
|
||||||
setShowControls(true);
|
|
||||||
await ScreenOrientation?.unlockAsync();
|
await ScreenOrientation?.unlockAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,29 +128,23 @@ export default function DownloadsScreen() {
|
|||||||
onRequestClose={closePlayer}
|
onRequestClose={closePlayer}
|
||||||
>
|
>
|
||||||
<StatusBar hidden />
|
<StatusBar hidden />
|
||||||
<TouchableOpacity
|
<View style={styles.playerBg}>
|
||||||
activeOpacity={1}
|
|
||||||
style={styles.playerBg}
|
|
||||||
onPress={() => setShowControls(v => !v)}
|
|
||||||
>
|
|
||||||
{playingUri && (
|
{playingUri && (
|
||||||
<Video
|
<Video
|
||||||
source={{ uri: playingUri }}
|
source={{ uri: playingUri }}
|
||||||
style={isLandscape ? { width, height } : { width, height: width * 0.5625 }}
|
style={StyleSheet.absoluteFillObject}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
controls={false}
|
controls
|
||||||
paused={false}
|
paused={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showControls && (
|
<View style={styles.playerBar}>
|
||||||
<View style={[styles.playerBar, isLandscape && { top: 16 }]}>
|
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
<Ionicons name="chevron-back" size={24} color="#fff" />
|
||||||
<Ionicons name="chevron-back" size={24} color="#fff" />
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
|
||||||
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -24,9 +24,16 @@ type Tab = "intro" | "danmaku";
|
|||||||
|
|
||||||
export default function LiveDetailScreen() {
|
export default function LiveDetailScreen() {
|
||||||
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
||||||
|
console.log("LiveDetailScreen params:", { roomId });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const id = parseInt(roomId ?? "0", 10);
|
const id = parseInt(roomId ?? "0", 10);
|
||||||
|
|
||||||
|
// 进入详情页时立即清除小窗(useLayoutEffect 在绘制前同步执行)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
useLiveStore.getState().clearLive();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { room, anchor, stream, loading, error, changeQuality } =
|
const { room, anchor, stream, loading, error, changeQuality } =
|
||||||
useLiveDetail(id);
|
useLiveDetail(id);
|
||||||
const [tab, setTab] = useState<Tab>("intro");
|
const [tab, setTab] = useState<Tab>("intro");
|
||||||
@@ -37,15 +44,7 @@ export default function LiveDetailScreen() {
|
|||||||
const qualities = stream?.qualities ?? [];
|
const qualities = stream?.qualities ?? [];
|
||||||
const currentQn = stream?.qn ?? 0;
|
const currentQn = stream?.qn ?? 0;
|
||||||
|
|
||||||
const { setLive, clearLive } = useLiveStore();
|
const setLive = useLiveStore(s => s.setLive);
|
||||||
|
|
||||||
// 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播
|
|
||||||
// 仅在挂载时运行一次(用 getState 读值,不创建响应式依赖)
|
|
||||||
useEffect(() => {
|
|
||||||
if (useLiveStore.getState().roomId === id) {
|
|
||||||
clearLive();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actualRoomId = room?.roomid ?? id;
|
const actualRoomId = room?.roomid ?? id;
|
||||||
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
|
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -23,6 +23,7 @@ import { formatCount, formatDuration } from "../../utils/format";
|
|||||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||||
import { DownloadSheet } from "../../components/DownloadSheet";
|
import { DownloadSheet } from "../../components/DownloadSheet";
|
||||||
import { useTheme } from "../../utils/theme";
|
import { useTheme } from "../../utils/theme";
|
||||||
|
import { useLiveStore } from "../../store/liveStore";
|
||||||
|
|
||||||
type Tab = "intro" | "comments" | "danmaku";
|
type Tab = "intro" | "comments" | "danmaku";
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ export default function VideoDetailScreen() {
|
|||||||
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// 进入视频详情页时立即清除直播小窗
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
useLiveStore.getState().clearLive();
|
||||||
|
}, []);
|
||||||
const {
|
const {
|
||||||
video,
|
video,
|
||||||
playData,
|
playData,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getPlayUrl, getVideoDetail } from "../services/bilibili";
|
|||||||
import { coverImageUrl } from "../utils/imageUrl";
|
import { coverImageUrl } from "../utils/imageUrl";
|
||||||
import { useSettingsStore } from "../store/settingsStore";
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
import { useTheme } from "../utils/theme";
|
import { useTheme } from "../utils/theme";
|
||||||
|
import { useLiveStore } from "../store/liveStore";
|
||||||
import { formatCount, formatDuration } from "../utils/format";
|
import { formatCount, formatDuration } from "../utils/format";
|
||||||
import type { VideoItem } from "../services/types";
|
import type { VideoItem } from "../services/types";
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { width: SCREEN_W } = useWindowDimensions();
|
const { width: SCREEN_W } = useWindowDimensions();
|
||||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||||
|
const liveActive = useLiveStore(s => s.isActive);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const THUMB_H = SCREEN_W * 0.5625;
|
const THUMB_H = SCREEN_W * 0.5625;
|
||||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
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)
|
// Preload: fetch play URL on mount (before card is visible)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoUrl || trafficSaving) return;
|
if (videoUrl || trafficSaving || liveActive) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -127,8 +129,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
// Pause/resume based on visibility and scroll state
|
// Pause/resume based on visibility and scroll state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoUrl) return;
|
if (!videoUrl) return;
|
||||||
if (!isVisible || trafficSaving) {
|
if (!isVisible || trafficSaving || liveActive) {
|
||||||
// Off-screen or traffic saving: pause, mute, show thumbnail
|
|
||||||
setPaused(true);
|
setPaused(true);
|
||||||
setMuted(true);
|
setMuted(true);
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
@@ -137,10 +138,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
} else if (isScrolling) {
|
} else if (isScrolling) {
|
||||||
// Visible but scrolling: just pause (keep thumbnail hidden, keep mute state)
|
|
||||||
setPaused(true);
|
setPaused(true);
|
||||||
} else {
|
} else {
|
||||||
// Visible and not scrolling: play, fade out thumbnail
|
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -148,10 +147,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
}, [isVisible, isScrolling, videoUrl, trafficSaving]);
|
}, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]);
|
||||||
|
|
||||||
const handleVideoReady = () => {
|
const handleVideoReady = () => {
|
||||||
if (!isVisible || isScrolling || trafficSaving) return;
|
if (!isVisible || isScrolling || trafficSaving || liveActive) return;
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -229,7 +228,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
{/* Media area */}
|
{/* Media area */}
|
||||||
<View style={[mediaDimensions, { position: "relative" }]}>
|
<View style={[mediaDimensions, { position: "relative" }]}>
|
||||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||||
{videoUrl && (
|
{videoUrl && !liveActive && (
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={
|
source={
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export default function DanmakuList({
|
|||||||
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
|
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
||||||
<View style={liveStyles.msgBody}>
|
<View style={liveStyles.msgBody}>
|
||||||
{guard && (
|
{guard && (
|
||||||
<View
|
<View
|
||||||
@@ -242,7 +242,9 @@ export default function DanmakuList({
|
|||||||
<View style={liveStyles.medalTag}>
|
<View style={liveStyles.medalTag}>
|
||||||
<Text style={liveStyles.medalName}>{item.medalName}</Text>
|
<Text style={liveStyles.medalName}>{item.medalName}</Text>
|
||||||
<View style={liveStyles.medalLvBox}>
|
<View style={liveStyles.medalLvBox}>
|
||||||
<Text style={liveStyles.medalLv}>{item.medalLevel}</Text>
|
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
|
||||||
|
{item.medalLevel}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -257,7 +259,6 @@ export default function DanmakuList({
|
|||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -451,7 +452,7 @@ const liveStyles = StyleSheet.create({
|
|||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
justifyContent:"space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export function LiveMiniPlayer() {
|
|||||||
const pan = useRef(new Animated.ValueXY()).current;
|
const pan = useRef(new Animated.ValueXY()).current;
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||||
|
const storeRef = useRef({ roomId, clearLive, router });
|
||||||
|
storeRef.current = { roomId, clearLive, router };
|
||||||
|
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
@@ -71,10 +75,11 @@ export function LiveMiniPlayer() {
|
|||||||
pan.flattenOffset();
|
pan.flattenOffset();
|
||||||
if (!isDragging.current) {
|
if (!isDragging.current) {
|
||||||
const { locationX, locationY } = evt.nativeEvent;
|
const { locationX, locationY } = evt.nativeEvent;
|
||||||
|
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
|
||||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||||
clearLive();
|
clear();
|
||||||
} else {
|
} else {
|
||||||
router.push(`/live/${roomId}` as any);
|
r.push(`/live/${rid}` as any);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export function MiniPlayer() {
|
|||||||
const pan = useRef(new Animated.ValueXY()).current;
|
const pan = useRef(new Animated.ValueXY()).current;
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||||
|
const storeRef = useRef({ bvid, clearVideo, router });
|
||||||
|
storeRef.current = { bvid, clearVideo, router };
|
||||||
|
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
// 从 start 阶段即抢占响应权,确保拖动可靠触发
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
onPanResponderGrant: () => {
|
onPanResponderGrant: () => {
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
@@ -38,16 +41,15 @@ export function MiniPlayer() {
|
|||||||
onPanResponderRelease: (evt) => {
|
onPanResponderRelease: (evt) => {
|
||||||
pan.flattenOffset();
|
pan.flattenOffset();
|
||||||
if (!isDragging.current) {
|
if (!isDragging.current) {
|
||||||
// 点击:通过坐标判断是关闭还是跳转
|
|
||||||
const { locationX, locationY } = evt.nativeEvent;
|
const { locationX, locationY } = evt.nativeEvent;
|
||||||
|
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
|
||||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||||
clearVideo();
|
clear();
|
||||||
} else {
|
} else {
|
||||||
router.push(`/video/${bvid}` as any);
|
r.push(`/video/${vid}` as any);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 拖动:吸附到最近边缘
|
|
||||||
const { width: sw, height: sh } = Dimensions.get('window');
|
const { width: sw, height: sh } = Dimensions.get('window');
|
||||||
const curX = (pan.x as any)._value;
|
const curX = (pan.x as any)._value;
|
||||||
const curY = (pan.y as any)._value;
|
const curY = (pan.y as any)._value;
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
|
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const currentTimeRef = useRef(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const durationRef = useRef(0);
|
const durationRef = useRef(0);
|
||||||
|
const lastProgressUpdate = useRef(0);
|
||||||
|
|
||||||
const [showQuality, setShowQuality] = useState(false);
|
const [showQuality, setShowQuality] = useState(false);
|
||||||
|
|
||||||
@@ -318,16 +320,6 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
const local = frameIdx % framesPerSheet;
|
const local = frameIdx % framesPerSheet;
|
||||||
const col = local % img_x_len;
|
const col = local % img_x_len;
|
||||||
const row = Math.floor(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 scale = THUMB_DISPLAY_W / TW;
|
||||||
const DW = THUMB_DISPLAY_W;
|
const DW = THUMB_DISPLAY_W;
|
||||||
@@ -396,20 +388,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
controls={false}
|
controls={false}
|
||||||
paused={!!(forcePaused || paused)}
|
paused={!!(forcePaused || paused)}
|
||||||
|
progressUpdateInterval={500}
|
||||||
onProgress={({
|
onProgress={({
|
||||||
currentTime: ct,
|
currentTime: ct,
|
||||||
seekableDuration: dur,
|
seekableDuration: dur,
|
||||||
playableDuration: buf,
|
playableDuration: buf,
|
||||||
}) => {
|
}) => {
|
||||||
setCurrentTime(ct);
|
currentTimeRef.current = ct;
|
||||||
if (dur > 0) setDuration(dur);
|
|
||||||
setBuffered(buf);
|
|
||||||
onTimeUpdate?.(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={() => {
|
onLoad={() => {
|
||||||
if (initialTime && initialTime > 0) {
|
if (initialTime && initialTime > 0) {
|
||||||
videoRef.current?.seek(initialTime);
|
videoRef.current?.seek(initialTime);
|
||||||
}
|
}
|
||||||
|
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
|
||||||
|
if (!forcePaused) {
|
||||||
|
setPaused(true);
|
||||||
|
requestAnimationFrame(() => setPaused(false));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 杜比视界播放失败时自动降级到 1080P
|
// 杜比视界播放失败时自动降级到 1080P
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExitFullscreen = async () => {
|
const handleExitFullscreen = async () => {
|
||||||
// 退出全屏:同步进度,竖屏一律暂停
|
|
||||||
portraitRef.current?.seek(lastTimeRef.current);
|
|
||||||
portraitRef.current?.setPaused(true);
|
|
||||||
setFullscreen(false);
|
setFullscreen(false);
|
||||||
if (Platform.OS !== 'web')
|
if (Platform.OS !== 'web')
|
||||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||||
@@ -71,46 +68,49 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Portrait player: always mounted, force-paused while fullscreen is active */}
|
{/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */}
|
||||||
<NativeVideoPlayer
|
{!fullscreen && (
|
||||||
ref={portraitRef}
|
<NativeVideoPlayer
|
||||||
playData={playData}
|
ref={portraitRef}
|
||||||
qualities={qualities}
|
playData={playData}
|
||||||
currentQn={currentQn}
|
qualities={qualities}
|
||||||
onQualityChange={onQualityChange}
|
currentQn={currentQn}
|
||||||
onFullscreen={handleEnterFullscreen}
|
onQualityChange={onQualityChange}
|
||||||
bvid={bvid}
|
onFullscreen={handleEnterFullscreen}
|
||||||
cid={cid}
|
bvid={bvid}
|
||||||
isFullscreen={false}
|
cid={cid}
|
||||||
forcePaused={fullscreen}
|
isFullscreen={false}
|
||||||
initialTime={lastTimeRef.current}
|
initialTime={lastTimeRef.current}
|
||||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal visible={fullscreen} animationType="none" statusBarTranslucent>
|
{fullscreen && (
|
||||||
<StatusBar hidden />
|
<Modal visible animationType="none" statusBarTranslucent>
|
||||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
<StatusBar hidden />
|
||||||
<View style={needsRotation
|
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
|
<View style={needsRotation
|
||||||
: { flex: 1, width: '100%' }
|
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
|
||||||
}>
|
: { flex: 1, width: '100%' }
|
||||||
<NativeVideoPlayer
|
}>
|
||||||
playData={playData}
|
<NativeVideoPlayer
|
||||||
qualities={qualities}
|
playData={playData}
|
||||||
currentQn={currentQn}
|
qualities={qualities}
|
||||||
onQualityChange={onQualityChange}
|
currentQn={currentQn}
|
||||||
onFullscreen={handleExitFullscreen}
|
onQualityChange={onQualityChange}
|
||||||
bvid={bvid}
|
onFullscreen={handleExitFullscreen}
|
||||||
cid={cid}
|
bvid={bvid}
|
||||||
danmakus={danmakus}
|
cid={cid}
|
||||||
isFullscreen={true}
|
danmakus={danmakus}
|
||||||
initialTime={lastTimeRef.current}
|
isFullscreen={true}
|
||||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
initialTime={lastTimeRef.current}
|
||||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||||
/>
|
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Modal>
|
||||||
</Modal>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili';
|
||||||
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
|
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
|
||||||
|
|
||||||
@@ -19,35 +19,42 @@ export function useLiveDetail(roomId: number) {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用 ref 追踪最新的 roomId,避免 cancelled 闭包问题
|
||||||
|
const latestRoomId = useRef(roomId);
|
||||||
|
latestRoomId.current = roomId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
setState({ room: null, anchor: null, stream: null, loading: true, error: null });
|
setState({ room: null, anchor: null, stream: null, loading: true, error: null });
|
||||||
|
|
||||||
async function fetch() {
|
const fetchId = roomId; // 捕获当前 roomId
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
try {
|
try {
|
||||||
const [room, anchor] = await Promise.all([
|
const [room, anchor] = await Promise.all([
|
||||||
getLiveRoomDetail(roomId),
|
getLiveRoomDetail(fetchId),
|
||||||
getLiveAnchorInfo(roomId),
|
getLiveAnchorInfo(fetchId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
|
||||||
|
// 仅在 roomId 未变化时更新状态(替代 cancelled 模式)
|
||||||
|
if (latestRoomId.current !== fetchId) return;
|
||||||
|
|
||||||
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
|
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
|
||||||
if (room.live_status === 1) {
|
if (room?.live_status === 1) {
|
||||||
stream = await getLiveStreamUrl(roomId);
|
stream = await getLiveStreamUrl(fetchId);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
|
||||||
|
if (latestRoomId.current !== fetchId) return;
|
||||||
|
|
||||||
setState({ room, anchor, stream, loading: false, error: null });
|
setState({ room, anchor, stream, loading: false, error: null });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (cancelled) return;
|
if (latestRoomId.current !== fetchId) return;
|
||||||
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
|
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch();
|
doFetch();
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
const changeQuality = useCallback(async (qn: number) => {
|
const changeQuality = useCallback(async (qn: number) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user