This commit is contained in:
Developer
2026-03-11 20:53:18 +08:00
parent 4e8beae209
commit 9347c8752d
12 changed files with 836 additions and 261 deletions

View File

@@ -1,15 +1,27 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback } from "react";
import { File, Directory, Paths } from "expo-file-system";
import {
View, StyleSheet, TouchableOpacity, TouchableWithoutFeedback,
Text, Modal, Image, PanResponder, 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 } from '../services/types';
import { buildDashMpdUri } from '../utils/dash';
import { getHeatmap, getVideoShot } from '../services/bilibili';
import DanmakuOverlay from './DanmakuOverlay';
View,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
Text,
Modal,
Image,
PanResponder,
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,
} from "../services/types";
import { buildDashMpdUri } from "../utils/dash";
import { getHeatmap, getVideoShot } from "../services/bilibili";
import DanmakuOverlay from "./DanmakuOverlay";
const BAR_H = 3;
// 进度球尺寸
@@ -22,8 +34,9 @@ const SEGMENTS = 100;
const HIDE_DELAY = 3000;
const HEADERS = {
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',
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",
};
function clamp(v: number, lo: number, hi: number) {
@@ -49,63 +62,145 @@ function decodeFloats(base64: string): number[] {
while (i < bytes.length) {
const tag = bytes[i++];
const wt = tag & 0x7;
if (wt === 5) { floats.push(view.getFloat32(i, true)); i += 4; }
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
else if (wt === 1) { i += 8; }
else if (wt === 2) {
let len = 0, shift = 0;
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
if (wt === 5) {
floats.push(view.getFloat32(i, true));
i += 4;
} else if (wt === 0) {
while (i < bytes.length && bytes[i++] & 0x80);
} else if (wt === 1) {
i += 8;
} else if (wt === 2) {
let len = 0,
shift = 0;
do {
const b = bytes[i++];
len |= (b & 0x7f) << shift;
shift += 7;
if (!(b & 0x80)) break;
} while (true);
i += len;
} else break;
}
return floats;
}
function decodePvData(base64: string): number[] {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const view = new DataView(bytes.buffer);
function decodePvBuffer(buffer: ArrayBuffer): number[] {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const timestamps: number[] = [];
let i = 0;
while (i < bytes.length) {
const tag = bytes[i++];
const wt = tag & 0x7;
if (wt === 5) { timestamps.push(view.getFloat32(i, true)); i += 4; }
else if (wt === 2) {
// Packed floats — read length prefix then extract each float32
let len = 0, shift = 0;
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
const wireType = tag & 0x07;
const fieldNum = tag >> 3;
// 我们主要关心 repeated float32通常 field 1 或直接数据
if (wireType === 5) {
// fixed32 / float32
if (i + 4 > bytes.length) break;
timestamps.push(view.getFloat32(i, true)); // little-endian
i += 4;
} else if (wireType === 2) {
// length-delimited → 进入子消息或 packed repeated
let len = 0;
let shift = 0;
while (true) {
if (i >= bytes.length) break;
const b = bytes[i++];
len |= (b & 0x7f) << shift;
shift += 7;
if (!(b & 0x80)) break;
}
const end = i + len;
while (i < end) { timestamps.push(view.getFloat32(i, true)); i += 4; }
// packed repeated float32 最常见情况:直接连续 float32
while (i + 4 <= end) {
timestamps.push(view.getFloat32(i, true));
i += 4;
}
// 如果不是 packed也跳过
} else if (wireType === 0) {
// varint
while (i < bytes.length && bytes[i++] & 0x80);
} else if (wireType === 1) {
// fixed64
i += 8;
} else {
break; // 未知类型,停止
}
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
else if (wt === 1) { i += 8; }
else break;
}
return timestamps;
// 过滤掉明显异常值(比如负数或极大值)
return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时
}
async function loadPvData(url: string) {
const realUrl = url.startsWith("//") ? `https:${url}` : url;
try {
// 选择缓存目录下的一个子目录(避免污染根缓存)
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
// 如果目录不存在创建intermediates: true 自动创建父目录)
if (!cacheDir.exists) {
await cacheDir.create({ intermediates: true });
}
// 下载文件到这个目录(会自动用远程文件名,或你可以指定 File
// 这里用 Directory 作为 destinationSDK 会从 URL 或 header 推导文件名
const downloadedFile: File = await File.downloadFileAsync(
realUrl,
cacheDir,
{
headers: HEADERS,
idempotent: true, // 如果文件已存在,覆盖(避免重复下载失败)
},
);
console.log("Downloaded to:", downloadedFile.uri);
// 读取为 base64如果你原来的 decodeFloats/decodePvBuffer 用 base64
// const base64 = await downloadedFile.base64();
// 更好:直接读 binary 为 Uint8Array然后转 ArrayBuffer
const bytes: Uint8Array = await downloadedFile.bytes();
const nums = new Uint16Array(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength / 2,
);
return nums;
} catch (error) {
console.error("loadPvData failed:", error);
throw error;
}
}
function findFrameIdx(timestamps: number[], seekTime: number): number {
if (!timestamps.length) return 0;
let lo = 0, hi = timestamps.length - 1;
let lo = 0,
hi = timestamps.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (timestamps[mid] <= seekTime) lo = mid; else hi = mid - 1;
if (timestamps[mid] <= seekTime) lo = mid;
else hi = mid - 1;
}
return lo;
}
function downsample(data: number[], n: number): number[] {
if (!data.length) return Array(n).fill(0);
const out = Array.from({ length: n }, (_, i) => data[Math.floor((i / n) * data.length)]);
const out = Array.from(
{ length: n },
(_, i) => data[Math.floor((i / n) * data.length)],
);
const max = Math.max(...out);
return max ? out.map(v => v / max) : out;
return max ? out.map((v) => v / max) : out;
}
function formatTime(s: number): string {
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
return `${m}:${Math.floor(s % 60)
.toString()
.padStart(2, "0")}`;
}
interface Props {
@@ -125,8 +220,19 @@ interface Props {
}
export function NativeVideoPlayer({
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime,
playData,
qualities,
currentQn,
onQualityChange,
onFullscreen,
onMiniPlayer,
style,
bvid,
cid,
danmakus,
isFullscreen,
onTimeUpdate,
initialTime,
}: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
@@ -157,11 +263,16 @@ export function NativeVideoPlayer({
const [showDanmaku, setShowDanmaku] = useState(true);
const videoRef = useRef<VideoRef>(null);
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD');
const currentDesc =
qualities.find((q) => q.qn === currentQn)?.desc ??
String(currentQn || "HD");
// URL resolution
useEffect(() => {
if (!playData) { setResolvedUrl(undefined); return; }
if (!playData) {
setResolvedUrl(undefined);
return;
}
if (isDash) {
buildDashMpdUri(playData, currentQn)
.then(setResolvedUrl)
@@ -175,24 +286,41 @@ export function NativeVideoPlayer({
useEffect(() => {
if (!bvid || !cid) return;
let cancelled = false;
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatmap, shotData]) => {
if (cancelled) return;
if (heatmap?.pb_data) {
try { setHeatSegments(downsample(decodeFloats(heatmap.pb_data), SEGMENTS)); }
catch { setHeatSegments([]); }
}
if (shotData?.image?.length) {
setShots(shotData);
if (shotData.pvdata) {
try { setShotTimestamps(decodePvData(shotData.pvdata)); }
catch { setShotTimestamps([]); }
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(
([heatmap, shotData]) => {
if (cancelled) return;
if (heatmap?.pb_data) {
try {
setHeatSegments(
downsample(decodeFloats(heatmap.pb_data), SEGMENTS),
);
} catch {
setHeatSegments([]);
}
}
}
});
return () => { cancelled = true; };
if (shotData?.image?.length) {
setShots(shotData);
console.log(shotData.pvdata, "pvdata");
if (shotData.pvdata) {
try {
loadPvData(shotData.pvdata).then((r) => {
setShotTimestamps(r);
});
} catch {
setShotTimestamps([]);
}
}
}
},
);
return () => {
cancelled = true;
};
}, [bvid, cid]);
useEffect(() => { durationRef.current = duration; }, [duration]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
@@ -207,8 +335,11 @@ export function NativeVideoPlayer({
}, [resetHideTimer]);
const handleTap = useCallback(() => {
setShowControls(prev => {
if (!prev) { resetHideTimer(); return true; }
setShowControls((prev) => {
if (!prev) {
resetHideTimer();
return true;
}
if (hideTimer.current) clearTimeout(hideTimer.current);
return false;
});
@@ -217,7 +348,9 @@ export function NativeVideoPlayer({
// Start hide timer on mount
useEffect(() => {
resetHideTimer();
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
const measureTrack = useCallback(() => {
@@ -244,7 +377,11 @@ export function NativeVideoPlayer({
setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current));
},
onPanResponderRelease: (_, gs) => {
const ratio = clamp((gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1);
const ratio = clamp(
(gs.moveX - barOffsetX.current) / barWidthRef.current,
0,
1,
);
const t = ratio * durationRef.current;
videoRef.current?.seek(t);
setCurrentTime(t);
@@ -252,32 +389,43 @@ export function NativeVideoPlayer({
isSeekingRef.current = false;
setIsSeeking(false);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
hideTimer.current = setTimeout(
() => setShowControls(false),
HIDE_DELAY,
);
},
onPanResponderTerminate: () => {
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
},
})
}),
).current;
const touchRatio = touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
const THUMB_DISPLAY_W = 120; // scaled display width
const renderThumbnail = () => {
if (touchRatio === null || !shots || !isSeeking) return null;
const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image } = shots;
const {
img_x_size: TW,
img_y_size: TH,
img_x_len,
img_y_len,
image,
} = shots;
const framesPerSheet = img_x_len * img_y_len;
const totalFrames = framesPerSheet * image.length;
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
const seekTime = touchRatio * duration;
const frameIdx = shotTimestamps.length > 0
? findFrameIdx(shotTimestamps, seekTime)
: Math.floor(touchRatio * (totalFrames - 1));
const frameIdx =
shotTimestamps.length > 0
? findFrameIdx(shotTimestamps, seekTime)
: Math.floor(touchRatio * (totalFrames - 1));
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
const local = frameIdx % framesPerSheet;
@@ -293,14 +441,21 @@ export function NativeVideoPlayer({
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
// Protocol-relative URLs from B站 API need explicit https:
const sheetUrl = image[sheetIdx].startsWith('//') ? `https:${image[sheetIdx]}` : image[sheetIdx];
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
return (
<View style={[styles.thumbPreview, { left: absLeft, width: DW }]} pointerEvents="none">
<View style={{ width: DW, height: DH, overflow: 'hidden', borderRadius: 4 }}>
<View
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
pointerEvents="none"
>
<View
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
>
<Image
source={{ uri: sheetUrl, headers: HEADERS }}
style={{
position: 'absolute',
position: "absolute",
width: TW * img_x_len * scale,
height: TH * img_y_len * scale,
left: -col * DW,
@@ -314,14 +469,22 @@ export function NativeVideoPlayer({
};
return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }, style]}>
<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 }
source={
isDash
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
: { uri: resolvedUrl, headers: HEADERS }
}
style={StyleSheet.absoluteFill}
resizeMode="contain"
@@ -361,27 +524,41 @@ export function NativeVideoPlayer({
<>
{/* Top bar */}
<LinearGradient
colors={['rgba(0,0,0,0.55)', 'transparent']}
colors={["rgba(0,0,0,0.55)", "transparent"]}
style={styles.topBar}
pointerEvents="box-none"
>
{onMiniPlayer && (
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
<Ionicons
name="tablet-portrait-outline"
size={20}
color="#fff"
/>
</TouchableOpacity>
)}
</LinearGradient>
{/* Center play/pause */}
<TouchableOpacity style={styles.centerBtn} onPress={() => { setPaused(p => !p); showAndReset(); }}>
<TouchableOpacity
style={styles.centerBtn}
onPress={() => {
setPaused((p) => !p);
showAndReset();
}}
>
<View style={styles.centerBtnBg}>
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
<Ionicons
name={paused ? "play" : "pause"}
size={28}
color="#fff"
/>
</View>
</TouchableOpacity>
{/* Bottom bar */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}
colors={["transparent", "rgba(0,0,0,0.7)"]}
style={styles.bottomBar}
pointerEvents="box-none"
>
@@ -393,38 +570,86 @@ export function NativeVideoPlayer({
{...panResponder.panHandlers}
>
<View style={styles.track}>
{heatSegments.length > 0
? heatSegments.map((v, i) => (
<View
key={i}
style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]}
/>
))
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
}
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
{heatSegments.length > 0 ? (
heatSegments.map((v, i) => (
<View
key={i}
style={[
styles.seg,
{
backgroundColor: heatColor(v),
width: `${100 / SEGMENTS}%` as any,
},
]}
/>
))
) : (
<View
style={[
styles.seg,
{ flex: 1, backgroundColor: "#00AEEC" },
]}
/>
)}
<View
style={[
styles.playedOverlay,
{ width: `${progressRatio * 100}%` as any },
]}
/>
</View>
{isSeeking && touchX !== null ? (
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
<View
style={[
styles.ball,
styles.ballActive,
{ left: touchX - BALL_ACTIVE / 2 },
]}
/>
) : (
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
<View
style={[
styles.ball,
{ left: progressRatio * barWidthRef.current - BALL / 2 },
]}
/>
)}
</View>
{/* Controls row */}
<View style={styles.ctrlRow}>
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
<TouchableOpacity
onPress={() => {
setPaused((p) => !p);
showAndReset();
}}
style={styles.ctrlBtn}
>
<Ionicons
name={paused ? "play" : "pause"}
size={16}
color="#fff"
/>
</TouchableOpacity>
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
<View style={{ flex: 1 }} />
<Text style={styles.timeText}>{formatTime(duration)}</Text>
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
<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
style={styles.ctrlBtn}
onPress={() => setShowDanmaku((v) => !v)}
>
<Ionicons
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
size={16}
color="#fff"
/>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
@@ -440,19 +665,33 @@ export function NativeVideoPlayer({
{/* Quality modal */}
<Modal visible={showQuality} transparent animationType="fade">
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
<TouchableOpacity
style={styles.modalOverlay}
onPress={() => setShowQuality(false)}
>
<View style={styles.qualityList}>
<Text style={styles.qualityTitle}></Text>
{qualities.map(q => (
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={styles.qualityItem}
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
showAndReset();
}}
>
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
<Text
style={[
styles.qualityItemText,
q.qn === currentQn && styles.qualityItemActive,
]}
>
{q.desc}
</Text>
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
{q.qn === currentQn && (
<Ionicons name="checkmark" size={16} color="#00AEEC" />
)}
</TouchableOpacity>
))}
</View>
@@ -463,66 +702,127 @@ export function NativeVideoPlayer({
}
const styles = StyleSheet.create({
container: { backgroundColor: '#000' },
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
container: { backgroundColor: "#000" },
fsContainer: { flex: 1, backgroundColor: "#000" },
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
topBar: {
position: 'absolute', top: 0, left: 0, right: 0, height: 56,
paddingHorizontal: 12, paddingTop: 10,
flexDirection: 'row', justifyContent: 'flex-end',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 56,
paddingHorizontal: 12,
paddingTop: 10,
flexDirection: "row",
justifyContent: "flex-end",
},
topBtn: { padding: 6 },
centerBtn: {
position: 'absolute', top: '50%', left: '50%',
position: "absolute",
top: "50%",
left: "50%",
transform: [{ translateX: -28 }, { translateY: -28 }],
},
centerBtnBg: {
width: 56, height: 56, borderRadius: 28,
backgroundColor: 'rgba(0,0,0,0.45)',
alignItems: 'center', justifyContent: 'center',
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "rgba(0,0,0,0.45)",
alignItems: "center",
justifyContent: "center",
},
bottomBar: {
position: 'absolute', bottom: 0, left: 0, right: 0,
paddingBottom: 8, paddingTop: 32,
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingBottom: 8,
paddingTop: 32,
},
thumbPreview: { position: 'absolute', bottom: 64, alignItems: 'center' },
thumbPreview: { position: "absolute", bottom: 64, alignItems: "center" },
thumbTime: {
color: '#fff', fontSize: 11, fontWeight: '600', marginTop: 2,
textShadowColor: 'rgba(0,0,0,0.7)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2,
color: "#fff",
fontSize: 11,
fontWeight: "600",
marginTop: 2,
textShadowColor: "rgba(0,0,0,0.7)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
trackWrapper: {
marginHorizontal: 8,
height: BAR_H + BALL_ACTIVE,
justifyContent: 'center',
position: 'relative',
justifyContent: "center",
position: "relative",
},
track: {
height: BAR_H, flexDirection: 'row',
borderRadius: 2, overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.3)',
height: BAR_H,
flexDirection: "row",
borderRadius: 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.3)",
},
seg: { height: BAR_H },
playedOverlay: {
position: 'absolute', top: 0, left: 0, height: BAR_H,
backgroundColor: 'rgba(255,255,255,0.3)',
position: "absolute",
top: 0,
left: 0,
height: BAR_H,
backgroundColor: "rgba(255,255,255,0.3)",
},
ball: {
position: 'absolute',
position: "absolute",
top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2,
width: BALL, height: BALL, borderRadius: BALL / 2,
backgroundColor: '#fff', elevation: 3,
width: BALL,
height: BALL,
borderRadius: BALL / 2,
backgroundColor: "#fff",
elevation: 3,
},
ballActive: {
width: BALL_ACTIVE, height: BALL_ACTIVE, borderRadius: BALL_ACTIVE / 2,
backgroundColor: '#00AEEC', top: 0,
width: BALL_ACTIVE,
height: BALL_ACTIVE,
borderRadius: BALL_ACTIVE / 2,
backgroundColor: "#00AEEC",
top: 0,
},
ctrlRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
marginTop: 4,
},
ctrlRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, marginTop: 4 },
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
timeText: { color: '#fff', fontSize: 11, marginHorizontal: 2 },
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' },
timeText: { color: "#fff", fontSize: 11, marginHorizontal: 2 },
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" },
});

View File

@@ -6,7 +6,7 @@ import { formatCount, formatDuration } from '../utils/format';
import { proxyImageUrl } from '../utils/imageUrl';
const { width } = Dimensions.get('window');
const CARD_WIDTH = (width - 24) / 2;
const CARD_WIDTH = (width - 14) / 2;
interface Props {
item: VideoItem;
@@ -39,7 +39,7 @@ export function VideoCard({ item, onPress }: Props) {
}
const styles = StyleSheet.create({
card: { width: CARD_WIDTH, marginBottom: 12, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
card: { width: CARD_WIDTH, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
thumbContainer: { position: 'relative' },
thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 },
durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 },

View File

@@ -22,14 +22,14 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
const [fullscreen, setFullscreen] = useState(false);
const { width, height } = useWindowDimensions();
const VIDEO_HEIGHT = width * 0.5625;
// When ScreenOrientation is unavailable (Expo Go), simulate landscape via transform
// In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform
const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0);
const handleEnterFullscreen = async () => {
setFullscreen(true);
if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
setFullscreen(true);
};
const handleExitFullscreen = async () => {
@@ -85,7 +85,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
/>
)}
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
<Modal visible={fullscreen} animationType="none" statusBarTranslucent>
<StatusBar hidden />
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<View style={needsRotation
@@ -104,7 +104,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
isFullscreen={true}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
style={{ width: '100%', height: '100%' }}
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
/>
</View>
</View>