Files
JKVideo/components/NativeVideoPlayer.tsx

746 lines
21 KiB
TypeScript
Raw Normal View History

2026-03-11 20:53:18 +08:00
import React, { useState, useRef, useEffect, useCallback } from "react";
import { File, Directory, Paths } from "expo-file-system";
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,
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";
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-06 12:58:41 +08:00
const BAR_H = 3;
2026-03-11 10:03:46 +08:00
// 进度球尺寸
const BALL = 12;
2026-03-11 10:03:46 +08:00
// 活跃状态下的拖动球增大尺寸,提升触控体验
const BALL_ACTIVE = 16;
const HIDE_DELAY = 3000;
2026-03-06 12:58:41 +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",
};
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
2026-03-11 20:53:18 +08:00
function decodePvBuffer(buffer: ArrayBuffer): number[] {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
2026-03-11 10:03:46 +08:00
const timestamps: number[] = [];
2026-03-11 20:53:18 +08:00
2026-03-11 10:03:46 +08:00
let i = 0;
while (i < bytes.length) {
const tag = bytes[i++];
2026-03-11 20:53:18 +08:00
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;
}
2026-03-11 10:03:46 +08:00
const end = i + len;
2026-03-11 20:53:18 +08:00
// 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; // 未知类型,停止
2026-03-11 10:03:46 +08:00
}
}
2026-03-11 20:53:18 +08:00
// 过滤掉明显异常值(比如负数或极大值)
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;
}
2026-03-11 10:03:46 +08:00
}
function findFrameIdx(timestamps: number[], seekTime: number): number {
if (!timestamps.length) return 0;
2026-03-11 20:53:18 +08:00
let lo = 0,
hi = timestamps.length - 1;
2026-03-11 10:03:46 +08:00
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
2026-03-11 20:53:18 +08:00
if (timestamps[mid] <= seekTime) lo = mid;
else hi = mid - 1;
2026-03-11 10:03:46 +08:00
}
return lo;
}
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;
onMiniPlayer?: () => void;
style?: object;
bvid?: string;
cid?: number;
danmakus?: DanmakuItem[];
isFullscreen?: boolean;
onTimeUpdate?: (t: number) => void;
initialTime?: number;
2026-03-10 19:04:18 +08:00
}
export function NativeVideoPlayer({
2026-03-11 20:53:18 +08:00
playData,
qualities,
currentQn,
onQualityChange,
onFullscreen,
onMiniPlayer,
style,
bvid,
cid,
danmakus,
isFullscreen,
onTimeUpdate,
initialTime,
2026-03-10 19:04:18 +08:00
}: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
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);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const durationRef = useRef(0);
const [showQuality, setShowQuality] = useState(false);
2026-03-13 00:30:07 +08:00
const [buffered, setBuffered] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
const isSeekingRef = useRef(false);
const [touchX, setTouchX] = useState<number | null>(null);
const barOffsetX = useRef(0);
const barWidthRef = useRef(300);
const trackRef = useRef<View>(null);
const [shots, setShots] = useState<VideoShotData | null>(null);
2026-03-11 10:03:46 +08:00
const [shotTimestamps, setShotTimestamps] = useState<number[]>([]);
const [showDanmaku, setShowDanmaku] = useState(true);
const videoRef = useRef<VideoRef>(null);
2026-03-11 20:53:18 +08:00
const currentDesc =
qualities.find((q) => q.qn === currentQn)?.desc ??
String(currentQn || "HD");
// URL resolution
useEffect(() => {
2026-03-11 20:53:18 +08:00
if (!playData) {
setResolvedUrl(undefined);
return;
}
if (isDash) {
buildDashMpdUri(playData, currentQn)
.then(setResolvedUrl)
.catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl));
} else {
setResolvedUrl(playData.durl?.[0]?.url);
}
}, [playData, currentQn]);
2026-03-12 23:57:01 +08:00
// Video shots (thumbnails for seek preview)
useEffect(() => {
if (!bvid || !cid) return;
let cancelled = false;
2026-03-12 23:57:01 +08:00
getVideoShot(bvid, cid).then((shotData) => {
if (cancelled) return;
if (shotData?.image?.length) {
setShots(shotData);
if (shotData.pvdata) {
2026-03-11 20:53:18 +08:00
try {
2026-03-12 23:57:01 +08:00
loadPvData(shotData.pvdata).then((r) => {
setShotTimestamps(r);
});
2026-03-11 20:53:18 +08:00
} catch {
2026-03-12 23:57:01 +08:00
setShotTimestamps([]);
2026-03-11 20:53:18 +08:00
}
2026-03-11 10:03:46 +08:00
}
2026-03-12 23:57:01 +08:00
}
});
2026-03-11 20:53:18 +08:00
return () => {
cancelled = true;
};
}, [bvid, cid]);
2026-03-11 20:53:18 +08:00
useEffect(() => {
durationRef.current = duration;
}, [duration]);
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
if (!isSeekingRef.current) {
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
}
}, []);
const showAndReset = useCallback(() => {
setShowControls(true);
resetHideTimer();
}, [resetHideTimer]);
const handleTap = useCallback(() => {
2026-03-11 20:53:18 +08:00
setShowControls((prev) => {
if (!prev) {
resetHideTimer();
return true;
}
if (hideTimer.current) clearTimeout(hideTimer.current);
return false;
});
}, [resetHideTimer]);
// Start hide timer on mount
useEffect(() => {
resetHideTimer();
2026-03-11 20:53:18 +08:00
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;
}
});
}, []);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_, gs) => {
isSeekingRef.current = true;
setIsSeeking(true);
setShowControls(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
setTouchX(clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current));
},
onPanResponderMove: (_, gs) => {
setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current));
},
onPanResponderRelease: (_, gs) => {
2026-03-11 20:53:18 +08:00
const ratio = clamp(
(gs.moveX - barOffsetX.current) / barWidthRef.current,
0,
1,
);
const t = ratio * durationRef.current;
videoRef.current?.seek(t);
setCurrentTime(t);
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
if (hideTimer.current) clearTimeout(hideTimer.current);
2026-03-11 20:53:18 +08:00
hideTimer.current = setTimeout(
() => setShowControls(false),
HIDE_DELAY,
);
},
onPanResponderTerminate: () => {
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
},
2026-03-11 20:53:18 +08:00
}),
).current;
2026-03-11 20:53:18 +08:00
const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
2026-03-13 00:30:07 +08:00
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
2026-03-11 10:03:46 +08:00
const THUMB_DISPLAY_W = 120; // scaled display width
const renderThumbnail = () => {
2026-03-11 10:03:46 +08:00
if (touchRatio === null || !shots || !isSeeking) return null;
2026-03-11 20:53:18 +08:00
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;
2026-03-11 10:03:46 +08:00
const totalFrames = framesPerSheet * image.length;
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
const seekTime = touchRatio * duration;
2026-03-11 20:53:18 +08:00
const frameIdx =
shotTimestamps.length > 0
? findFrameIdx(shotTimestamps, seekTime)
: Math.floor(touchRatio * (totalFrames - 1));
2026-03-11 10:03:46 +08:00
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
const local = frameIdx % framesPerSheet;
const col = local % img_x_len;
const row = Math.floor(local / img_x_len);
2026-03-11 10:03:46 +08:00
// Scale sprite frame to display size
const scale = THUMB_DISPLAY_W / TW;
const DW = THUMB_DISPLAY_W;
const DH = Math.round(TH * scale);
const trackLeft = barOffsetX.current;
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
// Protocol-relative URLs from B站 API need explicit https:
2026-03-11 20:53:18 +08:00
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
return (
2026-03-11 20:53:18 +08:00
<View
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
pointerEvents="none"
>
<View
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
>
<Image
2026-03-11 10:03:46 +08:00
source={{ uri: sheetUrl, headers: HEADERS }}
style={{
2026-03-11 20:53:18 +08:00
position: "absolute",
2026-03-11 10:03:46 +08:00
width: TW * img_x_len * scale,
height: TH * img_y_len * scale,
left: -col * DW,
top: -row * DH,
}}
/>
</View>
2026-03-12 23:57:01 +08:00
<Text style={styles.thumbTime}>
{formatDuration(Math.floor(seekTime))}
</Text>
</View>
);
};
2026-03-06 12:58:41 +08:00
return (
2026-03-11 20:53:18 +08:00
<View
style={[
isFullscreen
? styles.fsContainer
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
style,
]}
>
2026-03-11 10:03:46 +08:00
{resolvedUrl ? (
<Video
key={resolvedUrl}
ref={videoRef}
2026-03-11 20:53:18 +08:00
source={
isDash
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
: { uri: resolvedUrl, headers: HEADERS }
2026-03-11 10:03:46 +08:00
}
style={StyleSheet.absoluteFill}
resizeMode="contain"
controls={false}
paused={paused}
2026-03-13 00:30:07 +08:00
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
2026-03-11 10:03:46 +08:00
setCurrentTime(ct);
if (dur > 0) setDuration(dur);
2026-03-13 00:30:07 +08:00
setBuffered(buf);
onTimeUpdate?.(ct);
}}
onLoad={() => {
if (initialTime && initialTime > 0) {
videoRef.current?.seek(initialTime);
}
2026-03-11 10:03:46 +08:00
}}
/>
) : (
<View style={styles.placeholder} />
)}
{isFullscreen && !!danmakus?.length && (
<DanmakuOverlay
danmakus={danmakus}
currentTime={currentTime}
screenWidth={SCREEN_W}
screenHeight={SCREEN_H}
visible={showDanmaku}
/>
)}
2026-03-11 10:03:46 +08:00
{/* Permanent transparent tap layer — always above Video so taps always reach it */}
<TouchableWithoutFeedback onPress={handleTap}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
{showControls && (
<>
<LinearGradient
2026-03-11 20:53:18 +08:00
colors={["rgba(0,0,0,0.55)", "transparent"]}
2026-03-11 10:03:46 +08:00
style={styles.topBar}
pointerEvents="box-none"
>
{onMiniPlayer && (
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
2026-03-11 20:53:18 +08:00
<Ionicons
name="tablet-portrait-outline"
size={20}
color="#fff"
/>
2026-03-11 10:03:46 +08:00
</TouchableOpacity>
)}
</LinearGradient>
{/* Center play/pause */}
2026-03-11 20:53:18 +08:00
<TouchableOpacity
style={styles.centerBtn}
onPress={() => {
setPaused((p) => !p);
showAndReset();
}}
>
2026-03-11 10:03:46 +08:00
<View style={styles.centerBtnBg}>
2026-03-11 20:53:18 +08:00
<Ionicons
name={paused ? "play" : "pause"}
size={28}
color="#fff"
/>
2026-03-11 10:03:46 +08:00
</View>
</TouchableOpacity>
2026-03-11 10:03:46 +08:00
{/* Bottom bar */}
<LinearGradient
2026-03-11 20:53:18 +08:00
colors={["transparent", "rgba(0,0,0,0.7)"]}
2026-03-11 10:03:46 +08:00
style={styles.bottomBar}
pointerEvents="box-none"
>
{/* Progress track */}
<View
ref={trackRef}
style={styles.trackWrapper}
onLayout={measureTrack}
{...panResponder.panHandlers}
>
2026-03-11 10:03:46 +08:00
<View style={styles.track}>
2026-03-13 00:30:07 +08:00
{/* Buffered: lighter white overlay on top of base */}
2026-03-12 23:57:01 +08:00
<View
style={[
2026-03-13 00:30:07 +08:00
styles.trackLayer,
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(255,255,255,0.35)" },
2026-03-12 23:57:01 +08:00
]}
/>
2026-03-13 00:30:07 +08:00
{/* Played: accent color on top */}
2026-03-11 20:53:18 +08:00
<View
style={[
2026-03-13 00:30:07 +08:00
styles.trackLayer,
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
2026-03-11 20:53:18 +08:00
]}
/>
</View>
2026-03-11 10:03:46 +08:00
{isSeeking && touchX !== null ? (
2026-03-11 20:53:18 +08:00
<View
style={[
styles.ball,
styles.ballActive,
{ left: touchX - BALL_ACTIVE / 2 },
]}
/>
2026-03-11 10:03:46 +08:00
) : (
2026-03-11 20:53:18 +08:00
<View
style={[
styles.ball,
{ left: progressRatio * barWidthRef.current - BALL / 2 },
]}
/>
2026-03-11 10:03:46 +08:00
)}
</View>
2026-03-11 10:03:46 +08:00
{/* Controls row */}
<View style={styles.ctrlRow}>
2026-03-11 20:53:18 +08:00
<TouchableOpacity
onPress={() => {
setPaused((p) => !p);
showAndReset();
}}
style={styles.ctrlBtn}
>
<Ionicons
name={paused ? "play" : "pause"}
size={16}
color="#fff"
/>
2026-03-11 10:03:46 +08:00
</TouchableOpacity>
2026-03-12 23:57:01 +08:00
<Text style={styles.timeText}>
{formatDuration(Math.floor(currentTime))}
</Text>
2026-03-11 10:03:46 +08:00
<View style={{ flex: 1 }} />
2026-03-12 23:57:01 +08:00
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
2026-03-11 20:53:18 +08:00
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowQuality(true)}
>
2026-03-11 10:03:46 +08:00
<Text style={styles.qualityText}>{currentDesc}</Text>
</TouchableOpacity>
{isFullscreen && (
2026-03-11 20:53:18 +08:00
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowDanmaku((v) => !v)}
>
<Ionicons
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
size={16}
color="#fff"
/>
</TouchableOpacity>
)}
2026-03-11 10:03:46 +08:00
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
<Ionicons name="expand" size={16} color="#fff" />
</TouchableOpacity>
</View>
2026-03-11 10:03:46 +08:00
</LinearGradient>
</>
)}
{/* Thumbnail preview — absolute on container to avoid clipping */}
{renderThumbnail()}
{/* Quality modal */}
<Modal visible={showQuality} transparent animationType="fade">
2026-03-11 20:53:18 +08:00
<TouchableOpacity
style={styles.modalOverlay}
onPress={() => setShowQuality(false)}
>
2026-03-11 10:03:46 +08:00
<View style={styles.qualityList}>
<Text style={styles.qualityTitle}></Text>
2026-03-11 20:53:18 +08:00
{qualities.map((q) => (
2026-03-11 10:03:46 +08:00
<TouchableOpacity
key={q.qn}
style={styles.qualityItem}
2026-03-11 20:53:18 +08:00
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
showAndReset();
}}
2026-03-11 10:03:46 +08:00
>
2026-03-11 20:53:18 +08:00
<Text
style={[
styles.qualityItemText,
q.qn === currentQn && styles.qualityItemActive,
]}
>
2026-03-11 10:03:46 +08:00
{q.desc}
</Text>
2026-03-11 20:53:18 +08:00
{q.qn === currentQn && (
<Ionicons name="checkmark" size={16} color="#00AEEC" />
)}
2026-03-11 10:03:46 +08:00
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
</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" },
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",
},
topBtn: { padding: 6 },
centerBtn: {
2026-03-11 20:53:18 +08:00
position: "absolute",
top: "50%",
left: "50%",
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",
},
bottomBar: {
2026-03-11 20:53:18 +08:00
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingBottom: 8,
paddingTop: 32,
},
2026-03-11 20:53:18 +08:00
thumbPreview: { position: "absolute", bottom: 64, alignItems: "center" },
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,
},
trackWrapper: {
marginHorizontal: 8,
height: BAR_H + BALL_ACTIVE,
2026-03-11 20:53:18 +08:00
justifyContent: "center",
position: "relative",
},
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-13 00:30:07 +08:00
trackLayer: {
2026-03-11 20:53:18 +08:00
position: "absolute",
top: 0,
left: 0,
height: BAR_H,
},
ball: {
2026-03-11 20:53:18 +08:00
position: "absolute",
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,
},
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,
},
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
2026-03-11 20:53:18 +08:00
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" },
2026-03-06 12:58:41 +08:00
});