2026-03-17 22:18:05 +08:00
|
|
|
|
import React, {
|
|
|
|
|
|
useState,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
forwardRef,
|
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
|
} from "react";
|
2026-03-12 23:57:01 +08:00
|
|
|
|
import { formatDuration } from "../utils/format";
|
2026-03-10 19:04:18 +08:00
|
|
|
|
import {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
View,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
TouchableWithoutFeedback,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Image,
|
|
|
|
|
|
PanResponder,
|
2026-05-12 20:27:30 +08:00
|
|
|
|
ActivityIndicator,
|
|
|
|
|
|
Animated,
|
2026-03-11 20:53:18 +08:00
|
|
|
|
useWindowDimensions,
|
|
|
|
|
|
} from "react-native";
|
|
|
|
|
|
import Video, { VideoRef } from "react-native-video";
|
|
|
|
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
|
|
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
|
|
|
|
import type {
|
|
|
|
|
|
PlayUrlResponse,
|
|
|
|
|
|
VideoShotData,
|
|
|
|
|
|
DanmakuItem,
|
2026-05-12 20:27:30 +08:00
|
|
|
|
IVideoPlayer,
|
2026-03-11 20:53:18 +08:00
|
|
|
|
} from "../services/types";
|
|
|
|
|
|
import { buildDashMpdUri } from "../utils/dash";
|
2026-03-12 23:57:01 +08:00
|
|
|
|
import { getVideoShot } from "../services/bilibili";
|
2026-03-11 20:53:18 +08:00
|
|
|
|
import DanmakuOverlay from "./DanmakuOverlay";
|
2026-03-26 00:43:37 +08:00
|
|
|
|
import { useTheme } from "../utils/theme";
|
2026-05-12 20:27:30 +08:00
|
|
|
|
import { usePlayProgressStore } from "../store/playProgressStore";
|
2026-03-06 12:58:41 +08:00
|
|
|
|
|
2026-03-10 21:46:19 +08:00
|
|
|
|
const BAR_H = 3;
|
2026-03-11 10:03:46 +08:00
|
|
|
|
// 进度球尺寸
|
2026-03-10 21:46:19 +08:00
|
|
|
|
const BALL = 12;
|
2026-03-11 10:03:46 +08:00
|
|
|
|
// 活跃状态下的拖动球增大尺寸,提升触控体验
|
2026-03-10 21:46:19 +08:00
|
|
|
|
const BALL_ACTIVE = 16;
|
|
|
|
|
|
const HIDE_DELAY = 3000;
|
2026-03-06 12:58:41 +08:00
|
|
|
|
|
2026-03-10 21:46:19 +08:00
|
|
|
|
const HEADERS = {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
Referer: "https://www.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",
|
2026-03-10 20:04:48 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-10 21:46:19 +08:00
|
|
|
|
function clamp(v: number, lo: number, hi: number) {
|
|
|
|
|
|
return Math.max(lo, Math.min(hi, v));
|
|
|
|
|
|
}
|
2026-03-17 15:14:23 +08:00
|
|
|
|
//
|
2026-03-16 14:24:32 +08:00
|
|
|
|
function findFrameByTime(index: number[], seekTime: number): number {
|
2026-03-17 15:14:23 +08:00
|
|
|
|
let lo = 0,
|
|
|
|
|
|
hi = index.length - 1;
|
2026-03-16 14:24:32 +08:00
|
|
|
|
while (lo < hi) {
|
|
|
|
|
|
const mid = (lo + hi + 1) >> 1;
|
|
|
|
|
|
if (index[mid] <= seekTime) lo = mid;
|
|
|
|
|
|
else hi = mid - 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return lo;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
export interface NativeVideoPlayerRef extends IVideoPlayer {
|
|
|
|
|
|
/** @deprecated 用 pause()/resume() 代替 */
|
2026-03-19 20:11:05 +08:00
|
|
|
|
setPaused: (v: boolean) => void;
|
2026-03-17 17:26:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 12:58:41 +08:00
|
|
|
|
interface Props {
|
2026-03-10 19:04:18 +08:00
|
|
|
|
playData: PlayUrlResponse | null;
|
|
|
|
|
|
qualities: { qn: number; desc: string }[];
|
|
|
|
|
|
currentQn: number;
|
|
|
|
|
|
onQualityChange: (qn: number) => void;
|
|
|
|
|
|
onFullscreen: () => void;
|
|
|
|
|
|
style?: object;
|
2026-03-10 21:46:19 +08:00
|
|
|
|
bvid?: string;
|
|
|
|
|
|
cid?: number;
|
2026-03-10 22:50:48 +08:00
|
|
|
|
danmakus?: DanmakuItem[];
|
|
|
|
|
|
isFullscreen?: boolean;
|
|
|
|
|
|
onTimeUpdate?: (t: number) => void;
|
|
|
|
|
|
initialTime?: number;
|
2026-03-17 17:26:43 +08:00
|
|
|
|
forcePaused?: boolean;
|
2026-03-10 19:04:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|
|
|
|
|
function NativeVideoPlayer(
|
|
|
|
|
|
{
|
|
|
|
|
|
playData,
|
|
|
|
|
|
qualities,
|
|
|
|
|
|
currentQn,
|
|
|
|
|
|
onQualityChange,
|
|
|
|
|
|
onFullscreen,
|
|
|
|
|
|
style,
|
|
|
|
|
|
bvid,
|
|
|
|
|
|
cid,
|
|
|
|
|
|
danmakus,
|
|
|
|
|
|
isFullscreen,
|
|
|
|
|
|
onTimeUpdate,
|
|
|
|
|
|
initialTime,
|
|
|
|
|
|
forcePaused,
|
|
|
|
|
|
}: Props,
|
|
|
|
|
|
ref,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
|
|
|
|
|
const VIDEO_H = SCREEN_W * 0.5625;
|
2026-03-26 00:43:37 +08:00
|
|
|
|
const theme = useTheme();
|
2026-03-17 22:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
|
|
|
|
|
const isDash = !!playData?.dash;
|
|
|
|
|
|
|
|
|
|
|
|
const [showControls, setShowControls] = useState(true);
|
|
|
|
|
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const [paused, setPaused] = useState(false);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// seek 后强制触发 react-native-video 重新评估 paused prop 的 hack 用的瞬时叠加态
|
|
|
|
|
|
// 单独存储以避免污染 paused(用于图标显示):seek 完成的一瞬间不让"播放/暂停"图标闪
|
|
|
|
|
|
const [seekHackPaused, setSeekHackPaused] = useState(false);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
2026-03-25 15:03:49 +08:00
|
|
|
|
const currentTimeRef = useRef(0);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
|
|
const durationRef = useRef(0);
|
2026-03-25 15:03:49 +08:00
|
|
|
|
const lastProgressUpdate = useRef(0);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
const [showQuality, setShowQuality] = useState(false);
|
|
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 倍速
|
|
|
|
|
|
const RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2];
|
|
|
|
|
|
const [rate, setRate] = useState(1);
|
|
|
|
|
|
const [showRate, setShowRate] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 清晰度切换:保留进度 + loading 遮罩
|
|
|
|
|
|
const [switching, setSwitching] = useState(false);
|
|
|
|
|
|
const pendingSeekRef = useRef<number | null>(null);
|
|
|
|
|
|
const prevQnRef = useRef(currentQn);
|
|
|
|
|
|
const switchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 续播:每 5s 持久化一次
|
|
|
|
|
|
const lastSaveRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// 排除初始挂载(prevQn 或 currentQn === 0)
|
|
|
|
|
|
if (
|
|
|
|
|
|
prevQnRef.current !== 0 &&
|
|
|
|
|
|
currentQn !== 0 &&
|
|
|
|
|
|
prevQnRef.current !== currentQn
|
|
|
|
|
|
) {
|
|
|
|
|
|
pendingSeekRef.current = currentTimeRef.current;
|
|
|
|
|
|
setSwitching(true);
|
|
|
|
|
|
// 兜底:8s 内 onLoad 没触发就强制收起遮罩
|
|
|
|
|
|
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
|
|
|
|
|
|
switchTimeoutRef.current = setTimeout(() => setSwitching(false), 8000);
|
|
|
|
|
|
}
|
|
|
|
|
|
prevQnRef.current = currentQn;
|
|
|
|
|
|
}, [currentQn]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const [buffered, setBuffered] = useState(0);
|
|
|
|
|
|
const [isSeeking, setIsSeeking] = useState(false);
|
|
|
|
|
|
const isSeekingRef = useRef(false);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 拖动球位置用 Animated.Value 驱动:setValue 不触发 React 重渲染,
|
|
|
|
|
|
// 原生层直接更新坐标,跟手 60fps。消除老方案 setState+60ms 节流导致的"段落感"。
|
|
|
|
|
|
const touchAnimX = useRef(new Animated.Value(0)).current;
|
|
|
|
|
|
// 缩略图换帧仍走 state(精灵图位移涉及 RN 视图属性变化),50ms 节流足够
|
|
|
|
|
|
const [thumbFrame, setThumbFrame] = useState<{
|
|
|
|
|
|
sheetIdx: number;
|
|
|
|
|
|
col: number;
|
|
|
|
|
|
row: number;
|
|
|
|
|
|
seekTime: number;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
const thumbThrottleRef = useRef(0);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const barOffsetX = useRef(0);
|
|
|
|
|
|
const barWidthRef = useRef(300);
|
|
|
|
|
|
const trackRef = useRef<View>(null);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 让稳定的 PanResponder 闭包能读到最新 shots
|
|
|
|
|
|
const shotsRef = useRef<VideoShotData | null>(null);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
const [shots, setShots] = useState<VideoShotData | null>(null);
|
|
|
|
|
|
const [showDanmaku, setShowDanmaku] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
const videoRef = useRef<VideoRef>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
|
|
|
|
seek: (t: number) => {
|
|
|
|
|
|
videoRef.current?.seek(t);
|
|
|
|
|
|
},
|
2026-05-12 20:27:30 +08:00
|
|
|
|
pause: () => setPaused(true),
|
|
|
|
|
|
resume: () => setPaused(false),
|
|
|
|
|
|
getCurrentTime: () => currentTimeRef.current,
|
2026-03-19 20:11:05 +08:00
|
|
|
|
setPaused: (v: boolean) => {
|
|
|
|
|
|
setPaused(v);
|
|
|
|
|
|
},
|
2026-03-17 22:18:05 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const currentDesc =
|
|
|
|
|
|
qualities.find((q) => q.qn === currentQn)?.desc ??
|
|
|
|
|
|
String(currentQn || "HD");
|
2026-03-10 21:46:19 +08:00
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
// 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!playData) {
|
|
|
|
|
|
setResolvedUrl(undefined);
|
|
|
|
|
|
return;
|
2026-03-10 21:46:19 +08:00
|
|
|
|
}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
if (isDash) {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
buildDashMpdUri(playData, currentQn, bvid)
|
2026-03-17 22:18:05 +08:00
|
|
|
|
.then(setResolvedUrl)
|
|
|
|
|
|
.catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setResolvedUrl(playData.durl?.[0]?.url);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [playData, currentQn]);
|
|
|
|
|
|
// 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid,确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!bvid || !cid) return;
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
getVideoShot(bvid, cid).then((shotData) => {
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
if (shotData?.image?.length) {
|
|
|
|
|
|
setShots(shotData);
|
2026-03-16 12:55:45 +08:00
|
|
|
|
}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [bvid, cid]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
durationRef.current = duration;
|
|
|
|
|
|
}, [duration]);
|
|
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
shotsRef.current = shots;
|
|
|
|
|
|
}, [shots]);
|
|
|
|
|
|
|
|
|
|
|
|
// 非拖动时,球/进度填充随 currentTime 同步(onProgress 驱动)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isSeekingRef.current) return;
|
|
|
|
|
|
if (durationRef.current <= 0 || barWidthRef.current <= 0) {
|
|
|
|
|
|
touchAnimX.setValue(0);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const x = clamp(
|
|
|
|
|
|
(currentTime / durationRef.current) * barWidthRef.current,
|
|
|
|
|
|
0,
|
|
|
|
|
|
barWidthRef.current,
|
|
|
|
|
|
);
|
|
|
|
|
|
touchAnimX.setValue(x);
|
|
|
|
|
|
}, [currentTime, duration]);
|
|
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
|
|
|
|
|
const resetHideTimer = useCallback(() => {
|
|
|
|
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
|
|
|
|
if (!isSeekingRef.current) {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
hideTimer.current = setTimeout(
|
|
|
|
|
|
() => setShowControls(false),
|
|
|
|
|
|
HIDE_DELAY,
|
|
|
|
|
|
);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
|
|
|
|
|
const showAndReset = useCallback(() => {
|
|
|
|
|
|
setShowControls(true);
|
|
|
|
|
|
resetHideTimer();
|
|
|
|
|
|
}, [resetHideTimer]);
|
|
|
|
|
|
|
|
|
|
|
|
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
|
|
|
|
|
|
const handleTap = useCallback(() => {
|
|
|
|
|
|
setShowControls((prev) => {
|
|
|
|
|
|
if (!prev) {
|
|
|
|
|
|
resetHideTimer();
|
|
|
|
|
|
return true;
|
2026-03-16 12:55:45 +08:00
|
|
|
|
}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [resetHideTimer]);
|
|
|
|
|
|
|
|
|
|
|
|
// 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
resetHideTimer();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const measureTrack = useCallback(() => {
|
|
|
|
|
|
trackRef.current?.measureInWindow((x, _y, w) => {
|
|
|
|
|
|
if (w > 0) {
|
|
|
|
|
|
barOffsetX.current = x;
|
|
|
|
|
|
barWidthRef.current = w;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 拖动中按 50ms 节流计算精灵图帧索引;球位置不走这里,由 Animated.setValue 直接更新
|
|
|
|
|
|
const updateThumbFrame = useCallback((x: number) => {
|
|
|
|
|
|
const shotsData = shotsRef.current;
|
|
|
|
|
|
if (!shotsData || durationRef.current <= 0 || barWidthRef.current <= 0) return;
|
|
|
|
|
|
const ratio = clamp(x / barWidthRef.current, 0, 1);
|
|
|
|
|
|
const seekTime = ratio * durationRef.current;
|
|
|
|
|
|
const { img_x_len, img_y_len, image, index } = shotsData;
|
|
|
|
|
|
const framesPerSheet = img_x_len * img_y_len;
|
|
|
|
|
|
const totalFrames = framesPerSheet * image.length;
|
|
|
|
|
|
const frameIdx = index?.length
|
|
|
|
|
|
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
|
|
|
|
|
: clamp(Math.floor(ratio * (totalFrames - 1)), 0, totalFrames - 1);
|
|
|
|
|
|
const local = frameIdx % framesPerSheet;
|
|
|
|
|
|
setThumbFrame({
|
|
|
|
|
|
sheetIdx: Math.floor(frameIdx / framesPerSheet),
|
|
|
|
|
|
col: local % img_x_len,
|
|
|
|
|
|
row: Math.floor(local / img_x_len),
|
|
|
|
|
|
seekTime,
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// PanResponder 进度条拖动:球/进度填充走 Animated.setValue(无 React 渲染),
|
|
|
|
|
|
// 缩略图换帧 50ms 节流;松手时 seek 到目标时间并恢复自动同步。
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const panResponder = useRef(
|
|
|
|
|
|
PanResponder.create({
|
|
|
|
|
|
onStartShouldSetPanResponder: () => true,
|
|
|
|
|
|
onMoveShouldSetPanResponder: () => true,
|
|
|
|
|
|
onPanResponderGrant: (_, gs) => {
|
|
|
|
|
|
isSeekingRef.current = true;
|
|
|
|
|
|
setIsSeeking(true);
|
|
|
|
|
|
setShowControls(true);
|
|
|
|
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
|
|
|
|
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
touchAnimX.setValue(x);
|
|
|
|
|
|
thumbThrottleRef.current = 0;
|
|
|
|
|
|
updateThumbFrame(x);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
},
|
|
|
|
|
|
onPanResponderMove: (_, gs) => {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const x = clamp(
|
2026-03-17 22:18:05 +08:00
|
|
|
|
gs.moveX - barOffsetX.current,
|
|
|
|
|
|
0,
|
|
|
|
|
|
barWidthRef.current,
|
|
|
|
|
|
);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 关键:setValue 不触发 React 渲染,进度球/进度填充由原生层直接刷新
|
|
|
|
|
|
touchAnimX.setValue(x);
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - thumbThrottleRef.current >= 50) {
|
|
|
|
|
|
thumbThrottleRef.current = now;
|
|
|
|
|
|
updateThumbFrame(x);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动
|
2026-03-17 22:18:05 +08:00
|
|
|
|
onPanResponderRelease: (_, gs) => {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const x = clamp(
|
|
|
|
|
|
gs.moveX - barOffsetX.current,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
0,
|
2026-05-12 20:27:30 +08:00
|
|
|
|
barWidthRef.current,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const ratio = barWidthRef.current > 0 ? x / barWidthRef.current : 0;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const t = ratio * durationRef.current;
|
2026-05-12 20:27:30 +08:00
|
|
|
|
touchAnimX.setValue(x);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
videoRef.current?.seek(t);
|
|
|
|
|
|
setCurrentTime(t);
|
|
|
|
|
|
isSeekingRef.current = false;
|
|
|
|
|
|
setIsSeeking(false);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
setThumbFrame(null);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
|
|
|
|
hideTimer.current = setTimeout(
|
|
|
|
|
|
() => setShowControls(false),
|
|
|
|
|
|
HIDE_DELAY,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
onPanResponderTerminate: () => {
|
|
|
|
|
|
isSeekingRef.current = false;
|
|
|
|
|
|
setIsSeeking(false);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
setThumbFrame(null);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
).current;
|
|
|
|
|
|
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
|
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 缩略图尺寸:竖屏播放器较窄给 160,全屏给 220 提升可读性
|
|
|
|
|
|
const THUMB_DISPLAY_W = isFullscreen ? 220 : 160;
|
|
|
|
|
|
|
|
|
|
|
|
// 进度球水平偏移:active 态球更大,translate 偏移需对齐圆心
|
|
|
|
|
|
const ballTranslate = React.useMemo(
|
|
|
|
|
|
() => Animated.subtract(touchAnimX, (isSeeking ? BALL_ACTIVE : BALL) / 2),
|
|
|
|
|
|
[isSeeking, touchAnimX],
|
|
|
|
|
|
);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
const renderThumbnail = () => {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
if (!thumbFrame || !shots || !isSeeking) return null;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const {
|
|
|
|
|
|
img_x_size: TW,
|
|
|
|
|
|
img_y_size: TH,
|
|
|
|
|
|
img_x_len,
|
|
|
|
|
|
img_y_len,
|
|
|
|
|
|
image,
|
|
|
|
|
|
} = shots;
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const { sheetIdx, col, row, seekTime } = thumbFrame;
|
|
|
|
|
|
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const scale = THUMB_DISPLAY_W / TW;
|
|
|
|
|
|
const DW = THUMB_DISPLAY_W;
|
|
|
|
|
|
const DH = Math.round(TH * scale);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 缩略图固定到播放器水平中点,不跟随手指 / 进度球移动
|
|
|
|
|
|
const fixedLeft = Math.round(SCREEN_W / 2 - DW / 2);
|
|
|
|
|
|
const raw = image[sheetIdx];
|
|
|
|
|
|
if (!raw) return null;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
// 兼容处理图床地址,确保以 http(s) 协议开头
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const sheetUrl = raw.startsWith("//") ? `https:${raw}` : raw;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
return (
|
2026-03-11 20:53:18 +08:00
|
|
|
|
<View
|
2026-05-12 20:27:30 +08:00
|
|
|
|
style={[styles.thumbPreview, { left: fixedLeft, width: DW }]}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
pointerEvents="none"
|
2026-03-11 20:53:18 +08:00
|
|
|
|
>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<View
|
2026-03-10 21:46:19 +08:00
|
|
|
|
style={{
|
2026-03-17 22:18:05 +08:00
|
|
|
|
width: DW,
|
|
|
|
|
|
height: DH,
|
|
|
|
|
|
overflow: "hidden",
|
2026-05-12 20:27:30 +08:00
|
|
|
|
borderRadius: 6,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
}}
|
2026-03-11 10:03:46 +08:00
|
|
|
|
>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<Image
|
|
|
|
|
|
source={{ uri: sheetUrl, headers: HEADERS }}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
width: TW * img_x_len * scale,
|
|
|
|
|
|
height: TH * img_y_len * scale,
|
|
|
|
|
|
left: -col * DW,
|
|
|
|
|
|
top: -row * DH,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<Text style={styles.thumbTime}>
|
|
|
|
|
|
{formatDuration(Math.floor(seekTime))}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-03-11 10:03:46 +08:00
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<View
|
|
|
|
|
|
style={[
|
|
|
|
|
|
isFullscreen
|
|
|
|
|
|
? styles.fsContainer
|
|
|
|
|
|
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
|
|
|
|
|
|
style,
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{resolvedUrl ? (
|
|
|
|
|
|
<Video
|
|
|
|
|
|
key={resolvedUrl}
|
|
|
|
|
|
ref={videoRef}
|
|
|
|
|
|
source={
|
|
|
|
|
|
isDash
|
|
|
|
|
|
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
|
|
|
|
|
|
: { uri: resolvedUrl, headers: HEADERS }
|
|
|
|
|
|
}
|
|
|
|
|
|
style={StyleSheet.absoluteFill}
|
|
|
|
|
|
resizeMode="contain"
|
|
|
|
|
|
controls={false}
|
2026-05-12 20:27:30 +08:00
|
|
|
|
paused={!!(forcePaused || paused || seekHackPaused)}
|
|
|
|
|
|
rate={rate}
|
2026-03-25 15:03:49 +08:00
|
|
|
|
progressUpdateInterval={500}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
onProgress={({
|
|
|
|
|
|
currentTime: ct,
|
|
|
|
|
|
seekableDuration: dur,
|
|
|
|
|
|
playableDuration: buf,
|
|
|
|
|
|
}) => {
|
2026-03-25 15:03:49 +08:00
|
|
|
|
currentTimeRef.current = ct;
|
|
|
|
|
|
onTimeUpdate?.(ct);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 续播持久化(5s 节流)
|
|
|
|
|
|
if (bvid && dur > 0) {
|
|
|
|
|
|
const nowSave = Date.now();
|
|
|
|
|
|
if (nowSave - lastSaveRef.current > 5000) {
|
|
|
|
|
|
lastSaveRef.current = nowSave;
|
|
|
|
|
|
usePlayProgressStore.getState().save(bvid, ct, dur);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-25 15:03:49 +08:00
|
|
|
|
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
|
|
|
|
|
|
if (isSeekingRef.current) return;
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - lastProgressUpdate.current < 450) return;
|
|
|
|
|
|
lastProgressUpdate.current = now;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
setCurrentTime(ct);
|
2026-03-25 15:03:49 +08:00
|
|
|
|
if (dur > 0 && Math.abs(dur - durationRef.current) > 1) setDuration(dur);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
setBuffered(buf);
|
2026-03-11 20:53:18 +08:00
|
|
|
|
}}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
onLoad={() => {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 切清晰度后跳回原进度
|
|
|
|
|
|
const pending = pendingSeekRef.current;
|
|
|
|
|
|
let didSeek = false;
|
|
|
|
|
|
if (pending !== null && pending > 0) {
|
|
|
|
|
|
videoRef.current?.seek(pending);
|
|
|
|
|
|
pendingSeekRef.current = null;
|
|
|
|
|
|
didSeek = true;
|
|
|
|
|
|
} else if (initialTime && initialTime > 0) {
|
2026-03-17 22:18:05 +08:00
|
|
|
|
videoRef.current?.seek(initialTime);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
didSeek = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (switching) {
|
|
|
|
|
|
setSwitching(false);
|
|
|
|
|
|
if (switchTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(switchTimeoutRef.current);
|
|
|
|
|
|
switchTimeoutRef.current = null;
|
|
|
|
|
|
}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
}
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// seek 后部分播放器不自动恢复播放,需短暂 paused→false 触发 prop 变化
|
|
|
|
|
|
// 仅在确实 seek 时执行;走 seekHackPaused(不污染图标显示态),避免播放/暂停图标闪烁
|
|
|
|
|
|
if (didSeek && !forcePaused && !paused) {
|
|
|
|
|
|
setSeekHackPaused(true);
|
|
|
|
|
|
requestAnimationFrame(() => setSeekHackPaused(false));
|
2026-03-25 15:03:49 +08:00
|
|
|
|
}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
}}
|
2026-03-19 16:38:18 +08:00
|
|
|
|
onError={(e) => {
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 按降级链找下一档可用清晰度(从当前档位的下一档起,跳过不在 qualities 列表里的)
|
|
|
|
|
|
const FALLBACK_CHAIN = [126, 112, 80, 64, 32, 16];
|
|
|
|
|
|
const idx = FALLBACK_CHAIN.indexOf(currentQn);
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
|
const acceptable = new Set(qualities.map(q => q.qn));
|
|
|
|
|
|
for (let i = idx + 1; i < FALLBACK_CHAIN.length; i++) {
|
|
|
|
|
|
const next = FALLBACK_CHAIN[i];
|
|
|
|
|
|
if (acceptable.has(next)) {
|
|
|
|
|
|
onQualityChange(next);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-19 16:38:18 +08:00
|
|
|
|
}
|
2026-03-19 17:00:35 +08:00
|
|
|
|
console.warn("Video playback error:", e);
|
2026-03-19 16:38:18 +08:00
|
|
|
|
}}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<View style={styles.placeholder} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
{switching && (
|
|
|
|
|
|
<View style={styles.switchOverlay} pointerEvents="none">
|
|
|
|
|
|
<ActivityIndicator color="#fff" size="small" />
|
|
|
|
|
|
<Text style={styles.switchText}>切换到 {currentDesc}…</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
{isFullscreen && !!danmakus?.length && (
|
|
|
|
|
|
<DanmakuOverlay
|
|
|
|
|
|
danmakus={danmakus}
|
|
|
|
|
|
currentTime={currentTime}
|
|
|
|
|
|
screenWidth={SCREEN_W}
|
|
|
|
|
|
screenHeight={SCREEN_H}
|
|
|
|
|
|
visible={showDanmaku}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<TouchableWithoutFeedback onPress={handleTap}>
|
|
|
|
|
|
<View style={StyleSheet.absoluteFill} />
|
|
|
|
|
|
</TouchableWithoutFeedback>
|
|
|
|
|
|
|
|
|
|
|
|
{showControls && (
|
|
|
|
|
|
<>
|
2026-03-19 17:00:35 +08:00
|
|
|
|
{/* 小窗口 */}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<LinearGradient
|
|
|
|
|
|
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
|
|
|
|
|
style={styles.topBar}
|
|
|
|
|
|
pointerEvents="box-none"
|
2026-03-19 17:00:35 +08:00
|
|
|
|
></LinearGradient>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.centerBtn}
|
|
|
|
|
|
onPress={() => {
|
|
|
|
|
|
setPaused((p) => !p);
|
|
|
|
|
|
showAndReset();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.centerBtnBg}>
|
2026-03-11 20:53:18 +08:00
|
|
|
|
<Ionicons
|
|
|
|
|
|
name={paused ? "play" : "pause"}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
size={28}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
color="#fff"
|
|
|
|
|
|
/>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
<LinearGradient
|
|
|
|
|
|
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
|
|
|
|
|
style={styles.bottomBar}
|
|
|
|
|
|
pointerEvents="box-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
<View
|
|
|
|
|
|
ref={trackRef}
|
|
|
|
|
|
style={styles.trackWrapper}
|
|
|
|
|
|
onLayout={measureTrack}
|
|
|
|
|
|
{...panResponder.panHandlers}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<View style={styles.track}>
|
|
|
|
|
|
<View
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.trackLayer,
|
|
|
|
|
|
{
|
|
|
|
|
|
width: `${bufferedRatio * 100}%` as any,
|
|
|
|
|
|
backgroundColor: "rgba(255,255,255,0.35)",
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-05-12 20:27:30 +08:00
|
|
|
|
<Animated.View
|
2026-03-17 22:18:05 +08:00
|
|
|
|
style={[
|
|
|
|
|
|
styles.trackLayer,
|
|
|
|
|
|
{
|
2026-05-12 20:27:30 +08:00
|
|
|
|
width: touchAnimX,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
backgroundColor: "#00AEEC",
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
2026-05-12 20:27:30 +08:00
|
|
|
|
<Animated.View
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.ball,
|
|
|
|
|
|
isSeeking && styles.ballActive,
|
|
|
|
|
|
{
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
transform: [{ translateX: ballTranslate }],
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
{/* Controls */}
|
|
|
|
|
|
|
|
|
|
|
|
<View style={styles.ctrlRow}>
|
2026-03-11 20:53:18 +08:00
|
|
|
|
<TouchableOpacity
|
2026-03-17 22:18:05 +08:00
|
|
|
|
onPress={() => {
|
|
|
|
|
|
setPaused((p) => !p);
|
|
|
|
|
|
showAndReset();
|
|
|
|
|
|
}}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
style={styles.ctrlBtn}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Ionicons
|
2026-03-17 22:18:05 +08:00
|
|
|
|
name={paused ? "play" : "pause"}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
size={16}
|
|
|
|
|
|
color="#fff"
|
|
|
|
|
|
/>
|
2026-03-10 21:46:19 +08:00
|
|
|
|
</TouchableOpacity>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<Text style={styles.timeText}>
|
|
|
|
|
|
{formatDuration(Math.floor(currentTime))}
|
2026-03-11 10:03:46 +08:00
|
|
|
|
</Text>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<View style={{ flex: 1 }} />
|
|
|
|
|
|
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
|
2026-05-12 20:27:30 +08:00
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.ctrlBtn}
|
|
|
|
|
|
onPress={() => setShowRate(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text style={styles.qualityText}>{rate === 1 ? "倍速" : `${rate}x`}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.ctrlBtn}
|
|
|
|
|
|
onPress={() => setShowQuality(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text style={styles.qualityText}>{currentDesc}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
{isFullscreen && (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.ctrlBtn}
|
|
|
|
|
|
onPress={() => setShowDanmaku((v) => !v)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Ionicons
|
|
|
|
|
|
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
|
|
|
|
|
|
size={16}
|
|
|
|
|
|
color="#fff"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-11 20:53:18 +08:00
|
|
|
|
)}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
2026-03-19 20:11:05 +08:00
|
|
|
|
<Ionicons name="expand" size={18} color="#fff" />
|
2026-03-17 22:18:05 +08:00
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</LinearGradient>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{renderThumbnail()}
|
|
|
|
|
|
{/* 选清晰度 */}
|
|
|
|
|
|
<Modal visible={showQuality} transparent animationType="fade">
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.modalOverlay}
|
|
|
|
|
|
onPress={() => setShowQuality(false)}
|
|
|
|
|
|
>
|
2026-03-26 00:43:37 +08:00
|
|
|
|
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
|
|
|
|
|
|
<Text style={[styles.qualityTitle, { color: theme.modalText }]}>选择清晰度</Text>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
{qualities.map((q) => (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={q.qn}
|
2026-03-26 00:43:37 +08:00
|
|
|
|
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
onPress={() => {
|
|
|
|
|
|
setShowQuality(false);
|
|
|
|
|
|
onQualityChange(q.qn);
|
|
|
|
|
|
showAndReset();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.qualityItemText,
|
2026-03-26 00:43:37 +08:00
|
|
|
|
{ color: theme.modalTextSub },
|
2026-03-17 22:18:05 +08:00
|
|
|
|
q.qn === currentQn && styles.qualityItemActive,
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
2026-03-19 17:00:35 +08:00
|
|
|
|
{q.desc}
|
|
|
|
|
|
{q.qn === 126 ? " DV" : ""}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
</Text>
|
|
|
|
|
|
{q.qn === currentQn && (
|
|
|
|
|
|
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</Modal>
|
2026-05-12 20:27:30 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 选倍速 */}
|
|
|
|
|
|
<Modal visible={showRate} transparent animationType="fade">
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.modalOverlay}
|
|
|
|
|
|
onPress={() => setShowRate(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
|
|
|
|
|
|
<Text style={[styles.qualityTitle, { color: theme.modalText }]}>选择倍速</Text>
|
|
|
|
|
|
{RATE_OPTIONS.map((r) => (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={r}
|
|
|
|
|
|
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
|
|
|
|
|
onPress={() => {
|
|
|
|
|
|
setRate(r);
|
|
|
|
|
|
setShowRate(false);
|
|
|
|
|
|
showAndReset();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.qualityItemText,
|
|
|
|
|
|
{ color: theme.modalTextSub },
|
|
|
|
|
|
r === rate && styles.qualityItemActive,
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{r === 1 ? "正常" : `${r}x`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{r === rate && (
|
|
|
|
|
|
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</Modal>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-06 12:58:41 +08:00
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
2026-03-11 20:53:18 +08:00
|
|
|
|
container: { backgroundColor: "#000" },
|
|
|
|
|
|
fsContainer: { flex: 1, backgroundColor: "#000" },
|
|
|
|
|
|
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
|
2026-05-12 20:27:30 +08:00
|
|
|
|
switchOverlay: {
|
|
|
|
|
|
...StyleSheet.absoluteFillObject,
|
|
|
|
|
|
backgroundColor: "rgba(0,0,0,0.55)",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
flexDirection: "row",
|
|
|
|
|
|
gap: 10,
|
|
|
|
|
|
},
|
|
|
|
|
|
switchText: {
|
|
|
|
|
|
color: "#fff",
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
fontWeight: "500",
|
|
|
|
|
|
},
|
2026-03-10 21:46:19 +08:00
|
|
|
|
topBar: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
position: "absolute",
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
height: 56,
|
|
|
|
|
|
paddingHorizontal: 12,
|
|
|
|
|
|
paddingTop: 10,
|
|
|
|
|
|
flexDirection: "row",
|
|
|
|
|
|
justifyContent: "flex-end",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
topBtn: { padding: 6 },
|
|
|
|
|
|
centerBtn: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
position: "absolute",
|
|
|
|
|
|
top: "50%",
|
|
|
|
|
|
left: "50%",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
transform: [{ translateX: -28 }, { translateY: -28 }],
|
|
|
|
|
|
},
|
|
|
|
|
|
centerBtnBg: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
width: 56,
|
|
|
|
|
|
height: 56,
|
|
|
|
|
|
borderRadius: 28,
|
|
|
|
|
|
backgroundColor: "rgba(0,0,0,0.45)",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
bottomBar: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
position: "absolute",
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
paddingBottom: 8,
|
|
|
|
|
|
paddingTop: 32,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
2026-03-11 20:53:18 +08:00
|
|
|
|
thumbPreview: { position: "absolute", bottom: 64, alignItems: "center" },
|
2026-03-10 21:46:19 +08:00
|
|
|
|
thumbTime: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
color: "#fff",
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
fontWeight: "600",
|
|
|
|
|
|
marginTop: 2,
|
|
|
|
|
|
textShadowColor: "rgba(0,0,0,0.7)",
|
|
|
|
|
|
textShadowOffset: { width: 0, height: 1 },
|
|
|
|
|
|
textShadowRadius: 2,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
trackWrapper: {
|
|
|
|
|
|
marginHorizontal: 8,
|
|
|
|
|
|
height: BAR_H + BALL_ACTIVE,
|
2026-03-11 20:53:18 +08:00
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
position: "relative",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
track: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
height: BAR_H,
|
|
|
|
|
|
borderRadius: 2,
|
|
|
|
|
|
overflow: "hidden",
|
2026-03-13 00:30:07 +08:00
|
|
|
|
backgroundColor: "rgba(255,255,255,0.2)",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
2026-03-13 00:30:07 +08:00
|
|
|
|
trackLayer: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
position: "absolute",
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
height: BAR_H,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
ball: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
position: "absolute",
|
2026-03-10 21:46:19 +08:00
|
|
|
|
top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2,
|
2026-03-11 20:53:18 +08:00
|
|
|
|
width: BALL,
|
|
|
|
|
|
height: BALL,
|
|
|
|
|
|
borderRadius: BALL / 2,
|
|
|
|
|
|
backgroundColor: "#fff",
|
|
|
|
|
|
elevation: 3,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
ballActive: {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
width: BALL_ACTIVE,
|
|
|
|
|
|
height: BALL_ACTIVE,
|
|
|
|
|
|
borderRadius: BALL_ACTIVE / 2,
|
|
|
|
|
|
backgroundColor: "#00AEEC",
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
ctrlRow: {
|
|
|
|
|
|
flexDirection: "row",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
marginTop: 4,
|
2026-03-10 21:46:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
2026-03-19 17:00:35 +08:00
|
|
|
|
timeText: {
|
|
|
|
|
|
color: "#fff",
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
marginHorizontal: 2,
|
|
|
|
|
|
fontWeight: "600",
|
|
|
|
|
|
},
|
2026-03-11 20:53:18 +08:00
|
|
|
|
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
|
|
|
|
|
|
modalOverlay: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
backgroundColor: "rgba(0,0,0,0.5)",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
},
|
|
|
|
|
|
qualityList: {
|
|
|
|
|
|
backgroundColor: "#fff",
|
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
|
paddingVertical: 8,
|
|
|
|
|
|
paddingHorizontal: 16,
|
|
|
|
|
|
minWidth: 180,
|
|
|
|
|
|
},
|
|
|
|
|
|
qualityTitle: {
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
fontWeight: "700",
|
|
|
|
|
|
color: "#212121",
|
|
|
|
|
|
paddingVertical: 10,
|
|
|
|
|
|
textAlign: "center",
|
|
|
|
|
|
},
|
|
|
|
|
|
qualityItem: {
|
|
|
|
|
|
flexDirection: "row",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "space-between",
|
|
|
|
|
|
paddingVertical: 12,
|
|
|
|
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderTopColor: "#eee",
|
|
|
|
|
|
},
|
|
|
|
|
|
qualityItemText: { fontSize: 14, color: "#333" },
|
|
|
|
|
|
qualityItemActive: { color: "#00AEEC", fontWeight: "700" },
|
2026-03-06 12:58:41 +08:00
|
|
|
|
});
|