init
3
.claude/commands/add-doc.md
Normal file
@@ -0,0 +1,3 @@
|
||||
给代码加注释 $FILE_NAME
|
||||
1.每一行代码上方加上注释
|
||||
2.注释内容需要简洁,使用中文
|
||||
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Expo Dev Server",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["C:\\nvm4w\\nodejs\\node_modules\\npm\\bin\\npm-cli.js", "run", "start"],
|
||||
"port": 8081
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.env
Normal file
@@ -0,0 +1 @@
|
||||
EXPO_PUBLIC_SENTRY_DSN=https://bdb940f3a950ee46ce8ba651dee9b433@o4511094585819136.ingest.de.sentry.io/4511094601810000
|
||||
68
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Bug 报告
|
||||
description: 报告一个 Bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你提交 Bug 报告!请尽量填写完整信息,帮助我们更快定位问题。
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 运行平台
|
||||
options:
|
||||
- Android(Dev Build)
|
||||
- Android(Expo Go)
|
||||
- iOS(Dev Build)
|
||||
- iOS(Expo Go)
|
||||
- Web
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: 问题分类
|
||||
options:
|
||||
- 视频播放
|
||||
- 直播
|
||||
- 弹幕
|
||||
- 登录 / 账号
|
||||
- 下载
|
||||
- UI / 界面
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
placeholder: 请描述你遇到的问题...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
placeholder: |
|
||||
1. 打开 App
|
||||
2. 点击 ...
|
||||
3. 发现 ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
placeholder: 你期望发生什么?
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 错误日志 / 截图
|
||||
description: 如有控制台报错或截图,请粘贴在此
|
||||
render: shell
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 使用问题 / 求助
|
||||
url: https://github.com/tiajinsha/JKVideo/discussions
|
||||
about: 使用上的疑问请到 Discussions 提问
|
||||
41
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 功能建议
|
||||
description: 建议一个新功能或改进
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你的建议!在提交前请先搜索现有 Issue,避免重复。
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 需求背景
|
||||
placeholder: 这个功能解决了什么问题?目前的痛点是什么?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
placeholder: 你期望的功能是怎样的?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
placeholder: 你是否考虑过其他方案?
|
||||
|
||||
- type: dropdown
|
||||
id: contribute
|
||||
attributes:
|
||||
label: 是否愿意提交 PR?
|
||||
options:
|
||||
- 是,我愿意实现这个功能
|
||||
- 否,希望维护者实现
|
||||
- 不确定
|
||||
validations:
|
||||
required: true
|
||||
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
## 改动说明
|
||||
|
||||
> 简要描述本次 PR 做了什么
|
||||
|
||||
## 改动类型
|
||||
|
||||
- [ ] Bug 修复
|
||||
- [ ] 新功能
|
||||
- [ ] 重构(不改变功能)
|
||||
- [ ] 文档更新
|
||||
- [ ] 其他:
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
> 关闭 #(Issue 编号)
|
||||
|
||||
## 测试平台
|
||||
|
||||
- [ ] Android(Dev Build)
|
||||
- [ ] Android(Expo Go)
|
||||
- [ ] iOS
|
||||
- [ ] Web
|
||||
|
||||
## 截图 / 录屏(如适用)
|
||||
|
||||
## 注意事项
|
||||
|
||||
- [ ] 代码中无硬编码账号信息(SESSDATA、uid 等)
|
||||
- [ ] Commit 信息符合 Conventional Commits 规范
|
||||
- [ ] 已在本地测试通过
|
||||
85
.github/workflows/close-invalid-issues.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Auto Close Invalid Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
ABUSIVE_WORDS: ${{ secrets.ABUSIVE_WORDS }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
const title = (context.payload.issue.title || '').toLowerCase();
|
||||
const body = (context.payload.issue.body || '').toLowerCase();
|
||||
const text = title + ' ' + body;
|
||||
|
||||
// --- 第一层:攻击性内容(词库存于 Secret: ABUSIVE_WORDS,逗号分隔)---
|
||||
const abusiveWords = (process.env.ABUSIVE_WORDS || '').split(',').map(w => w.trim()).filter(Boolean);
|
||||
const isAbusive = abusiveWords.some(w => w && text.includes(w.toLowerCase()));
|
||||
|
||||
if (isAbusive) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body: [
|
||||
'你好,',
|
||||
'',
|
||||
'此 Issue 包含不文明内容,已自动关闭。',
|
||||
'',
|
||||
'我们欢迎任何建设性的反馈,但请保持礼貌和尊重。',
|
||||
'如有 Bug 或功能建议,请使用官方模板重新提交,谢谢。',
|
||||
].join('\n'),
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner, repo, issue_number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number,
|
||||
labels: ['invalid'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 第二层:未走模板 ---
|
||||
const templateMarkers = [
|
||||
'### 运行平台',
|
||||
'### 问题描述',
|
||||
'### 复现步骤',
|
||||
'### 需求背景',
|
||||
'### 建议方案',
|
||||
];
|
||||
const isFromTemplate = templateMarkers.some(m => body.includes(m.toLowerCase()));
|
||||
|
||||
if (!isFromTemplate) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body: [
|
||||
'感谢你的反馈!',
|
||||
'',
|
||||
'此 Issue 未按照模板填写,已自动关闭。',
|
||||
'',
|
||||
'请使用以下方式重新提交:',
|
||||
'- 🐛 **Bug 报告** → 使用 [Bug 报告模板](../../issues/new?template=bug_report.yml)',
|
||||
'- 💡 **功能建议** → 使用 [功能建议模板](../../issues/new?template=feature_request.yml)',
|
||||
'- 💬 **使用咨询** → 前往 [Discussions](../../discussions) 提问',
|
||||
].join('\n'),
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner, repo, issue_number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number,
|
||||
labels: ['invalid'],
|
||||
});
|
||||
}
|
||||
74
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Bump version
|
||||
id: bump
|
||||
run: |
|
||||
NEW_VERSION=$(node scripts/bump-version.js)
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add app.json package.json
|
||||
git commit -m "chore: bump version to v${{ steps.bump.outputs.version }} [skip ci]"
|
||||
git push
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Expo Prebuild
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Grant Gradle execute permission
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Build Release APK
|
||||
env:
|
||||
SENTRY_DISABLE_AUTO_UPLOAD: 'true'
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.bump.outputs.version }}" \
|
||||
--title "JKVideo v${{ steps.bump.outputs.version }}" \
|
||||
--generate-notes \
|
||||
android/app/build/outputs/apk/release/app-release.apk
|
||||
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Internal AI instructions & planning docs
|
||||
CLAUDE.md
|
||||
docs/
|
||||
feature.md
|
||||
livePlan.md
|
||||
Promotion.md
|
||||
wordsFilter.md
|
||||
.env.local
|
||||
reactJKVideoApp.zip
|
||||
33
.kiro/steering/commit-convention.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 提交规范(Conventional Commits)
|
||||
|
||||
所有 git commit 必须遵循以下格式:
|
||||
|
||||
```
|
||||
<type>(<scope>): <描述>
|
||||
|
||||
[可选正文]
|
||||
```
|
||||
|
||||
## 类型说明
|
||||
|
||||
| 类型 | 含义 |
|
||||
|------|------|
|
||||
| feat | 新功能 |
|
||||
| fix | Bug 修复 |
|
||||
| refactor | 重构(不改变功能) |
|
||||
| docs | 文档更新 |
|
||||
| chore | 构建脚本、依赖更新等 |
|
||||
| style | 代码格式(不影响逻辑) |
|
||||
| perf | 性能优化 |
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
feat(danmaku): 添加弹幕字体大小设置
|
||||
fix(player): 修复 DASH MPD 解析在 Android 12 上崩溃的问题
|
||||
docs: 更新 README 快速开始步骤
|
||||
```
|
||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
40
App.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://bdb940f3a950ee46ce8ba651dee9b433@o4511094585819136.ingest.de.sentry.io/4511094601810000',
|
||||
|
||||
// Adds more context data to events (IP address, cookies, user, etc.)
|
||||
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
|
||||
sendDefaultPii: true,
|
||||
|
||||
// Enable Logs
|
||||
enableLogs: true,
|
||||
|
||||
// Configure Session Replay
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1,
|
||||
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
|
||||
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: __DEV__,
|
||||
});
|
||||
|
||||
export default Sentry.wrap(function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
119
CHANGELOG.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
所有重要更新都记录在此文件中。
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
|
||||
|
||||
---
|
||||
|
||||
## [1.0.15] - 2026-03-26
|
||||
|
||||
### 新增
|
||||
- **UP主主页**:视频详情页点击 UP 主名称可进入创作者主页(`/creator/[mid]`),展示头像、简介、粉丝数、投稿数及视频列表(分页加载)
|
||||
- **缓存管理**:设置页新增「存储」区块,实时显示缓存大小,支持一键清除 expo-image 磁盘/内存缓存及通用缓存目录
|
||||
|
||||
### 优化
|
||||
- **性能**:封面图全面改用 `expo-image`(内存/磁盘两级缓存,`recyclingKey` 复用 UI 树节点);FlatList 开启 `removeClippedSubviews`;`buvid3` 改为懒初始化,首屏加载快约 30ms
|
||||
- **搜索体验**:搜索结果列表高亮命中词(HTML 标签过滤);空态增加图标+文案;防抖 300ms 减少无效请求
|
||||
- **深色模式**:修补设置页遗漏的硬编码颜色,外观/流量切换按钮、退出登录按钮全部跟随主题
|
||||
- **登录安全**:SESSDATA 迁移至 `expo-secure-store` 加密存储(Web 降级 AsyncStorage)
|
||||
|
||||
### 修复
|
||||
- **登录后头像不更新**:将 `getUserInfo()` 移入 `authStore.login()`,延迟 1s 后台执行,避免触发新会话并发风控,登录完成后头像立即刷新
|
||||
- **getFollowedLiveRooms 登录后返回空**:`login()` 内同步调用 `getUserInfo` 导致 `FollowedLiveStrip` 并发请求被平台风控拦截;改为 `setTimeout(1000)` 非阻塞后台拉取修复
|
||||
- **getUploaderVideos 无数据**:`/x/space/wbi/arc/search` 需要 WBI 签名,补全 `getWbiKeys()` + `signWbi()` 调用
|
||||
- **getFollowedLiveRooms 字段兼容**:新增 `code !== 0` 校验及 `list ?? rooms ?? []` 兼容不同 API 版本返回结构
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### 新增
|
||||
- **UP主信息**:视频详情页博主名称下方展示粉丝数和视频数(`getUploaderStat` → `/x/web-interface/card`)
|
||||
- **视频相关推荐**:详情页推荐列表改为基于当前视频(`getVideoRelated` → `/x/web-interface/archive/related`),不再与首页 feed 共用
|
||||
|
||||
### 修复
|
||||
- **直播全屏退出暂停**:全屏改用 `position:absolute` 覆盖,Video 组件始终在同一棵 React 树中,不再因 Modal 切换导致重建暂停;退出全屏时直播自动暂停
|
||||
- **直播画质选中**:`changeQuality` 强制用请求的 `qn` 覆盖服务端协商值,画质面板高亮与用户选择一致
|
||||
- **直播画质过滤**:过滤 `qn > 10000` 的选项(杜比/4K),最高仅展示原画
|
||||
- **推荐视频导航**:点击推荐列表改用 `router.replace`,避免详情页无限堆叠
|
||||
|
||||
### 优化
|
||||
- **直播画质面板**:改为居中 Modal 弹出框
|
||||
- **视频详情 Tab**:按钮向左靠齐,移除均分宽度
|
||||
- **评论排序按钮**:统一为实心背景风格(`#f0f0f0` → `#00AEEC`),与直播分区 Tab 一致
|
||||
- **设置页按钮**:外观/流量选项按钮统一为实心背景风格
|
||||
|
||||
## [1.0.11] - 2026-03-24
|
||||
|
||||
### 新增
|
||||
- **暗黑模式**:全局主题系统(`utils/theme.ts`),支持亮色 / 暗色一键切换,覆盖所有页面和组件
|
||||
- **节流模式**:设置页新增流量节省开关,开启后使用低画质封面、首页视频不自动播放、视频默认 360p 画质
|
||||
- **本地二维码生成**:登录二维码改用 `react-native-qrcode-svg` 本地渲染,移除对 `api.qrserver.com` 的外部依赖,提升可靠性
|
||||
|
||||
### 修复
|
||||
- **SeasonSection 背景色**:合集组件背景色与父容器不一致,现跟随主题色 (`theme.card`) 正确显示
|
||||
- **推荐列表 Loading 状态**:空列表加载中未显示 spinner(`ListEmptyComponent` 条件逻辑反转)
|
||||
- **合集滚动定位偏移**:`getItemLayout` offset 计算未计入卡片间距(`gap: 10`),导致 `scrollToIndex` 定位不准
|
||||
- **推荐视频卡片双边框**:相邻推荐视频卡片之间出现双分割线
|
||||
|
||||
## [1.0.0] — 2026-03-20
|
||||
|
||||
### 首个正式版本
|
||||
|
||||
#### 视频播放
|
||||
- DASH 完整播放:DASH 接口 → `buildDashMpdUri()` 生成本地 MPD → ExoPlayer 原生解码
|
||||
- 支持多清晰度切换(360P / 480P / 720P / 1080P / 1080P+ / 4K)
|
||||
- BigVideoCard 首页内联 DASH 静音自动播放,支持水平手势快进、进度条/缓冲条
|
||||
- 全局迷你播放器(MiniPlayer),切换页面后底部浮层续播
|
||||
- WebView 降级方案(NativeVideoPlayer),兼容 Expo Go 环境
|
||||
|
||||
#### 直播
|
||||
- 直播 Tab 顶部显示关注主播在线状态
|
||||
- 双列直播卡片网格 + 横向分区筛选
|
||||
- 热门列表中穿插直播推荐卡片
|
||||
- LivePlayer 支持 HLS 多画质切换
|
||||
- 直播弹幕 WebSocket 实时接收,舰长标记 + 礼物计数
|
||||
|
||||
#### 弹幕系统
|
||||
- 视频弹幕:XML 全量拉取 + 时间轴同步 drip 渲染
|
||||
- DanmakuOverlay 飘屏覆盖层(5 车道滚动)
|
||||
- DanmakuList 支持实时直播模式(保留最近 500 条)
|
||||
|
||||
#### 搜索 & 内容
|
||||
- 视频关键词搜索 + 分页加载
|
||||
- 视频详情:简介 / 评论 / 弹幕 三 Tab
|
||||
- 推荐视频流(无限滚动)
|
||||
- 评论列表(热评 / 最新排序切换)
|
||||
|
||||
#### 账号 & 设置
|
||||
- 扫码登录(二维码 + 2s 轮询 + SESSDATA 自动提取)
|
||||
- 登录态持久化(AsyncStorage)
|
||||
- 封面图清晰度设置(高清 / 普通,节省流量)
|
||||
|
||||
#### 下载 & 分享
|
||||
- 多清晰度视频后台下载
|
||||
- 下载管理页(播放、删除已下载视频)
|
||||
- 局域网 HTTP 服务器,生成 QR 码分享,同 Wi-Fi 设备扫码直接播放
|
||||
|
||||
#### 跨平台
|
||||
- Android、iOS、Web 三端支持
|
||||
- Expo Go 扫码快速运行(UI 预览模式)
|
||||
- Dev Build 完整功能(DASH 原生播放)
|
||||
96
CONTRIBUTING.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 贡献指南 · Contributing Guide
|
||||
|
||||
感谢你考虑为 JKVideo 做贡献!
|
||||
|
||||
---
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 推荐方式:Dev Build(完整功能)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/你的用户名/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo run:android # 需要连接 Android 设备或启动模拟器
|
||||
```
|
||||
|
||||
Dev Build 支持 DASH 原生播放(react-native-video),是开发视频播放功能的必选方式。
|
||||
|
||||
### Expo Go 模式(快速验证 UI)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
适合开发 UI 组件、导航、弹幕列表等不依赖原生视频解码的功能。
|
||||
|
||||
### Web 端
|
||||
|
||||
```bash
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
Web 端图片防盗链需要本地代理,在单独终端启动:
|
||||
|
||||
```bash
|
||||
node scripts/proxy.js # 监听 localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 提交规范(Conventional Commits)
|
||||
|
||||
请遵循以下格式:
|
||||
|
||||
```
|
||||
<type>(<scope>): <描述>
|
||||
|
||||
[可选正文]
|
||||
```
|
||||
|
||||
| type | 含义 |
|
||||
|---|---|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | Bug 修复 |
|
||||
| `refactor` | 重构(不改变功能) |
|
||||
| `docs` | 文档更新 |
|
||||
| `chore` | 构建脚本、依赖更新等 |
|
||||
| `style` | 代码格式(不影响逻辑) |
|
||||
| `perf` | 性能优化 |
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(danmaku): 添加弹幕字体大小设置
|
||||
fix(player): 修复 DASH MPD 解析在 Android 12 上崩溃的问题
|
||||
docs: 更新 README 快速开始步骤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR 提交流程
|
||||
|
||||
1. Fork 本仓库并创建功能分支:`git checkout -b feat/your-feature`
|
||||
2. 在本地完成开发并测试
|
||||
3. 提交符合规范的 commit
|
||||
4. 发起 Pull Request,填写模板中的说明
|
||||
5. 等待 Code Review
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在代码中硬编码任何账号信息(SESSDATA、uid 等)
|
||||
- **不接受**涉及自动化批量操作、绕过平台反爬的 PR
|
||||
- 新增功能请优先开 Issue 讨论,避免重复劳动
|
||||
- 涉及 API 参数变更时,请同步更新 `services/api.ts` 中的注释
|
||||
|
||||
---
|
||||
|
||||
## 问题反馈
|
||||
|
||||
- Bug 报告:[提交 Issue](https://github.com/你的用户名/JKVideo/issues/new?template=bug_report.yml)
|
||||
- 功能建议:[提交 Feature Request](https://github.com/你的用户名/JKVideo/issues/new?template=feature_request.yml)
|
||||
- 使用问题:优先在 [Discussions](https://github.com/你的用户名/JKVideo/discussions) 提问
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JKVideo Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
183
README.en.md
Normal file
@@ -0,0 +1,183 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/JKVideo-Video_Client-00AEEC?style=for-the-badge&logoColor=white" alt="JKVideo"/>
|
||||
|
||||
# JKVideo
|
||||
|
||||
**A feature-rich React Native video client**
|
||||
|
||||
*DASH playback · Real-time danmaku · WBI signing · Live streaming · Cross-platform*
|
||||
|
||||
---
|
||||
|
||||
[](https://reactnative.dev)
|
||||
[](https://expo.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
[](README.en.md)
|
||||
|
||||
[中文](README.md) · [Quick Start](#quick-start) · [Features](#features) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p1.jpg" width="180"/><br/><sub>Home · Inline Video · Live Cards</sub></td>
|
||||
<td align="center"><img src="public/p2.jpg" width="180"/><br/><sub>Video Detail · Info · Recommendations</sub></td>
|
||||
<td align="center"><img src="public/p3.jpg" width="180"/><br/><sub>Player · 4K HDR · Quality Switch</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p4.jpg" width="180"/><br/><sub>Downloads · LAN Share QR Code</sub></td>
|
||||
<td align="center"><img src="public/p5.jpg" width="180"/><br/><sub>Live Tab · Followed Streamers · Categories</sub></td>
|
||||
<td align="center"><img src="public/p6.jpg" width="180"/><br/><sub>Live Room · Real-time Danmaku · Guard Marks</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
🎬 **Full DASH Playback**
|
||||
DASH stream → `buildDashMpdUri()` local MPD → ExoPlayer native decode, supports 1080P+ & 4K HDR
|
||||
|
||||
💬 **Complete Danmaku System**
|
||||
Video danmaku with XML timeline sync + 5-lane floating overlay; Live danmaku via WebSocket with guard marks & gift counting
|
||||
|
||||
🔐 **WBI Signing**
|
||||
Pure TypeScript MD5 implementation, zero external crypto dependencies, 12h auto-cached nav interface
|
||||
|
||||
🏠 **Smart Home Layout**
|
||||
BigVideoCard inline DASH muted autoplay + swipe-to-seek gesture + live card interleaving + dual-column grid
|
||||
|
||||
📺 **Global Mini Player**
|
||||
Persistent bottom overlay player survives navigation, VideoStore cross-component state sync
|
||||
|
||||
🔑 **QR Code Login**
|
||||
QR code generation + 2s polling + automatic SESSDATA extraction from response headers
|
||||
|
||||
📥 **Download + LAN Sharing**
|
||||
Multi-quality background download, built-in HTTP server generates LAN QR code for same-Wi-Fi playback
|
||||
|
||||
🌐 **Cross-Platform**
|
||||
Android · iOS · Web, Expo Go scan-to-run in 5 minutes, Dev Build unlocks full DASH playback
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | React Native 0.83 + Expo SDK 55 |
|
||||
| Navigation | expo-router v4 (file-based, Stack) |
|
||||
| State | Zustand |
|
||||
| HTTP | Axios |
|
||||
| Storage | @react-native-async-storage/async-storage |
|
||||
| Video | react-native-video (DASH MPD / HLS / MP4) |
|
||||
| Fallback | react-native-webview (HTML5 video injection) |
|
||||
| Pager | react-native-pager-view |
|
||||
| Icons | @expo/vector-icons (Ionicons) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Expo Go (5 minutes, no build required)
|
||||
|
||||
> Some quality options limited; video falls back to WebView
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiajinsha/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Scan the QR code with [Expo Go](https://expo.dev/go) on Android or iOS.
|
||||
|
||||
### Option 2: Dev Build (Full features, recommended)
|
||||
|
||||
> Supports DASH 1080P+ native playback, full danmaku system
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo run:android # Android
|
||||
npx expo run:ios # iOS (requires macOS + Xcode)
|
||||
```
|
||||
|
||||
### Option 3: Web
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
> Web requires a local proxy server for image anti-hotlinking: `node scripts/proxy.js` (port 3001)
|
||||
|
||||
### Direct Install (Android)
|
||||
|
||||
Download the latest APK from [Releases](https://github.com/tiajinsha/JKVideo/releases/latest) — no build needed.
|
||||
|
||||
> Enable "Install from unknown sources" in Android settings
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
index.tsx # Home (PagerView Hot/Live tabs)
|
||||
video/[bvid].tsx # Video detail (player + info/comments/danmaku)
|
||||
live/[roomId].tsx # Live room (HLS player + real-time danmaku)
|
||||
search.tsx # Search page
|
||||
downloads.tsx # Download manager
|
||||
settings.tsx # Settings (quality + logout)
|
||||
|
||||
components/ # UI components (player, danmaku, cards, etc.)
|
||||
hooks/ # Data hooks (video list, stream URLs, danmaku, etc.)
|
||||
services/ # Video platform API wrapper (axios + cookie interceptor)
|
||||
store/ # Zustand stores (auth, download, playback, settings)
|
||||
utils/ # Utilities (format, image proxy, MPD builder)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
| Limitation | Reason |
|
||||
|---|---|
|
||||
| 4K / 1080P+ requires premium account | API restriction |
|
||||
| FLV live streams not supported | Neither HTML5 nor ExoPlayer support FLV; HLS auto-selected |
|
||||
| Web requires local proxy | Image CDN Referer anti-hotlinking |
|
||||
| Feed / like / collect features | Requires `bili_jct` CSRF token, not yet implemented |
|
||||
| QR code expires after 10 minutes | Close and reopen the login modal to refresh |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for personal learning and research purposes only. Not for commercial use.
|
||||
All video content copyright belongs to the original authors and the respective platforms.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 JKVideo Contributors
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
If this project helps you, please give it a ⭐ Star!
|
||||
|
||||
</div>
|
||||
206
README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/JKVideo-视频播放器客户端-00AEEC?style=for-the-badge&logoColor=white" alt="JKVideo"/>
|
||||
|
||||
# JKVideo
|
||||
|
||||
**高颜值的网络视频播放器 React Native 客户端**
|
||||
|
||||
*A feature-rich Video app with DASH playback, real-time danmaku, WBI signing & live streaming*
|
||||
|
||||
---
|
||||
|
||||
[](https://reactnative.dev)
|
||||
[](https://expo.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
[](README.md)
|
||||
|
||||
[English](README.en.md) · [快速开始](#快速开始) · [功能亮点](#功能亮点) · [贡献](CONTRIBUTING.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 截图预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p1.jpg" width="180"/><br/><sub>首页热门 · 内联视频 · 穿插直播</sub></td>
|
||||
<td align="center"><img src="public/p2.jpg" width="180"/><br/><sub>首页直播 · 关注房间· 分区筛选</sub></td>
|
||||
<td align="center"><img src="public/p3.jpg" width="180"/><br/><sub>直播详情 · 4K HDR · 多清晰度</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p4.jpg" width="180"/><br/><sub>下载管理 · 局域网分享二维码</sub></td>
|
||||
<td align="center"><img src="public/p5.jpg" width="180"/><br/><sub>直播详情 ·实时弹幕 · 清晰度切换</sub></td>
|
||||
<td align="center"><img src="public/p6.jpg" width="180"/><br/><sub>视频弹幕 · 同步加载</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 演示视频
|
||||
|
||||
https://github.com/tiajinsha/JKVideo/releases/download/v1.0.0/6490dcd9dba9a243a7cd8f00359cc285.mp4
|
||||
|
||||
---
|
||||
|
||||
## 功能亮点
|
||||
|
||||
🎬 **DASH 完整播放**
|
||||
DASH 流 → `buildDashMpdUri()` 生成本地 MPD → ExoPlayer 原生解码,支持 1080P + 4K HDR杜比视界
|
||||
|
||||
💬 **完整弹幕系统**
|
||||
视频弹幕 XML 时间轴同步 + 5 车道飘屏覆盖;直播弹幕 WebSocket 实时接收 + 舰长标记 + 礼物计数
|
||||
|
||||
🔐 **WBI 签名实现**
|
||||
纯 TypeScript 手写 MD5,无任何外部加密依赖,nav 接口 12h 自动缓存
|
||||
|
||||
🏠 **智能首页排布**
|
||||
BigVideoCard 内联 DASH 静音自动播放 + 水平手势快进 + 直播卡片穿插 + 双列混排
|
||||
|
||||
📺 **全局迷你播放器**
|
||||
切换页面后底部浮层续播,VideoStore 跨组件状态同步
|
||||
|
||||
🔑 **扫码登录**
|
||||
二维码生成 + 2s 轮询 + 响应头 Cookie 自动提取 SESSDATA
|
||||
|
||||
📥 **下载 + 局域网分享**
|
||||
多清晰度后台下载,内置 HTTP 服务器生成局域网 QR 码,同 Wi-Fi 设备扫码直接播放
|
||||
|
||||
🌐 **跨平台运行**
|
||||
Android · iOS · Web,Expo Go 扫码 5 分钟运行,Dev Build 解锁完整 DASH 播放
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 框架 | React Native 0.83 + Expo SDK 55 |
|
||||
| 路由 | expo-router v4(文件系统路由,Stack 导航) |
|
||||
| 状态管理 | Zustand |
|
||||
| 网络请求 | Axios |
|
||||
| 本地存储 | @react-native-async-storage/async-storage |
|
||||
| 视频播放 | react-native-video(DASH MPD / HLS / MP4) |
|
||||
| 降级播放 | react-native-webview(HTML5 video 注入) |
|
||||
| 页面滑动 | react-native-pager-view |
|
||||
| 图标 | @expo/vector-icons(Ionicons) |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:Expo Go(5 分钟,无需编译)
|
||||
|
||||
> 部分清晰度受限,视频播放降级为 WebView 方案
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiajinsha/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
用 Expo Go App([Android](https://expo.dev/go) / [iOS](https://expo.dev/go))扫描终端二维码即可运行。
|
||||
|
||||
### 方式二:Dev Build(完整功能,推荐)
|
||||
|
||||
> 支持 DASH 1080P+ 原生播放、完整弹幕系统
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo run:android # Android
|
||||
npx expo run:ios # iOS(需 macOS + Xcode)
|
||||
```
|
||||
|
||||
### 方式三:Web 端
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
> Web 端图片需本地代理服务器绕过防盗链:`node scripts/proxy.js`(端口 3001)
|
||||
|
||||
### 直接安装(Android)
|
||||
|
||||
前往 [Releases](https://github.com/tiajinsha/JKVideo/releases/latest) 下载最新 APK,无需编译,安装即用。
|
||||
|
||||
> 需在 Android 设置中开启「安装未知来源应用」
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
app/
|
||||
index.tsx # 首页(PagerView 热门/直播 Tab)
|
||||
video/[bvid].tsx # 视频详情(播放 + 简介/评论/弹幕)
|
||||
live/[roomId].tsx # 直播详情(HLS 播放 + 实时弹幕)
|
||||
search.tsx # 搜索页
|
||||
downloads.tsx # 下载管理页
|
||||
settings.tsx # 设置页(画质 + 退出登录)
|
||||
|
||||
components/ # UI 组件(播放器、弹幕、卡片等)
|
||||
hooks/ # 数据 Hooks(视频列表、播放流、弹幕等)
|
||||
services/ # 视频平台 API 封装(axios + Cookie 拦截)
|
||||
store/ # Zustand 状态(登录、下载、播放、设置)
|
||||
utils/ # 工具函数(格式化、图片代理、MPD 构建)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
| 限制 | 原因 |
|
||||
|---|---|
|
||||
| 4K / 1080P+ 需要大会员账号登录 | B 站 API 策略限制 |
|
||||
| FLV 直播流不支持 | HTML5 / ExoPlayer 均不支持 FLV,已自动选 HLS |
|
||||
| Web 端需本地代理 | B 站图片防盗链(Referer 限制) |
|
||||
| 动态流 / 投稿 / 点赞 | 需要 `bili_jct` CSRF Token,暂未实现 |
|
||||
| 二维码 10 分钟过期 | 关闭登录弹窗重新打开即可刷新 |
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 PR!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供个人学习研究使用,不得用于商业用途。
|
||||
所有视频内容版权归原作者及相关平台所有。
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 JKVideo Contributors
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
如果这个项目对你有帮助,欢迎点一个 ⭐ Star!
|
||||
|
||||
---
|
||||
|
||||
## 请作者喝杯咖啡 ☕
|
||||
|
||||
如果这个项目对你有所帮助,欢迎请作者喝杯咖啡,你的支持是持续开发的最大动力,感谢每一位愿意打赏的朋友!
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="public/wxpay.jpg" width="180"/><br/>
|
||||
<sub>微信支付</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/alipay.jpg" width="180"/><br/>
|
||||
<sub>支付宝</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
59
app.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "JKVideo",
|
||||
"slug": "jsvideo",
|
||||
"version": "1.0.17",
|
||||
"scheme": "jkvideo",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#00AEEC"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"android.permission.REQUEST_INSTALL_PACKAGES",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
],
|
||||
"package": "com.anonymous.jkvideo"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"react-native-video",
|
||||
"expo-screen-orientation",
|
||||
"@sentry/react-native/expo",
|
||||
"expo-secure-store",
|
||||
"expo-image"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "eac4192a-ae80-461c-8fff-6e1a8b777bd1"
|
||||
}
|
||||
},
|
||||
"owner": "jinsha"
|
||||
}
|
||||
}
|
||||
105
app/_layout.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { Text, View } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadStore } from '../store/downloadStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTheme } from '../utils/theme';
|
||||
import { MiniPlayer } from '../components/MiniPlayer';
|
||||
import { LiveMiniPlayer } from '../components/LiveMiniPlayer';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import { ErrorBoundary } from '@sentry/react-native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN ?? '',
|
||||
enabled: !__DEV__,
|
||||
tracesSampleRate: 0.05,
|
||||
environment: process.env.EXPO_PUBLIC_APP_ENV ?? 'production',
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
const restore = useAuthStore(s => s.restore);
|
||||
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
|
||||
const restoreSettings = useSettingsStore(s => s.restore);
|
||||
const darkMode = useSettingsStore(s => s.darkMode);
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
...Ionicons.font,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
restore();
|
||||
loadDownloads();
|
||||
restoreSettings();
|
||||
}, []);
|
||||
|
||||
if (!fontsLoaded) return null;
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style={darkMode ? 'light' : 'dark'} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<ErrorBoundary fallback={<Text style={{ padding: 32, textAlign: 'center' }}>发生错误,请重启 App</Text>}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="video"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="live"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="search"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="creator"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</ErrorBoundary>
|
||||
<MiniPlayer />
|
||||
<LiveMiniPlayer />
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.wrap(RootLayout);
|
||||
209
app/creator/[mid].tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getUploaderInfo, getUploaderVideos } from '../../services/api';
|
||||
import type { VideoItem } from '../../services/types';
|
||||
import { useTheme } from '../../utils/theme';
|
||||
import { formatCount, formatDuration } from '../../utils/format';
|
||||
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function CreatorScreen() {
|
||||
const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
|
||||
const mid = Number(midStr);
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
|
||||
const [info, setInfo] = useState<{
|
||||
name: string; face: string; sign: string; follower: number; archiveCount: number;
|
||||
} | null>(null);
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [infoLoading, setInfoLoading] = useState(true);
|
||||
const loadingRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUploaderInfo(mid)
|
||||
.then(setInfo)
|
||||
.catch(() => {})
|
||||
.finally(() => setInfoLoading(false));
|
||||
loadVideos(1, true);
|
||||
}, [mid]);
|
||||
|
||||
const loadVideos = useCallback(async (pn: number, reset = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { videos: newVideos, total: t } = await getUploaderVideos(mid, pn, PAGE_SIZE);
|
||||
setTotal(t);
|
||||
setVideos(prev => reset ? newVideos : [...prev, ...newVideos]);
|
||||
setPage(pn);
|
||||
} catch {}
|
||||
finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mid]);
|
||||
|
||||
const hasMore = videos.length < total;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
|
||||
{/* Top bar */}
|
||||
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
|
||||
{info?.name ?? 'UP主主页'}
|
||||
</Text>
|
||||
<View style={styles.backBtn} />
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={videos}
|
||||
keyExtractor={item => item.bvid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
|
||||
onEndReachedThreshold={0.3}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={6}
|
||||
removeClippedSubviews
|
||||
ListHeaderComponent={
|
||||
infoLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : info ? (
|
||||
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(info.face) }}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
recyclingKey={String(mid)}
|
||||
/>
|
||||
<Text style={[styles.name, { color: theme.text }]}>{info.name}</Text>
|
||||
{info.sign ? (
|
||||
<Text style={[styles.sign, { color: theme.textSub }]} numberOfLines={2}>{info.sign}</Text>
|
||||
) : null}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.follower)}</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.textSub }]}>粉丝</Text>
|
||||
</View>
|
||||
<View style={[styles.statDivider, { backgroundColor: theme.border }]} />
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.archiveCount)}</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.textSub }]}>视频</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.videoListHeader, { color: theme.textSub }]}>
|
||||
全部视频({total})
|
||||
</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
|
||||
onPress={() => router.push(`/video/${item.bvid}` as any)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.thumbWrap}>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||
style={styles.thumb}
|
||||
contentFit="cover"
|
||||
recyclingKey={item.bvid}
|
||||
transition={200}
|
||||
/>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.videoInfo}>
|
||||
<Text style={[styles.videoTitle, { color: theme.text }]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View style={styles.videoMeta}>
|
||||
<Ionicons name="play" size={11} color={theme.textSub} />
|
||||
<Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat.view)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
!loading && !infoLoading ? (
|
||||
<Text style={[styles.emptyTxt, { color: theme.textSub }]}>暂无视频</Text>
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
loading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
backBtn: { padding: 4, width: 32 },
|
||||
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
|
||||
profileCard: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
marginBottom: 4,
|
||||
},
|
||||
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
|
||||
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
|
||||
sign: { fontSize: 13, textAlign: 'center', paddingHorizontal: 24, marginBottom: 12, lineHeight: 19 },
|
||||
statsRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
|
||||
statItem: { alignItems: 'center', paddingHorizontal: 24 },
|
||||
statNum: { fontSize: 18, fontWeight: '700' },
|
||||
statLabel: { fontSize: 12, marginTop: 2 },
|
||||
statDivider: { width: 1, height: 28 },
|
||||
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 },
|
||||
loader: { marginVertical: 24 },
|
||||
videoRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: 10,
|
||||
},
|
||||
thumbWrap: { width: 120, height: 68, borderRadius: 4, overflow: 'hidden', flexShrink: 0, position: 'relative' },
|
||||
thumb: { width: 120, height: 68 },
|
||||
durationBadge: {
|
||||
position: 'absolute', bottom: 3, right: 3,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1,
|
||||
},
|
||||
durationText: { color: '#fff', fontSize: 10 },
|
||||
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
|
||||
videoTitle: { fontSize: 13, lineHeight: 18 },
|
||||
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
metaText: { fontSize: 12 },
|
||||
emptyTxt: { textAlign: 'center', padding: 40 },
|
||||
});
|
||||
5
app/creator/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function CreatorLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
299
app/downloads.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Modal,
|
||||
StatusBar,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Video from 'react-native-video';
|
||||
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
|
||||
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
|
||||
import { useDownloadStore, DownloadTask } from '../store/downloadStore';
|
||||
import { LanShareModal } from '../components/LanShareModal';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { useTheme } from '../utils/theme';
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes || bytes <= 0) return '';
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function DownloadsScreen() {
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const { tasks, loadFromStorage, removeTask } = useDownloadStore();
|
||||
const [playingUri, setPlayingUri] = useState<string | null>(null);
|
||||
const [playingTitle, setPlayingTitle] = useState('');
|
||||
const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null);
|
||||
|
||||
async function openPlayer(uri: string, title: string) {
|
||||
setPlayingTitle(title);
|
||||
setPlayingUri(uri);
|
||||
await ScreenOrientation?.unlockAsync();
|
||||
}
|
||||
|
||||
async function closePlayer() {
|
||||
setPlayingUri(null);
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
}
|
||||
|
||||
function confirmDelete(key: string, status: DownloadTask['status']) {
|
||||
const isDownloading = status === 'downloading';
|
||||
Alert.alert(
|
||||
isDownloading ? '取消下载' : '删除下载',
|
||||
isDownloading ? '确定取消该下载任务?' : '确定删除该文件?删除后不可恢复。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: isDownloading ? '取消下载' : '删除', style: 'destructive', onPress: () => removeTask(key) },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFromStorage();
|
||||
}, []);
|
||||
|
||||
const all = Object.entries(tasks).map(([key, task]) => ({ key, ...task }));
|
||||
const downloading = all.filter((t) => t.status === 'downloading' || t.status === 'error');
|
||||
const done = all.filter((t) => t.status === 'done');
|
||||
|
||||
const sections = [];
|
||||
if (downloading.length > 0) sections.push({ title: '下载中', data: downloading });
|
||||
if (done.length > 0) sections.push({ title: '已下载', data: done });
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
|
||||
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.topTitle, { color: theme.text }]}>我的下载</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name="cloud-download-outline" size={56} color={theme.textSub} />
|
||||
<Text style={[styles.emptyTxt, { color: theme.textSub }]}>暂无下载记录</Text>
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.key}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View style={[styles.sectionHeader, { backgroundColor: theme.bg }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textSub }]}>{section.title}</Text>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item }) => (
|
||||
<DownloadRow
|
||||
task={item}
|
||||
theme={theme}
|
||||
onPlay={() => {
|
||||
if (item.localUri) openPlayer(item.localUri, item.title);
|
||||
}}
|
||||
onDelete={() => confirmDelete(item.key, item.status)}
|
||||
onShare={() => setShareTask(item)}
|
||||
onRetry={() => router.push(`/video/${item.bvid}` as any)}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={[styles.separator, { backgroundColor: theme.border, marginLeft: 108 }]} />
|
||||
)}
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LanShareModal
|
||||
visible={!!shareTask}
|
||||
task={shareTask}
|
||||
onClose={() => setShareTask(null)}
|
||||
/>
|
||||
|
||||
{/* Local file player modal */}
|
||||
<Modal
|
||||
visible={!!playingUri}
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
onRequestClose={closePlayer}
|
||||
>
|
||||
<StatusBar hidden />
|
||||
<View style={styles.playerBg}>
|
||||
{playingUri && (
|
||||
<Video
|
||||
source={{ uri: playingUri }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode="contain"
|
||||
controls
|
||||
paused={false}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.playerBar}>
|
||||
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Ionicons name="chevron-back" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadRow({
|
||||
task,
|
||||
theme,
|
||||
onPlay,
|
||||
onDelete,
|
||||
onShare,
|
||||
onRetry,
|
||||
}: {
|
||||
task: DownloadTask & { key: string };
|
||||
theme: ReturnType<typeof useTheme>;
|
||||
onPlay: () => void;
|
||||
onDelete: () => void;
|
||||
onShare: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const isDone = task.status === 'done';
|
||||
const isError = task.status === 'error';
|
||||
const isDownloading = task.status === 'downloading';
|
||||
|
||||
const rowContent = (
|
||||
<View style={[styles.row, { backgroundColor: theme.card }]}>
|
||||
<Image source={{ uri: proxyImageUrl(task.cover) }} style={styles.cover} />
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>{task.title}</Text>
|
||||
<Text style={[styles.qdesc, { color: theme.textSub }]}>
|
||||
{task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''}
|
||||
</Text>
|
||||
{isDownloading && (
|
||||
<View style={styles.progressWrap}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${Math.round(task.progress * 100)}%` as any }]} />
|
||||
</View>
|
||||
<Text style={styles.progressTxt}>{Math.round(task.progress * 100)}%</Text>
|
||||
</View>
|
||||
)}
|
||||
{isError && (
|
||||
<View style={styles.errorRow}>
|
||||
<Text style={styles.errorTxt} numberOfLines={1}>{task.error ?? '下载失败'}</Text>
|
||||
<TouchableOpacity onPress={onRetry} style={styles.retryBtn}>
|
||||
<Text style={styles.retryTxt}>重新下载</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
{isDone && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={onShare}>
|
||||
<Ionicons name="share-social-outline" size={20} color="#00AEEC" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.actionBtn}
|
||||
onPress={onDelete}
|
||||
>
|
||||
<Ionicons
|
||||
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
|
||||
size={20}
|
||||
color="#bbb"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isDone) {
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={onPlay}>
|
||||
{rowContent}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return rowContent;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginLeft: 4,
|
||||
},
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
|
||||
emptyTxt: { fontSize: 14 },
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sectionTitle: { fontSize: 13, fontWeight: '600' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
},
|
||||
cover: { width: 80, height: 54, borderRadius: 6, backgroundColor: '#eee', flexShrink: 0 },
|
||||
info: { flex: 1 },
|
||||
title: { fontSize: 13, lineHeight: 18, marginBottom: 4 },
|
||||
qdesc: { fontSize: 12, marginBottom: 4 },
|
||||
progressWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 2, gap: 6 },
|
||||
progressTrack: {
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#e0e0e0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: { height: 3, backgroundColor: '#00AEEC', borderRadius: 2 },
|
||||
progressTxt: { fontSize: 11, color: '#999', minWidth: 30 },
|
||||
errorRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 2 },
|
||||
errorTxt: { fontSize: 12, color: '#f44', flex: 1 },
|
||||
retryBtn: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#e8f7fd',
|
||||
},
|
||||
retryTxt: { fontSize: 12, color: '#00AEEC', fontWeight: '600' },
|
||||
actions: { alignItems: 'center', gap: 12 },
|
||||
actionBtn: { padding: 4 },
|
||||
separator: { height: StyleSheet.hairlineWidth },
|
||||
// player modal
|
||||
playerBg: { flex: 1, backgroundColor: '#000', justifyContent: 'center' },
|
||||
playerBar: {
|
||||
position: 'absolute',
|
||||
top: 44,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
closeBtn: { padding: 6 },
|
||||
playerTitle: { flex: 1, color: '#fff', fontSize: 14, fontWeight: '600', marginLeft: 4 },
|
||||
});
|
||||
620
app/index.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
RefreshControl,
|
||||
ViewToken,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import PagerView from "react-native-pager-view";
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { VideoCard } from "../components/VideoCard";
|
||||
import { LiveCard } from "../components/LiveCard";
|
||||
import { LoginModal } from "../components/LoginModal";
|
||||
import { DownloadProgressBtn } from "../components/DownloadProgressBtn";
|
||||
import { useVideoList } from "../hooks/useVideoList";
|
||||
import { useLiveList } from "../hooks/useLiveList";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import {
|
||||
toListRows,
|
||||
type ListRow,
|
||||
type BigRow,
|
||||
type LiveRow,
|
||||
} from "../utils/videoRows";
|
||||
import { BigVideoCard } from "../components/BigVideoCard";
|
||||
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import type { LiveRoom } from "../services/types";
|
||||
|
||||
const HEADER_H = 44;
|
||||
const TAB_H = 38;
|
||||
const NAV_H = HEADER_H + TAB_H;
|
||||
|
||||
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
|
||||
|
||||
type TabKey = "hot" | "live";
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: "hot", label: "热门" },
|
||||
{ key: "live", label: "直播" },
|
||||
];
|
||||
|
||||
const LIVE_AREAS = [
|
||||
{ id: 0, name: "推荐" },
|
||||
{ id: 2, name: "网游" },
|
||||
{ id: 3, name: "手游" },
|
||||
{ id: 6, name: "单机游戏" },
|
||||
{ id: 1, name: "娱乐" },
|
||||
{ id: 9, name: "虚拟主播" },
|
||||
{ id: 10, name: "生活" },
|
||||
{ id: 11, name: "知识" },
|
||||
];
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { pages, liveRooms, loading, refreshing, load, refresh } =
|
||||
useVideoList();
|
||||
const {
|
||||
rooms,
|
||||
loading: liveLoading,
|
||||
refreshing: liveRefreshing,
|
||||
load: liveLoad,
|
||||
refresh: liveRefresh,
|
||||
} = useLiveList();
|
||||
const { isLoggedIn, face } = useAuthStore();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("hot");
|
||||
const [liveAreaId, setLiveAreaId] = useState(0);
|
||||
|
||||
const theme = useTheme();
|
||||
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
||||
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
|
||||
const pagerRef = useRef<PagerView>(null);
|
||||
|
||||
const hotListRef = useRef<FlatList>(null);
|
||||
const liveListRef = useRef<FlatList>(null);
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
const bigRow = viewableItems.find(
|
||||
(v) => v.item && (v.item as ListRow).type === "big",
|
||||
);
|
||||
setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
|
||||
},
|
||||
).current;
|
||||
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const headerTranslate = scrollY.interpolate({
|
||||
inputRange: [0, HEADER_H],
|
||||
outputRange: [0, -HEADER_H],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const headerOpacity = scrollY.interpolate({
|
||||
inputRange: [0, HEADER_H * 0.2],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
// 直播列表也共用同一个 scrollY
|
||||
const liveScrollY = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const liveHeaderTranslate = liveScrollY.interpolate({
|
||||
inputRange: [0, HEADER_H],
|
||||
outputRange: [0, -HEADER_H],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const liveHeaderOpacity = liveScrollY.interpolate({
|
||||
inputRange: [0, HEADER_H * 0.2],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const onScroll = useMemo(
|
||||
() =>
|
||||
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onLiveScroll = useMemo(
|
||||
() =>
|
||||
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(key: TabKey) => {
|
||||
if (key === activeTab) {
|
||||
// 点击已激活的 tab:滚动到顶部并刷新
|
||||
if (key === "hot") {
|
||||
hotListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
refresh();
|
||||
} else {
|
||||
liveListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
liveRefresh(liveAreaId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 切换 tab
|
||||
pagerRef.current?.setPage(key === "hot" ? 0 : 1);
|
||||
setActiveTab(key);
|
||||
if (key === "live" && rooms.length === 0) {
|
||||
liveLoad(true, liveAreaId);
|
||||
}
|
||||
},
|
||||
[activeTab, rooms.length, liveAreaId],
|
||||
);
|
||||
|
||||
const onPageSelected = useCallback(
|
||||
(e: any) => {
|
||||
const key: TabKey = e.nativeEvent.position === 0 ? "hot" : "live";
|
||||
if (key === activeTab) return;
|
||||
setActiveTab(key);
|
||||
if (key === "live" && rooms.length === 0) {
|
||||
liveLoad(true, liveAreaId);
|
||||
}
|
||||
},
|
||||
[activeTab, rooms.length, liveAreaId],
|
||||
);
|
||||
|
||||
const handleLiveAreaPress = useCallback(
|
||||
(areaId: number) => {
|
||||
if (areaId === liveAreaId) return;
|
||||
setLiveAreaId(areaId);
|
||||
liveListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
liveLoad(true, areaId);
|
||||
},
|
||||
[liveAreaId, liveLoad],
|
||||
);
|
||||
|
||||
const visibleBigKeyRef = useRef(visibleBigKey);
|
||||
visibleBigKeyRef.current = visibleBigKey;
|
||||
|
||||
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
|
||||
if (row.type === "big") {
|
||||
return (
|
||||
<BigVideoCard
|
||||
item={row.item}
|
||||
isVisible={visibleBigKeyRef.current === row.item.bvid}
|
||||
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (row.type === "live") {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<LiveCard
|
||||
isLivePulse
|
||||
item={row.left}
|
||||
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{row.right && (
|
||||
<View style={styles.rightCol}>
|
||||
<LiveCard
|
||||
isLivePulse
|
||||
item={row.right}
|
||||
onPress={() => router.push(`/live/${row.right!.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const right = row.right;
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<VideoCard
|
||||
item={row.left}
|
||||
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{right && (
|
||||
<View style={styles.rightCol}>
|
||||
<VideoCard
|
||||
item={right}
|
||||
onPress={() => router.push(`/video/${right.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderLiveItem = useCallback(
|
||||
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<LiveCard
|
||||
item={item.left}
|
||||
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{item.right && (
|
||||
<View style={styles.rightCol}>
|
||||
<LiveCard
|
||||
item={item.right}
|
||||
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// 将直播列表分成两列的行
|
||||
const liveRows = useMemo(() => {
|
||||
const result: { left: LiveRoom; right?: LiveRoom }[] = [];
|
||||
for (let i = 0; i < rooms.length; i += 2) {
|
||||
result.push({ left: rooms[i], right: rooms[i + 1] });
|
||||
}
|
||||
return result;
|
||||
}, [rooms]);
|
||||
|
||||
const currentHeaderTranslate =
|
||||
activeTab === "hot" ? headerTranslate : liveHeaderTranslate;
|
||||
const currentHeaderOpacity =
|
||||
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={["left", "right"]}>
|
||||
{/* 滑动切换容器 */}
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={styles.pager}
|
||||
initialPage={0}
|
||||
scrollEnabled={false}
|
||||
onPageSelected={onPageSelected}
|
||||
>
|
||||
{/* 热门列表 */}
|
||||
<View key="hot" collapsable={false}>
|
||||
<Animated.FlatList
|
||||
ref={hotListRef as any}
|
||||
style={styles.listContainer}
|
||||
data={rows}
|
||||
keyExtractor={(row: any, index: number) =>
|
||||
row.type === "big"
|
||||
? `big-${row.item.bvid}`
|
||||
: row.type === "live"
|
||||
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
|
||||
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + NAV_H + 6,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
renderItem={renderItem}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
progressViewOffset={insets.top + NAV_H}
|
||||
/>
|
||||
}
|
||||
onEndReached={() => load()}
|
||||
onEndReachedThreshold={0.5}
|
||||
extraData={visibleBigKey}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||
ListFooterComponent={
|
||||
<View style={styles.footer}>
|
||||
{loading && <ActivityIndicator color="#00AEEC" />}
|
||||
</View>
|
||||
}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={16}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={6}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 直播列表 */}
|
||||
<View key="live" collapsable={false}>
|
||||
<Animated.FlatList
|
||||
ref={liveListRef as any}
|
||||
style={styles.listContainer}
|
||||
data={liveRows}
|
||||
keyExtractor={(item: any, index: number) =>
|
||||
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + NAV_H + 6,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
renderItem={renderLiveItem}
|
||||
ListHeaderComponent={
|
||||
<View>
|
||||
<FollowedLiveStrip />
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.areaTabRow}
|
||||
contentContainerStyle={styles.areaTabContent}
|
||||
>
|
||||
{LIVE_AREAS.map((area) => (
|
||||
<TouchableOpacity
|
||||
key={area.id}
|
||||
style={[
|
||||
styles.areaTab,
|
||||
liveAreaId === area.id && styles.areaTabActive,
|
||||
]}
|
||||
onPress={() => handleLiveAreaPress(area.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.areaTabText,
|
||||
liveAreaId === area.id && styles.areaTabTextActive,
|
||||
]}
|
||||
>
|
||||
{area.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={liveRefreshing}
|
||||
onRefresh={() => liveRefresh(liveAreaId)}
|
||||
progressViewOffset={insets.top + NAV_H}
|
||||
/>
|
||||
}
|
||||
onEndReached={() => liveLoad()}
|
||||
onEndReachedThreshold={1.5}
|
||||
ListFooterComponent={
|
||||
liveLoading ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator color="#00AEEC" />
|
||||
<Text style={styles.footerText}>加载中...</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
onScroll={onLiveScroll}
|
||||
scrollEventThrottle={16}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={6}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
</PagerView>
|
||||
|
||||
{/* 绝对定位导航栏 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.navBar,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: theme.card,
|
||||
transform: [{ translateY: currentHeaderTranslate }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
opacity: currentHeaderOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity
|
||||
style={styles.headerBtn}
|
||||
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
|
||||
>
|
||||
{isLoggedIn && face ? (
|
||||
<Image source={{ uri: face }} style={styles.userAvatar} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={isLoggedIn ? "person" : "person-outline"}
|
||||
size={22}
|
||||
color="#00AEEC"
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBar, { backgroundColor: theme.inputBg }]}
|
||||
onPress={() => router.push("/search" as any)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="search" size={14} color={theme.textSub} />
|
||||
<Text style={[styles.searchPlaceholder, { color: theme.textSub }]}>搜索视频、UP主...</Text>
|
||||
</TouchableOpacity>
|
||||
<DownloadProgressBtn
|
||||
onPress={() => router.push("/downloads" as any)}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.tabRow, { backgroundColor: theme.card }]}>
|
||||
{TABS.map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={styles.tabItem}
|
||||
onPress={() => handleTabPress(tab.key)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
{ color: theme.textSub },
|
||||
activeTab === tab.key && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{activeTab === tab.key && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: "#f4f4f4" },
|
||||
pager: { flex: 1 },
|
||||
listContainer: { flex: 1 },
|
||||
navBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: "#fff",
|
||||
overflow: "hidden",
|
||||
elevation: 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_H,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
gap: 10,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 20,
|
||||
fontWeight: "800",
|
||||
color: "#00AEEC",
|
||||
letterSpacing: -0.5,
|
||||
width: 72,
|
||||
},
|
||||
searchBar: {
|
||||
flex: 1,
|
||||
height: 30,
|
||||
marginLeft: 8,
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: 15,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
gap: 5,
|
||||
},
|
||||
downloadBtn: {},
|
||||
searchPlaceholder: {
|
||||
fontSize: 13,
|
||||
color: "#999",
|
||||
flex: 1,
|
||||
},
|
||||
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
|
||||
headerBtn: { paddingLeft: 0 },
|
||||
userAvatar: {
|
||||
width: 35,
|
||||
height: 35,
|
||||
borderRadius: 50,
|
||||
backgroundColor: "#eee",
|
||||
},
|
||||
tabRow: {
|
||||
height: TAB_H,
|
||||
backgroundColor: "#fff",
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 20,
|
||||
},
|
||||
tabItem: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: TAB_H,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "450",
|
||||
color: "#999",
|
||||
},
|
||||
tabTextActive: {
|
||||
fontWeight: "500",
|
||||
color: "#00AEEC",
|
||||
},
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
width: 24,
|
||||
height: 3,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 1,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
leftCol: { marginLeft: 4, marginRight: 2 },
|
||||
rightCol: { marginLeft: 2, marginRight: 4 },
|
||||
footer: {
|
||||
height: 48,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
},
|
||||
footerText: { fontSize: 12, color: "#999" },
|
||||
areaTabRow: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
areaTabContent: {
|
||||
paddingHorizontal: 8,
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
height: 36,
|
||||
},
|
||||
areaTab: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
areaTabActive: {
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
areaTabText: {
|
||||
fontSize: 13,
|
||||
color: "#333",
|
||||
fontWeight: "500",
|
||||
},
|
||||
areaTabTextActive: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
292
app/live/[roomId].tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLiveDetail } from "../../hooks/useLiveDetail";
|
||||
import { useLiveDanmaku } from "../../hooks/useLiveDanmaku";
|
||||
import { LivePlayer } from "../../components/LivePlayer";
|
||||
import DanmakuList from "../../components/DanmakuList";
|
||||
import { formatCount } from "../../utils/format";
|
||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||
import { useTheme } from "../../utils/theme";
|
||||
import { useLiveStore } from "../../store/liveStore";
|
||||
|
||||
type Tab = "intro" | "danmaku";
|
||||
|
||||
export default function LiveDetailScreen() {
|
||||
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const id = parseInt(roomId ?? "0", 10);
|
||||
|
||||
// 进入详情页时立即清除小窗(useLayoutEffect 在绘制前同步执行)
|
||||
useLayoutEffect(() => {
|
||||
useLiveStore.getState().clearLive();
|
||||
}, []);
|
||||
|
||||
const { room, anchor, stream, loading, error, changeQuality } =
|
||||
useLiveDetail(id);
|
||||
const [tab, setTab] = useState<Tab>("intro");
|
||||
|
||||
const isLive = room?.live_status === 1;
|
||||
const hlsUrl = stream?.hlsUrl ?? "";
|
||||
const flvUrl = stream?.flvUrl ?? "";
|
||||
const qualities = stream?.qualities ?? [];
|
||||
const currentQn = stream?.qn ?? 0;
|
||||
|
||||
const setLive = useLiveStore(s => s.setLive);
|
||||
|
||||
const actualRoomId = room?.roomid ?? id;
|
||||
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
|
||||
{/* TopBar */}
|
||||
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
|
||||
{room?.title ?? "直播间"}
|
||||
</Text>
|
||||
{isLive && hlsUrl ? (
|
||||
<TouchableOpacity
|
||||
style={styles.pipBtn}
|
||||
onPress={() => {
|
||||
setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl);
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="browsers-outline" size={22} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.pipBtn} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Player */}
|
||||
<LivePlayer
|
||||
hlsUrl={hlsUrl}
|
||||
flvUrl={flvUrl}
|
||||
isLive={isLive}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={changeQuality}
|
||||
/>
|
||||
|
||||
{/* TabBar */}
|
||||
<View style={[styles.tabBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("intro")}
|
||||
>
|
||||
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === "intro" && styles.tabActive]}>
|
||||
简介
|
||||
</Text>
|
||||
{tab === "intro" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("danmaku")}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabLabel, { color: theme.textSub }, tab === "danmaku" && styles.tabActive]}
|
||||
>
|
||||
弹幕{danmakus.length > 0 ? ` ${danmakus.length}` : ""}
|
||||
</Text>
|
||||
{tab === "danmaku" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : error ? (
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
) : (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[styles.scroll, tab !== "intro" && styles.hidden]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{room?.title}</Text>
|
||||
<View style={styles.metaRow}>
|
||||
{isLive ? (
|
||||
<View style={styles.livePill}>
|
||||
<View style={styles.liveDot} />
|
||||
<Text style={styles.livePillText}>直播中</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.livePill, styles.offlinePill]}>
|
||||
<Text style={[styles.livePillText, styles.offlinePillText]}>
|
||||
未开播
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.onlineRow}>
|
||||
<Ionicons name="eye-outline" size={13} color="#999" />
|
||||
<Text style={styles.onlineText}>
|
||||
{formatCount(room?.online ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.areaRow}>
|
||||
{room?.parent_area_name ? (
|
||||
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
|
||||
) : null}
|
||||
{room?.area_name ? (
|
||||
<Text style={styles.areaTag}>{room.area_name}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.border }]} />
|
||||
|
||||
{anchor && (
|
||||
<View style={styles.anchorRow}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(anchor.face) }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<Text style={[styles.anchorName, { color: theme.text }]}>{anchor.uname}</Text>
|
||||
<TouchableOpacity style={styles.followBtn}>
|
||||
<Text style={styles.followTxt}>+ 关注</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!room?.description && (
|
||||
<View style={styles.descBox}>
|
||||
<Text style={[styles.descText, { color: theme.text }]}>{room.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={999999}
|
||||
visible
|
||||
onToggle={() => {}}
|
||||
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
|
||||
hideHeader
|
||||
isLive
|
||||
maxItems={500}
|
||||
giftCounts={giftCounts}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
pipBtn: { padding: 4, width: 32, alignItems: 'center' },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
marginLeft: 4,
|
||||
},
|
||||
loader: { marginVertical: 30 },
|
||||
errorText: { textAlign: "center", color: "#f00", padding: 20 },
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
tabItem: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
},
|
||||
tabLabel: { fontSize: 14, fontWeight: "500" },
|
||||
tabActive: { color: "#00AEEC", fontWeight: "700" },
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
height: 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 2,
|
||||
},
|
||||
scroll: { flex: 1 },
|
||||
titleSection: { padding: 14 },
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
livePill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff0f0",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
gap: 4,
|
||||
},
|
||||
offlinePill: { backgroundColor: "#f5f5f5" },
|
||||
liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#f00" },
|
||||
livePillText: { fontSize: 12, color: "#f00", fontWeight: "600" },
|
||||
offlinePillText: { color: "#999" },
|
||||
onlineRow: { flexDirection: "row", alignItems: "center", gap: 3 },
|
||||
onlineText: { fontSize: 12, color: "#999" },
|
||||
areaRow: { flexDirection: "row", gap: 6 },
|
||||
areaTag: {
|
||||
fontSize: 11,
|
||||
color: "#00AEEC",
|
||||
backgroundColor: "#e8f7fd",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginHorizontal: 14,
|
||||
},
|
||||
anchorRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
avatar: { width: 40, height: 40, borderRadius: 20, marginRight: 10 },
|
||||
anchorName: { flex: 1, fontSize: 14, fontWeight: "500" },
|
||||
followBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 14,
|
||||
},
|
||||
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
|
||||
descBox: { padding: 14, paddingTop: 4 },
|
||||
descText: { fontSize: 14, lineHeight: 22 },
|
||||
danmakuFull: { flex: 1 },
|
||||
hidden: { display: "none" },
|
||||
});
|
||||
5
app/live/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Slot } from 'expo-router';
|
||||
|
||||
export default function LiveLayout() {
|
||||
return <Slot />;
|
||||
}
|
||||
389
app/search.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { VideoCard } from '../components/VideoCard';
|
||||
import { useSearch, SearchSort } from '../hooks/useSearch';
|
||||
import { useTheme } from '../utils/theme';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
const SORT_OPTIONS: { key: SearchSort; label: string }[] = [
|
||||
{ key: 'default', label: '综合排序' },
|
||||
{ key: 'pubdate', label: '最新发布' },
|
||||
{ key: 'view', label: '最多播放' },
|
||||
];
|
||||
|
||||
export default function SearchScreen() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
keyword, setKeyword,
|
||||
results, loading, hasMore,
|
||||
search, loadMore,
|
||||
sort, changeSort,
|
||||
history, removeFromHistory, clearHistory,
|
||||
suggestions,
|
||||
hotSearches,
|
||||
} = useSearch();
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const hasResults = results.length > 0;
|
||||
const hasSearched = hasResults || (loading && results.length === 0);
|
||||
|
||||
const handleSearch = useCallback((kw?: string) => {
|
||||
const term = (kw ?? keyword).trim();
|
||||
if (term) {
|
||||
if (kw) setKeyword(kw);
|
||||
search(kw ?? keyword, true);
|
||||
}
|
||||
}, [keyword, search, setKeyword]);
|
||||
|
||||
const handleSuggestionPress = useCallback((value: string) => {
|
||||
setKeyword(value);
|
||||
search(value, true);
|
||||
}, [search, setKeyword]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: VideoItem; index: number }) => {
|
||||
if (index % 2 !== 0) return null;
|
||||
const right = results[index + 1];
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<VideoCard
|
||||
item={item}
|
||||
onPress={() => router.push(`/video/${item.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{right ? (
|
||||
<View style={styles.rightCol}>
|
||||
<VideoCard
|
||||
item={right}
|
||||
onPress={() => router.push(`/video/${right.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.rightCol} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[results, router],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(_: VideoItem, index: number) => String(index),
|
||||
[],
|
||||
);
|
||||
|
||||
// Show pre-search panel (history + hot searches + suggestions)
|
||||
const showPreSearch = !hasSearched && !loading;
|
||||
const showSuggestions = suggestions.length > 0 && keyword.trim().length > 0 && !hasResults;
|
||||
|
||||
const ListHeaderComponent = useCallback(() => {
|
||||
if (!hasResults) return null;
|
||||
return (
|
||||
<View style={[styles.sortBar, { backgroundColor: theme.card }]}>
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
<TouchableOpacity
|
||||
key={opt.key}
|
||||
style={[styles.sortBtn, sort === opt.key && styles.sortBtnActive]}
|
||||
onPress={() => changeSort(opt.key)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.sortBtnText, sort === opt.key && styles.sortBtnTextActive]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}, [hasResults, sort, changeSort, theme.card]);
|
||||
|
||||
const ListEmptyComponent = () => {
|
||||
if (loading) return null;
|
||||
if (!keyword.trim()) return null;
|
||||
return (
|
||||
<View style={styles.emptyBox}>
|
||||
<Ionicons name="search-outline" size={48} color="#ddd" />
|
||||
<Text style={[styles.emptyText, { color: theme.textSub }]}>没有找到相关视频</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
|
||||
{/* Search header */}
|
||||
<View style={[styles.header, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg }]}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder="搜索视频、UP主..."
|
||||
placeholderTextColor="#999"
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
onSubmitEditing={() => handleSearch()}
|
||||
returnKeyType="search"
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{keyword.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setKeyword('')} style={styles.clearBtn}>
|
||||
<Ionicons name="close-circle" size={16} color="#bbb" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.searchBtn} onPress={() => handleSearch()} activeOpacity={0.85}>
|
||||
<Text style={styles.searchBtnText}>搜索</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<View style={[styles.suggestPanel, { backgroundColor: theme.card }]}>
|
||||
{suggestions.map((s, i) => (
|
||||
<TouchableOpacity
|
||||
key={`${s.value}-${i}`}
|
||||
style={[styles.suggestItem, { borderBottomColor: theme.border }]}
|
||||
onPress={() => handleSuggestionPress(s.value)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Ionicons name="search-outline" size={14} color="#bbb" style={styles.suggestIcon} />
|
||||
<Text style={[styles.suggestText, { color: theme.text }]} numberOfLines={1}>{s.value}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pre-search: history + hot searches */}
|
||||
{showPreSearch && !showSuggestions ? (
|
||||
<ScrollView style={styles.preSearch} keyboardShouldPersistTaps="handled">
|
||||
{/* Search history */}
|
||||
{history.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>搜索历史</Text>
|
||||
<TouchableOpacity onPress={clearHistory} activeOpacity={0.85}>
|
||||
<Ionicons name="trash-outline" size={16} color={theme.textSub} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.tagWrap}>
|
||||
{history.map(h => (
|
||||
<TouchableOpacity
|
||||
key={h}
|
||||
style={[styles.tag, { backgroundColor: theme.inputBg }]}
|
||||
onPress={() => handleSearch(h)}
|
||||
onLongPress={() => removeFromHistory(h)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.tagText, { color: theme.text }]} numberOfLines={1}>{h}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hot searches */}
|
||||
{hotSearches.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>热搜榜</Text>
|
||||
{hotSearches.map((item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={item.keyword}
|
||||
style={[styles.hotItem, { borderBottomColor: theme.border }]}
|
||||
onPress={() => handleSearch(item.keyword)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[
|
||||
styles.hotIndex,
|
||||
idx < 3 && styles.hotIndexTop,
|
||||
]}>
|
||||
{idx + 1}
|
||||
</Text>
|
||||
<Text style={[styles.hotText, { color: theme.text }]} numberOfLines={1}>
|
||||
{item.show_name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{history.length === 0 && hotSearches.length === 0 && (
|
||||
<View style={styles.emptyBox}>
|
||||
<Ionicons name="search-outline" size={48} color="#ddd" />
|
||||
<Text style={[styles.emptyText, { color: theme.textSub }]}>输入关键词搜索</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
) : (
|
||||
/* Results list */
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={<ListHeaderComponent />}
|
||||
ListEmptyComponent={<ListEmptyComponent />}
|
||||
ListFooterComponent={
|
||||
loading && results.length > 0 ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator color="#00AEEC" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: '#f4f4f4' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
gap: 6,
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
inputWrap: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: 17,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#212121',
|
||||
padding: 0,
|
||||
},
|
||||
clearBtn: { paddingLeft: 4 },
|
||||
searchBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
|
||||
|
||||
// Sort bar
|
||||
sortBar: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
sortBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 14,
|
||||
},
|
||||
sortBtnActive: {
|
||||
backgroundColor: '#00AEEC',
|
||||
},
|
||||
sortBtnText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
sortBtnTextActive: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Suggestions
|
||||
suggestPanel: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
suggestItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
suggestIcon: { marginRight: 8 },
|
||||
suggestText: { fontSize: 14, flex: 1 },
|
||||
|
||||
// Pre-search
|
||||
preSearch: { flex: 1, paddingHorizontal: 16, paddingTop: 12 },
|
||||
section: { marginBottom: 20 },
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
sectionTitle: { fontSize: 15, fontWeight: '600', color: '#212121', marginBottom: 2 },
|
||||
tagWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
tag: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f0f0f0',
|
||||
maxWidth: '45%',
|
||||
},
|
||||
tagText: { fontSize: 13, color: '#212121' },
|
||||
|
||||
// Hot search list
|
||||
hotItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
hotIndex: {
|
||||
width: 22,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginRight: 10,
|
||||
},
|
||||
hotIndexTop: { color: '#00AEEC' },
|
||||
hotText: { fontSize: 14, flex: 1 },
|
||||
|
||||
// Results
|
||||
listContent: { paddingTop: 0, paddingBottom: 20 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 1,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
leftCol: { flex: 1, marginLeft: 4, marginRight: 2 },
|
||||
rightCol: { flex: 1, marginLeft: 2, marginRight: 4 },
|
||||
emptyBox: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 80,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: { fontSize: 14, color: '#bbb' },
|
||||
footer: { height: 48, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
240
app/settings.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTheme } from '../utils/theme';
|
||||
import { useCheckUpdate } from '../hooks/useCheckUpdate';
|
||||
import { getImageCacheSize, clearImageCache, formatBytes } from '../utils/cache';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, logout } = useAuthStore();
|
||||
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
|
||||
const theme = useTheme();
|
||||
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
|
||||
const [cacheSize, setCacheSize] = useState<number | null>(null);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
|
||||
const refreshCacheSize = useCallback(async () => {
|
||||
const size = await getImageCacheSize();
|
||||
setCacheSize(size);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCacheSize();
|
||||
}, []);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
Alert.alert('清除缓存', '确定要清除所有缓存吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '清除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setClearingCache(true);
|
||||
await clearImageCache();
|
||||
setClearingCache(false);
|
||||
setCacheSize(0);
|
||||
Alert.alert('已完成', '缓存已清除');
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
|
||||
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.topTitle, { color: theme.text }]}>设置</Text>
|
||||
<View style={styles.spacer} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>版本信息</Text>
|
||||
<View style={styles.versionRow}>
|
||||
<Text style={[styles.versionLabel, { color: theme.text }]}>当前版本</Text>
|
||||
<Text style={[styles.versionValue, { color: theme.textSub }]}>v{currentVersion}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>更新</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.updateBtn}
|
||||
onPress={checkUpdate}
|
||||
activeOpacity={0.7}
|
||||
disabled={isChecking || downloadProgress !== null}
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#00AEEC" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.updateBtnText}>检查中...</Text>
|
||||
</>
|
||||
) : downloadProgress !== null ? (
|
||||
<Text style={styles.updateBtnText}>下载中 {downloadProgress}%</Text>
|
||||
) : (
|
||||
<Text style={styles.updateBtnText}>检查更新</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>外观</Text>
|
||||
<View style={styles.optionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, { backgroundColor: theme.inputBg }, !darkMode && styles.optionActive]}
|
||||
onPress={() => setDarkMode(false)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, { color: theme.text }, !darkMode && styles.optionTextActive]}>浅色</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, { backgroundColor: theme.inputBg }, darkMode && styles.optionActive]}
|
||||
onPress={() => setDarkMode(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, { color: theme.text }, darkMode && styles.optionTextActive]}>深色</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>流量</Text>
|
||||
<View style={styles.optionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, { backgroundColor: theme.inputBg }, !trafficSaving && styles.optionActive]}
|
||||
onPress={() => setTrafficSaving(false)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, { color: theme.text }, !trafficSaving && styles.optionTextActive]}>标准</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, { backgroundColor: theme.inputBg }, trafficSaving && styles.optionActive]}
|
||||
onPress={() => setTrafficSaving(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, { color: theme.text }, trafficSaving && styles.optionTextActive]}>节流</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{trafficSaving && (
|
||||
<Text style={[styles.hint, { color: theme.textSub }]}>
|
||||
封面低画质 · 首页视频不自动播放 · 视频默认 360p
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.textSub }]}>存储</Text>
|
||||
<View style={styles.cacheRow}>
|
||||
<View>
|
||||
<Text style={[styles.cacheLabel, { color: theme.text }]}>缓存大小</Text>
|
||||
<Text style={[styles.cacheValue, { color: theme.textSub }]}>
|
||||
{cacheSize === null ? '计算中...' : formatBytes(cacheSize)}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearBtn, clearingCache && { opacity: 0.5 }]}
|
||||
onPress={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{clearingCache
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={styles.clearBtnText}>清除缓存</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoggedIn && (
|
||||
<TouchableOpacity style={[styles.logoutBtn, { borderColor: theme.danger }]} onPress={handleLogout} activeOpacity={0.8}>
|
||||
<Text style={[styles.logoutText, { color: theme.danger }]}>退出登录</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
backBtn: { padding: 4, width: 32 },
|
||||
spacer: { width: 32 },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
sectionLabel: { fontSize: 13, marginBottom: 10 },
|
||||
optionRow: { flexDirection: 'row', gap: 10 },
|
||||
option: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 16,
|
||||
},
|
||||
optionActive: { backgroundColor: '#00AEEC' },
|
||||
optionText: { fontSize: 13, fontWeight: '500' },
|
||||
optionTextActive: { color: '#fff', fontWeight: '600' },
|
||||
hint: { fontSize: 12, marginTop: 8 },
|
||||
versionRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
versionLabel: { fontSize: 14 },
|
||||
versionValue: { fontSize: 14 },
|
||||
updateBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
|
||||
cacheRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cacheLabel: { fontSize: 14 },
|
||||
cacheValue: { fontSize: 12, marginTop: 2 },
|
||||
clearBtn: {
|
||||
backgroundColor: '#00AEEC',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
clearBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||
logoutBtn: {
|
||||
margin: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutText: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
683
app/video/[bvid].tsx
Normal file
@@ -0,0 +1,683 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { VideoPlayer } from "../../components/VideoPlayer";
|
||||
import { CommentItem } from "../../components/CommentItem";
|
||||
import { getDanmaku, getUploaderStat } from "../../services/api";
|
||||
import { DanmakuItem } from "../../services/types";
|
||||
import DanmakuList from "../../components/DanmakuList";
|
||||
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
||||
import { useComments } from "../../hooks/useComments";
|
||||
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
|
||||
import { formatCount, formatDuration } from "../../utils/format";
|
||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||
import { DownloadSheet } from "../../components/DownloadSheet";
|
||||
import { useTheme } from "../../utils/theme";
|
||||
import { useLiveStore } from "../../store/liveStore";
|
||||
|
||||
type Tab = "intro" | "comments" | "danmaku";
|
||||
|
||||
export default function VideoDetailScreen() {
|
||||
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
// 进入视频详情页时立即清除直播小窗
|
||||
useLayoutEffect(() => {
|
||||
useLiveStore.getState().clearLive();
|
||||
}, []);
|
||||
const {
|
||||
video,
|
||||
playData,
|
||||
loading: videoLoading,
|
||||
qualities,
|
||||
currentQn,
|
||||
changeQuality,
|
||||
} = useVideoDetail(bvid as string);
|
||||
const [commentSort, setCommentSort] = useState<0 | 2>(2);
|
||||
const {
|
||||
comments,
|
||||
loading: cmtLoading,
|
||||
hasMore: cmtHasMore,
|
||||
load: loadComments,
|
||||
} = useComments(video?.aid ?? 0, commentSort);
|
||||
const [tab, setTab] = useState<Tab>("intro");
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const [uploaderStat, setUploaderStat] = useState<{
|
||||
follower: number;
|
||||
archiveCount: number;
|
||||
} | null>(null);
|
||||
const {
|
||||
videos: relatedVideos,
|
||||
loading: relatedLoading,
|
||||
load: loadRelated,
|
||||
} = useRelatedVideos(bvid as string);
|
||||
|
||||
useEffect(() => {
|
||||
loadRelated();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (video?.aid) loadComments();
|
||||
}, [video?.aid, commentSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video?.cid) return;
|
||||
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
|
||||
}, [video?.cid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video?.owner?.mid) return;
|
||||
getUploaderStat(video.owner.mid)
|
||||
.then(setUploaderStat)
|
||||
.catch(() => {});
|
||||
}, [video?.owner?.mid]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
|
||||
{/* TopBar */}
|
||||
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={[styles.topTitle, { color: theme.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{video?.title ?? "视频详情"}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.miniBtn}
|
||||
onPress={() => setShowDownload(true)}
|
||||
>
|
||||
<Ionicons
|
||||
name="cloud-download-outline"
|
||||
size={22}
|
||||
color={theme.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Video player — fixed 16:9 */}
|
||||
<VideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={changeQuality}
|
||||
bvid={bvid as string}
|
||||
cid={video?.cid}
|
||||
danmakus={danmakus}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
/>
|
||||
<DownloadSheet
|
||||
visible={showDownload}
|
||||
onClose={() => setShowDownload(false)}
|
||||
bvid={bvid as string}
|
||||
cid={video?.cid ?? 0}
|
||||
title={video?.title ?? ""}
|
||||
cover={video?.pic ?? ""}
|
||||
qualities={qualities}
|
||||
/>
|
||||
|
||||
{/* TabBar */}
|
||||
{video && (
|
||||
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("intro")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: theme.textSub },
|
||||
tab === "intro" && styles.tabActive,
|
||||
]}
|
||||
>
|
||||
简介
|
||||
</Text>
|
||||
{tab === "intro" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("comments")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: theme.textSub },
|
||||
tab === "comments" && styles.tabActive,
|
||||
]}
|
||||
>
|
||||
评论
|
||||
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
|
||||
</Text>
|
||||
{tab === "comments" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("danmaku")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: theme.textSub },
|
||||
tab === "danmaku" && styles.tabActive,
|
||||
]}
|
||||
>
|
||||
弹幕
|
||||
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
|
||||
</Text>
|
||||
{tab === "danmaku" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{videoLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : video ? (
|
||||
<>
|
||||
{tab === "intro" && (
|
||||
<FlatList<import("../../services/types").VideoItem>
|
||||
style={styles.tabScroll}
|
||||
data={relatedVideos}
|
||||
keyExtractor={(item) => item.bvid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.upRow}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(video.owner.face) }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.upInfo}>
|
||||
<Text style={[styles.upName, { color: theme.text }]}>
|
||||
{video.owner.name}
|
||||
</Text>
|
||||
{uploaderStat && (
|
||||
<Text style={styles.upStat}>
|
||||
{formatCount(uploaderStat.follower)}粉丝 ·{" "}
|
||||
{formatCount(uploaderStat.archiveCount)}视频
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.followBtn}>
|
||||
<Text style={styles.followTxt}>查看主页</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={[
|
||||
styles.titleSection,
|
||||
{ borderBottomColor: theme.border },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{video.title}
|
||||
</Text>
|
||||
<Text style={[styles.subTitle, { color: theme.text }]}>
|
||||
{video.desc || "暂无简介"}
|
||||
</Text>
|
||||
<View style={styles.statsRow}>
|
||||
<StatBadge icon="play" count={video.stat.view} />
|
||||
<StatBadge icon="heart" count={video.stat.like} />
|
||||
<StatBadge icon="star" count={video.stat.favorite} />
|
||||
<StatBadge icon="chatbubble" count={video.stat.reply} />
|
||||
</View>
|
||||
</View>
|
||||
{video.ugc_season && (
|
||||
<SeasonSection
|
||||
season={video.ugc_season}
|
||||
currentBvid={bvid as string}
|
||||
onEpisodePress={(epBvid) =>
|
||||
router.replace(`/video/${epBvid}`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.relatedHeader,
|
||||
{
|
||||
backgroundColor: theme.card,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.relatedHeaderText, { color: theme.text }]}
|
||||
>
|
||||
推荐视频
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.relatedCard,
|
||||
{
|
||||
backgroundColor: theme.card,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.replace(`/video/${item.bvid}` as any)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.relatedThumbWrap,
|
||||
{ backgroundColor: theme.card },
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
style={styles.relatedThumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.relatedDuration}>
|
||||
<Text style={styles.relatedDurationText}>
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.relatedInfo}>
|
||||
<Text
|
||||
style={[styles.relatedTitle, { color: theme.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Text style={styles.relatedOwner} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
<Text style={styles.relatedView}>
|
||||
{formatCount(item.stat?.view ?? 0)} 播放
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
relatedLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
relatedLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "comments" && (
|
||||
<FlatList
|
||||
style={styles.tabScroll}
|
||||
data={comments}
|
||||
keyExtractor={(c) => String(c.rpid)}
|
||||
renderItem={({ item }) => <CommentItem item={item} />}
|
||||
onEndReached={() => {
|
||||
if (cmtHasMore && !cmtLoading) loadComments();
|
||||
}}
|
||||
onEndReachedThreshold={0.3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
style={[styles.sortRow, { borderBottomColor: theme.border }]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sortBtn,
|
||||
commentSort === 2 && styles.sortBtnActive,
|
||||
]}
|
||||
onPress={() => setCommentSort(2)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortBtnTxt,
|
||||
commentSort === 2 && styles.sortBtnTxtActive,
|
||||
]}
|
||||
>
|
||||
热门
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sortBtn,
|
||||
commentSort === 0 && styles.sortBtnActive,
|
||||
]}
|
||||
onPress={() => setCommentSort(0)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortBtnTxt,
|
||||
commentSort === 0 && styles.sortBtnTxtActive,
|
||||
]}
|
||||
>
|
||||
最新
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
cmtLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : !cmtHasMore && comments.length > 0 ? (
|
||||
<Text style={styles.emptyTxt}>已加载全部评论</Text>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
!cmtLoading ? (
|
||||
<Text style={styles.emptyTxt}>暂无评论</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */}
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
visible={tab === "danmaku"}
|
||||
onToggle={() => {}}
|
||||
hideHeader={true}
|
||||
style={[
|
||||
styles.danmakuTab,
|
||||
tab !== "danmaku" && { display: "none" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBadge({ icon, count }: { icon: string; count: number }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Ionicons name={icon as any} size={14} color="#999" />
|
||||
<Text style={styles.statText}>{formatCount(count)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonSection({
|
||||
season,
|
||||
currentBvid,
|
||||
onEpisodePress,
|
||||
}: {
|
||||
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>;
|
||||
currentBvid: string;
|
||||
onEpisodePress: (bvid: string) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const episodes = season.sections?.[0]?.episodes ?? [];
|
||||
const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex <= 0 || episodes.length === 0) return;
|
||||
const t = setTimeout(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: currentIndex,
|
||||
viewPosition: 0.5,
|
||||
animated: false,
|
||||
});
|
||||
}, 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [currentIndex, episodes.length]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.seasonBox,
|
||||
{ borderTopColor: theme.border, backgroundColor: theme.card },
|
||||
]}
|
||||
>
|
||||
<View style={styles.seasonHeader}>
|
||||
<Text style={[styles.seasonTitle, { color: theme.text }]}>
|
||||
合集 · {season.title}
|
||||
</Text>
|
||||
<Text style={styles.seasonCount}>{season.ep_count}个视频</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color="#999" />
|
||||
</View>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={episodes}
|
||||
keyExtractor={(ep) => ep.bvid}
|
||||
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: 130,
|
||||
offset: 12 + index * 130,
|
||||
index,
|
||||
})}
|
||||
onScrollToIndexFailed={() => {}}
|
||||
renderItem={({ item: ep, index }) => {
|
||||
const isCurrent = ep.bvid === currentBvid;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.epCard,
|
||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||
isCurrent && styles.epCardActive,
|
||||
]}
|
||||
onPress={() => !isCurrent && onEpisodePress(ep.bvid)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{ep.arc?.pic && (
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(ep.arc.pic) }}
|
||||
style={[styles.epThumb, { backgroundColor: theme.card }]}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>
|
||||
第{index + 1}集
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.epTitle, { color: theme.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{ep.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
marginLeft: 4,
|
||||
},
|
||||
miniBtn: { padding: 4 },
|
||||
loader: { marginVertical: 30 },
|
||||
titleSection: {
|
||||
padding: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statsRow: { flexDirection: "row", gap: 16 },
|
||||
stat: { flexDirection: "row", alignItems: "center", gap: 3 },
|
||||
statText: { fontSize: 12, color: "#999" },
|
||||
upRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingBottom: 0,
|
||||
paddingTop: 12,
|
||||
},
|
||||
avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 },
|
||||
upInfo: { flex: 1, justifyContent: "center" },
|
||||
upName: { fontSize: 14, fontWeight: "500" },
|
||||
upStat: { fontSize: 11, color: "#999", marginTop: 2 },
|
||||
followBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 14,
|
||||
},
|
||||
followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
|
||||
seasonBox: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
seasonHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 8,
|
||||
gap: 4,
|
||||
},
|
||||
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" },
|
||||
seasonCount: { fontSize: 12, color: "#999" },
|
||||
epCard: {
|
||||
width: 120,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 },
|
||||
epThumb: { width: 120, height: 68 },
|
||||
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
|
||||
epNumActive: { color: "#00AEEC", fontWeight: "600" },
|
||||
epTitle: {
|
||||
fontSize: 12,
|
||||
paddingHorizontal: 6,
|
||||
paddingBottom: 6,
|
||||
lineHeight: 16,
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingLeft: 3,
|
||||
},
|
||||
tabItem: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
position: "relative",
|
||||
},
|
||||
tabLabel: { fontSize: 13 },
|
||||
tabActive: { color: "#00AEEC" },
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
height: 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 1,
|
||||
},
|
||||
tabScroll: { flex: 1 },
|
||||
descBox: { padding: 16 },
|
||||
descText: { fontSize: 14, lineHeight: 22 },
|
||||
danmakuTab: { flex: 1 },
|
||||
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
|
||||
relatedHeader: {
|
||||
paddingLeft: 13,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
relatedHeaderText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600" as const,
|
||||
},
|
||||
relatedCard: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: 10,
|
||||
},
|
||||
relatedThumbWrap: {
|
||||
position: "relative",
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
},
|
||||
relatedThumb: { width: 120, height: 68 },
|
||||
relatedDuration: {
|
||||
position: "absolute",
|
||||
bottom: 3,
|
||||
right: 3,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
relatedDurationText: { color: "#fff", fontSize: 10 },
|
||||
relatedInfo: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
},
|
||||
relatedTitle: { fontSize: 13, lineHeight: 18 },
|
||||
relatedOwner: { fontSize: 12, color: "#999" },
|
||||
relatedView: { fontSize: 11, color: "#bbb" },
|
||||
sortRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
sortBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
sortBtnActive: { backgroundColor: "#00AEEC" },
|
||||
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
|
||||
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
|
||||
});
|
||||
5
app/video/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Slot } from 'expo-router';
|
||||
|
||||
export default function VideoLayout() {
|
||||
return <Slot />;
|
||||
}
|
||||
BIN
assets/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
22
build-apk.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
|
||||
$env:ANDROID_HOME = "C:\Users\Administrator\AppData\Local\Android\Sdk"
|
||||
$env:PATH = "$env:JAVA_HOME\bin;C:\nvm4w\nodejs;$env:ANDROID_HOME\platform-tools;$env:PATH"
|
||||
|
||||
# Create short path alias to avoid 260-char limit
|
||||
subst Z: "C:\Users\Administrator\Desktop\claude code studly\reactJKVideoApp" 2>$null
|
||||
|
||||
Set-Location "Z:\android"
|
||||
.\gradlew.bat assembleDebug --no-daemon
|
||||
|
||||
Write-Host ""
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$apk = Get-ChildItem "Z:\android\app\build\outputs\apk\debug\*.apk" | Select-Object -First 1
|
||||
Write-Host "SUCCESS! APK: $($apk.FullName)" -ForegroundColor Green
|
||||
# Copy to Desktop
|
||||
Copy-Item $apk.FullName "C:\Users\Administrator\Desktop\JKVideo-debug.apk"
|
||||
Write-Host "Copied to Desktop: JKVideo-debug.apk" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "Build FAILED" -ForegroundColor Red
|
||||
}
|
||||
|
||||
subst Z: /D 2>$null
|
||||
426
components/BigVideoCard.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
// components/BigVideoCard.tsx
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
Animated,
|
||||
PanResponder,
|
||||
} from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { buildDashMpdUri } from "../utils/dash";
|
||||
import { getPlayUrl, getVideoDetail } from "../services/api";
|
||||
import { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { useLiveStore } from "../store/liveStore";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import type { VideoItem } from "../services/types";
|
||||
|
||||
const HEADERS = {};
|
||||
|
||||
const BAR_H = 3;
|
||||
// Minimum horizontal distance (px) before treating the gesture as a seek
|
||||
const SWIPE_THRESHOLD = 8;
|
||||
// Full swipe across the screen = seek this many seconds
|
||||
const SWIPE_SECONDS = 90;
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
isScrolling?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
item,
|
||||
isVisible,
|
||||
isScrolling,
|
||||
onPress,
|
||||
}: Props) {
|
||||
const { width: SCREEN_W } = useWindowDimensions();
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
const liveActive = useLiveStore(s => s.isActive);
|
||||
const theme = useTheme();
|
||||
const THUMB_H = SCREEN_W * 0.5625;
|
||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>();
|
||||
const [isDash, setIsDash] = useState(false);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const thumbOpacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
|
||||
// Refs for PanResponder (avoid stale closures)
|
||||
const currentTimeRef = useRef(0);
|
||||
const durationRef = useRef(0);
|
||||
const seekingRef = useRef(false);
|
||||
const [seekLabel, setSeekLabel] = useState<string | null>(null);
|
||||
|
||||
// Reset video state when the item changes
|
||||
useEffect(() => {
|
||||
setVideoUrl(undefined);
|
||||
setIsDash(false);
|
||||
setPaused(true);
|
||||
setMuted(true);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setBuffered(0);
|
||||
thumbOpacity.setValue(1);
|
||||
}, [item.bvid]);
|
||||
|
||||
// Preload: fetch play URL on mount (before card is visible)
|
||||
useEffect(() => {
|
||||
if (videoUrl || trafficSaving || liveActive) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
let cid = item.cid;
|
||||
if (!cid) {
|
||||
const detail = await getVideoDetail(item.bvid);
|
||||
cid = detail.cid ?? detail.pages?.[0]?.cid;
|
||||
}
|
||||
if (!cid) {
|
||||
console.warn('BigVideoCard: no cid available for', item.bvid);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
const playData = await getPlayUrl(item.bvid, cid, 16);
|
||||
if (cancelled) return;
|
||||
if (playData.dash) {
|
||||
if (!cancelled) setIsDash(true);
|
||||
try {
|
||||
const mpdUri = await buildDashMpdUri(playData, 16);
|
||||
if (!cancelled) setVideoUrl(mpdUri);
|
||||
} catch {
|
||||
if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl);
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) setVideoUrl(playData.durl?.[0]?.url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("BigVideoCard: failed to load play URL", e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.bvid]);
|
||||
|
||||
// Pause/resume based on visibility and scroll state
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
if (!isVisible || trafficSaving || liveActive) {
|
||||
setPaused(true);
|
||||
setMuted(true);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else if (isScrolling) {
|
||||
setPaused(true);
|
||||
} else {
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]);
|
||||
|
||||
const handleVideoReady = () => {
|
||||
if (!isVisible || isScrolling || trafficSaving || liveActive) return;
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
currentTimeRef.current = currentTime;
|
||||
}, [currentTime]);
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// Horizontal swipe to seek
|
||||
const swipeStartTime = useRef(0);
|
||||
const screenWRef = useRef(SCREEN_W);
|
||||
useEffect(() => {
|
||||
screenWRef.current = SCREEN_W;
|
||||
}, [SCREEN_W]);
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: (_, gs) =>
|
||||
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
|
||||
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
|
||||
onPanResponderGrant: () => {
|
||||
seekingRef.current = true;
|
||||
swipeStartTime.current = currentTimeRef.current;
|
||||
},
|
||||
onMoveShouldSetPanResponderCapture: (_, gs) =>
|
||||
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
|
||||
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (durationRef.current <= 0) return;
|
||||
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
|
||||
const target = clamp(
|
||||
swipeStartTime.current + delta,
|
||||
0,
|
||||
durationRef.current,
|
||||
);
|
||||
setSeekLabel(formatDuration(Math.floor(target)));
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
if (durationRef.current > 0) {
|
||||
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
|
||||
const target = clamp(
|
||||
swipeStartTime.current + delta,
|
||||
0,
|
||||
durationRef.current,
|
||||
);
|
||||
videoRef.current?.seek(target);
|
||||
setCurrentTime(target);
|
||||
}
|
||||
seekingRef.current = false;
|
||||
setSeekLabel(null);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
seekingRef.current = false;
|
||||
setSeekLabel(null);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
{/* Media area */}
|
||||
<View style={[mediaDimensions, { position: "relative" }]}>
|
||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||
{videoUrl && !liveActive && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={
|
||||
isDash
|
||||
? { uri: videoUrl, type: "mpd", headers: HEADERS }
|
||||
: { uri: videoUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
muted={muted}
|
||||
paused={paused || seekingRef.current}
|
||||
repeat
|
||||
controls={false}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
if (!seekingRef.current) setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail — on top of Video, fades out once video is ready */}
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||
style={mediaDimensions}
|
||||
contentFit="cover"
|
||||
recyclingKey={item.bvid}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Swipe gesture layer */}
|
||||
<View
|
||||
style={[StyleSheet.absoluteFill, { zIndex: 5 }]}
|
||||
{...panResponder.panHandlers}
|
||||
/>
|
||||
|
||||
{/* Seek time label */}
|
||||
{seekLabel && (
|
||||
<View style={styles.seekBadge}>
|
||||
<Text style={styles.seekText}>
|
||||
{seekLabel} / {formatDuration(durationRef.current)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="play" size={11} color="#fff" />
|
||||
<Text style={styles.metaText}>
|
||||
{formatCount(item.stat?.view ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Mute toggle — visible only when video is playing */}
|
||||
{videoUrl && !paused && (
|
||||
<TouchableOpacity
|
||||
style={styles.muteBtn}
|
||||
onPress={() => setMuted((m) => !m)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={muted ? "volume-mute" : "volume-high"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress bar between video and info — always rendered to avoid height jump */}
|
||||
<View style={styles.progressTrack}>
|
||||
{videoUrl && duration > 0 && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.progressLayer,
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(0,174,236,0.25)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.progressLayer,
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{/* Info */}
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginHorizontal: 4,
|
||||
marginBottom: 6,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
},
|
||||
durationBadge: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
durationText: { color: "#fff", fontSize: 10 },
|
||||
muteBtn: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 6,
|
||||
},
|
||||
seekBadge: {
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
alignSelf: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
zIndex: 4,
|
||||
},
|
||||
seekText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
progressTrack: {
|
||||
height: BAR_H,
|
||||
backgroundColor: "rgba(0,0,0,0.08)",
|
||||
position: "relative",
|
||||
},
|
||||
progressLayer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: BAR_H,
|
||||
},
|
||||
info: { padding: 8 },
|
||||
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
|
||||
meta: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 5,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 0,
|
||||
gap: 2,
|
||||
zIndex: 2,
|
||||
},
|
||||
metaText: { fontSize: 10, color: "#fff" },
|
||||
owner: { fontSize: 11, color: "#999", marginTop: 2 },
|
||||
});
|
||||
42
components/CommentItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { Comment } from '../services/types';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { useTheme } from '../utils/theme';
|
||||
|
||||
interface Props { item: Comment; }
|
||||
|
||||
export function CommentItem({ item }: Props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={[styles.row, { borderBottomColor: theme.border }]}>
|
||||
<Image source={{ uri: proxyImageUrl(item.member.avatar) }} style={styles.avatar} />
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.username}>{item.member.uname}</Text>
|
||||
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.time, { color: theme.textSub }]}>{formatTime(item.ctime)}</Text>
|
||||
<View style={styles.likeRow}>
|
||||
<Ionicons name="thumbs-up-outline" size={12} color={theme.textSub} />
|
||||
<Text style={[styles.likeCount, { color: theme.textSub }]}>{item.like > 0 ? item.like : ''}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
avatar: { width: 34, height: 34, borderRadius: 17, marginRight: 10 },
|
||||
content: { flex: 1 },
|
||||
username: { fontSize: 12, color: '#00AEEC', marginBottom: 3 },
|
||||
message: { fontSize: 14, color: '#212121', lineHeight: 20 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||
time: { fontSize: 11, color: '#bbb' },
|
||||
likeRow: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||
likeCount: { fontSize: 11, color: '#999' },
|
||||
});
|
||||
604
components/DanmakuList.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { DanmakuItem } from "../services/types";
|
||||
import { danmakuColorToCss } from "../utils/danmaku";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
interface Props {
|
||||
danmakus: DanmakuItem[];
|
||||
currentTime: number;
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
style?: object | object[];
|
||||
hideHeader?: boolean;
|
||||
isLive?: boolean;
|
||||
maxItems?: number;
|
||||
giftCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DisplayedDanmaku extends DanmakuItem {
|
||||
_key: number;
|
||||
_fadeAnim: Animated.Value;
|
||||
}
|
||||
|
||||
const DRIP_INTERVAL = 250;
|
||||
const FAST_DRIP_INTERVAL = 100;
|
||||
const QUEUE_FAST_THRESHOLD = 50;
|
||||
const SEEK_THRESHOLD = 2;
|
||||
|
||||
// ─── Animated.Value 对象池,减少频繁创建/GC ──────────────────────────────────
|
||||
const animPool: Animated.Value[] = [];
|
||||
const POOL_MAX = 64;
|
||||
|
||||
function acquireAnim(): Animated.Value {
|
||||
const v = animPool.pop();
|
||||
if (v) { v.setValue(0); return v; }
|
||||
return new Animated.Value(0);
|
||||
}
|
||||
|
||||
function releaseAnims(items: DisplayedDanmaku[]) {
|
||||
for (const item of items) {
|
||||
if (animPool.length < POOL_MAX) {
|
||||
animPool.push(item._fadeAnim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
|
||||
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
|
||||
1: { text: "总督", color: "#E13979" },
|
||||
2: { text: "提督", color: "#7B68EE" },
|
||||
3: { text: "舰长", color: "#00D1F1" },
|
||||
};
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatLiveTime(timeline?: string): string {
|
||||
if (!timeline) return "";
|
||||
const parts = timeline.split(" ");
|
||||
return parts[1]?.slice(0, 5) ?? ""; // "HH:MM"
|
||||
}
|
||||
|
||||
export default function DanmakuList({
|
||||
danmakus,
|
||||
currentTime,
|
||||
visible,
|
||||
onToggle,
|
||||
style,
|
||||
hideHeader,
|
||||
isLive,
|
||||
maxItems = 100,
|
||||
giftCounts,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
|
||||
const [unseenCount, setUnseenCount] = useState(0);
|
||||
|
||||
const queueRef = useRef<DanmakuItem[]>([]);
|
||||
const lastTimeRef = useRef(0);
|
||||
const processedIndexRef = useRef(0);
|
||||
const keyCounterRef = useRef(0);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const danmakusRef = useRef(danmakus);
|
||||
|
||||
// Detect changes in the danmakus array
|
||||
useEffect(() => {
|
||||
const prev = danmakusRef.current;
|
||||
if (prev === danmakus) return;
|
||||
danmakusRef.current = danmakus;
|
||||
|
||||
if (danmakus.length === 0) {
|
||||
queueRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
setDisplayedItems([]);
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
const newStart = processedIndexRef.current;
|
||||
if (danmakus.length > newStart) {
|
||||
queueRef.current.push(...danmakus.slice(newStart));
|
||||
processedIndexRef.current = danmakus.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
queueRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
setDisplayedItems([]);
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
}, [danmakus, isLive]);
|
||||
|
||||
// Watch currentTime — only used in video mode
|
||||
useEffect(() => {
|
||||
if (danmakus.length === 0 || isLive) return;
|
||||
|
||||
const prevTime = lastTimeRef.current;
|
||||
lastTimeRef.current = currentTime;
|
||||
|
||||
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
|
||||
queueRef.current = [];
|
||||
processedIndexRef.current = 0;
|
||||
setDisplayedItems([]);
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
|
||||
const catchUp = danmakus.filter((d) => d.time <= currentTime);
|
||||
const tail = catchUp.slice(-20);
|
||||
queueRef.current = tail;
|
||||
processedIndexRef.current = danmakus.findIndex(
|
||||
(d) => d.time > currentTime,
|
||||
);
|
||||
if (processedIndexRef.current === -1) {
|
||||
processedIndexRef.current = danmakus.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = danmakus;
|
||||
let i = processedIndexRef.current;
|
||||
while (i < sorted.length && sorted[i].time <= currentTime) {
|
||||
queueRef.current.push(sorted[i]);
|
||||
i++;
|
||||
}
|
||||
processedIndexRef.current = i;
|
||||
}, [currentTime, danmakus, isLive]);
|
||||
|
||||
// Drip interval — always running so queue is consumed even when tab is hidden
|
||||
useEffect(() => {
|
||||
const id = setInterval(
|
||||
() => {
|
||||
if (queueRef.current.length === 0) return;
|
||||
|
||||
const item = queueRef.current.shift()!;
|
||||
const fadeAnim = acquireAnim();
|
||||
const displayed: DisplayedDanmaku = {
|
||||
...item,
|
||||
_key: keyCounterRef.current++,
|
||||
_fadeAnim: fadeAnim,
|
||||
};
|
||||
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
setDisplayedItems((prev) => {
|
||||
const next = [...prev, displayed];
|
||||
if (next.length > maxItems) {
|
||||
const trimCount = next.length - Math.floor(maxItems / 2);
|
||||
const trimmed = next.slice(trimCount);
|
||||
releaseAnims(next.slice(0, trimCount));
|
||||
return trimmed;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isAtBottomRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
} else {
|
||||
setUnseenCount((c) => c + 1);
|
||||
}
|
||||
},
|
||||
queueRef.current.length > QUEUE_FAST_THRESHOLD
|
||||
? FAST_DRIP_INTERVAL
|
||||
: DRIP_INTERVAL,
|
||||
);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
||||
const distanceFromBottom =
|
||||
contentSize.height - layoutMeasurement.height - contentOffset.y;
|
||||
isAtBottomRef.current = distanceFromBottom < 40;
|
||||
if (isAtBottomRef.current) setUnseenCount(0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
isAtBottomRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handlePillPress = useCallback(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
}, []);
|
||||
|
||||
// ─── Live mode render (live chat) ─────────────────────────────────────
|
||||
const renderLiveItem = useCallback(
|
||||
({ item }: { item: DisplayedDanmaku }) => {
|
||||
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
|
||||
const timeStr = formatLiveTime(item.timeline);
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
liveStyles.row,
|
||||
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
|
||||
]}
|
||||
>
|
||||
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
||||
<View style={liveStyles.msgBody}>
|
||||
{guard && (
|
||||
<View
|
||||
style={[liveStyles.guardTag, { backgroundColor: guard.color }]}
|
||||
>
|
||||
<Text style={liveStyles.guardTagText}>{guard.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.isAdmin && (
|
||||
<View
|
||||
style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}
|
||||
>
|
||||
<Text style={liveStyles.guardTagText}>房管</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.medalLevel != null && item.medalName && (
|
||||
<View style={liveStyles.medalTag}>
|
||||
<Text style={liveStyles.medalName}>{item.medalName}</Text>
|
||||
<View style={liveStyles.medalLvBox}>
|
||||
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
|
||||
{item.medalLevel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<Text style={liveStyles.uname} numberOfLines={1}>
|
||||
{item.uname ?? "匿名"}
|
||||
</Text>
|
||||
<Text style={liveStyles.colon}>:</Text>
|
||||
<Text
|
||||
style={[liveStyles.text, { color: theme.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
[theme],
|
||||
);
|
||||
|
||||
// ─── Video mode render (original bubble) ───────────────────────────────────
|
||||
const renderVideoItem = useCallback(
|
||||
({ item }: { item: DisplayedDanmaku }) => {
|
||||
const dotColor = danmakuColorToCss(item.color);
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bubble,
|
||||
{ opacity: item._fadeAnim, backgroundColor: theme.bg },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.bubbleText, { color: theme.text }]}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{item.text}
|
||||
</Text>
|
||||
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
[theme],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: DisplayedDanmaku) => String(item._key),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.card, borderTopColor: theme.border },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<TouchableOpacity
|
||||
style={styles.header}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={visible ? "chatbubbles" : "chatbubbles-outline"}
|
||||
size={16}
|
||||
color="#00AEEC"
|
||||
/>
|
||||
<Text style={styles.headerText}>
|
||||
弹幕 {danmakus.length > 0 ? `(${danmakus.length})` : ""}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={visible ? "chevron-up" : "chevron-down"}
|
||||
size={14}
|
||||
color="#999"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{visible && (
|
||||
<View style={styles.listWrapper}>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={displayedItems}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={isLive ? renderLiveItem : renderVideoItem}
|
||||
style={[
|
||||
isLive ? liveStyles.list : styles.list,
|
||||
{ backgroundColor: theme.card },
|
||||
]}
|
||||
contentContainerStyle={
|
||||
isLive ? liveStyles.listContent : styles.listContent
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
scrollEventThrottle={16}
|
||||
removeClippedSubviews={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.empty}>
|
||||
{danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
{unseenCount > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.pill}
|
||||
onPress={handlePillPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.pillText}>{unseenCount} 条新弹幕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Video mode styles ────────────────────────────────────────────────────────
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#fff",
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#eee",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 6,
|
||||
},
|
||||
headerText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: "#212121",
|
||||
fontWeight: "500",
|
||||
},
|
||||
listWrapper: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
bubble: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
backgroundColor: "#f8f8f8",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
marginVertical: 2,
|
||||
gap: 8,
|
||||
},
|
||||
bubbleText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: "#333",
|
||||
lineHeight: 18,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
color: "#bbb",
|
||||
marginTop: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
pill: {
|
||||
position: "absolute",
|
||||
bottom: 8,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
pillText: {
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
empty: {
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
textAlign: "center",
|
||||
paddingVertical: 20,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Live mode styles (live chat) ────────────────────────────────────────
|
||||
const liveStyles = StyleSheet.create({
|
||||
list: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f9f9fb",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 5,
|
||||
},
|
||||
time: {
|
||||
fontSize: 10,
|
||||
color: "#c0c0c0",
|
||||
width: 34,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
msgBody: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
},
|
||||
guardTag: {
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
marginRight: 4,
|
||||
},
|
||||
guardTagText: {
|
||||
color: "#fff",
|
||||
fontSize: 9,
|
||||
fontWeight: "700",
|
||||
},
|
||||
medalTag: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e891ab",
|
||||
overflow: "hidden",
|
||||
marginRight: 4,
|
||||
height: 16,
|
||||
},
|
||||
medalName: {
|
||||
fontSize: 9,
|
||||
color: "#e891ab",
|
||||
paddingHorizontal: 3,
|
||||
},
|
||||
medalLvBox: {
|
||||
paddingHorizontal: 3,
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
},
|
||||
medalLv: {
|
||||
fontSize: 9,
|
||||
color: "#fff",
|
||||
fontWeight: "700",
|
||||
},
|
||||
uname: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
fontWeight: "500",
|
||||
maxWidth: 90,
|
||||
},
|
||||
colon: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
},
|
||||
text: {
|
||||
fontSize: 13,
|
||||
color: "#212121",
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Gift bar styles ──────────────────────────────────────────────────────────
|
||||
const giftStyles = StyleSheet.create({
|
||||
bar: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#eee",
|
||||
backgroundColor: "#fff",
|
||||
height: 72,
|
||||
},
|
||||
scroll: {
|
||||
paddingHorizontal: 6,
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
},
|
||||
item: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 60,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
iconWrap: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
badge: {
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "#FF6B81",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
zIndex: 1,
|
||||
minWidth: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
badgeText: {
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
fontWeight: "700",
|
||||
lineHeight: 13,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
},
|
||||
name: {
|
||||
fontSize: 10,
|
||||
color: "#333",
|
||||
fontWeight: "500",
|
||||
marginTop: 2,
|
||||
},
|
||||
price: {
|
||||
fontSize: 9,
|
||||
color: "#aaa",
|
||||
marginTop: 1,
|
||||
},
|
||||
});
|
||||
170
components/DanmakuOverlay.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { View, Animated, StyleSheet, Text } from 'react-native';
|
||||
import { DanmakuItem } from '../services/types';
|
||||
import { danmakuColorToCss } from '../utils/danmaku';
|
||||
|
||||
interface Props {
|
||||
danmakus: DanmakuItem[];
|
||||
currentTime: number;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const LANE_COUNT = 5;
|
||||
const LANE_H = 28;
|
||||
|
||||
interface ActiveDanmaku {
|
||||
id: string;
|
||||
item: DanmakuItem;
|
||||
lane: number;
|
||||
tx: Animated.Value;
|
||||
opacity: Animated.Value;
|
||||
}
|
||||
|
||||
export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, screenHeight, visible }: Props) {
|
||||
const [activeDanmakus, setActiveDanmakus] = useState<ActiveDanmaku[]>([]);
|
||||
const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0));
|
||||
const activated = useRef<Set<string>>(new Set());
|
||||
const prevTimeRef = useRef<number>(currentTime);
|
||||
const idCounter = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const pickLane = useCallback((): number | null => {
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < LANE_COUNT; i++) {
|
||||
if (laneAvailAt.current[i] <= now) return i;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activated.current.clear();
|
||||
laneAvailAt.current.fill(0);
|
||||
setActiveDanmakus([]);
|
||||
}, [danmakus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const prevTime = prevTimeRef.current;
|
||||
const didSeek = Math.abs(currentTime - prevTime) > 2;
|
||||
prevTimeRef.current = currentTime;
|
||||
|
||||
if (didSeek) {
|
||||
// Clear on seek
|
||||
activated.current.clear();
|
||||
laneAvailAt.current.fill(0);
|
||||
setActiveDanmakus([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find danmakus in the activation window
|
||||
const window = 0.4;
|
||||
const candidates = danmakus.filter(d => {
|
||||
const key = `${d.time}_${d.text}`;
|
||||
return d.time >= currentTime - window && d.time <= currentTime + window && !activated.current.has(key);
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const newItems: ActiveDanmaku[] = [];
|
||||
|
||||
if (activated.current.size > 200) activated.current.clear(); // prevent memory leak
|
||||
for (const item of candidates) {
|
||||
const key = `${item.time}_${item.text}`;
|
||||
activated.current.add(key);
|
||||
|
||||
if (item.mode === 1) {
|
||||
// Scrolling danmaku
|
||||
const lane = pickLane();
|
||||
if (lane === null) continue; // drop if all lanes full
|
||||
|
||||
const charWidth = Math.min(item.fontSize, 22) * 0.8;
|
||||
const textWidth = item.text.length * charWidth;
|
||||
const duration = 8000;
|
||||
// Lane becomes available when tail of this danmaku clears the right edge of screen
|
||||
// tail starts at screenWidth, text has width textWidth
|
||||
// tail clears left edge at duration ms
|
||||
// lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth)
|
||||
// conservative: lane free after textWidth / (2*screenWidth) * duration ms
|
||||
const laneDelay = (textWidth / (screenWidth + textWidth)) * duration;
|
||||
laneAvailAt.current[lane] = Date.now() + laneDelay;
|
||||
|
||||
const tx = new Animated.Value(screenWidth);
|
||||
const id = `d_${idCounter.current++}`;
|
||||
|
||||
newItems.push({ id, item, lane, tx, opacity: new Animated.Value(1) });
|
||||
|
||||
Animated.timing(tx, {
|
||||
toValue: -textWidth - 20,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
|
||||
});
|
||||
} else {
|
||||
// Fixed danmaku (mode 4 = bottom, mode 5 = top)
|
||||
const opacity = new Animated.Value(1);
|
||||
const id = `d_${idCounter.current++}`;
|
||||
newItems.push({ id, item, lane: -1, tx: new Animated.Value(0), opacity });
|
||||
|
||||
Animated.sequence([
|
||||
Animated.delay(2000),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
]).start(() => {
|
||||
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setActiveDanmakus(prev => {
|
||||
const combined = [...prev, ...newItems];
|
||||
// Cap at 30 simultaneous danmakus
|
||||
return combined.slice(Math.max(0, combined.length - 30));
|
||||
});
|
||||
}
|
||||
}, [currentTime, visible, danmakus, pickLane, screenWidth]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
|
||||
{activeDanmakus.map(d => {
|
||||
const fontSize = Math.min(d.item.fontSize || 25, 22);
|
||||
const isScrolling = d.item.mode === 1;
|
||||
const isTop = d.item.mode === 5;
|
||||
|
||||
return (
|
||||
<Animated.Text
|
||||
key={d.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: isScrolling
|
||||
? 20 + d.lane * LANE_H
|
||||
: isTop
|
||||
? 20
|
||||
: screenHeight - 48,
|
||||
left: isScrolling ? 0 : undefined,
|
||||
alignSelf: !isScrolling ? 'center' : undefined,
|
||||
transform: isScrolling ? [{ translateX: d.tx }] : [],
|
||||
opacity: d.opacity,
|
||||
color: danmakuColorToCss(d.item.color),
|
||||
fontSize,
|
||||
fontWeight: '700',
|
||||
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||
textShadowOffset: { width: 1, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
}}
|
||||
>
|
||||
{d.item.text}
|
||||
</Animated.Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
162
components/DownloadProgressBtn.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useDownloadStore } from '../store/downloadStore';
|
||||
import { useTheme } from '../utils/theme';
|
||||
|
||||
const SIZE = 32; // 环外径
|
||||
const RING = 3; // 环宽
|
||||
const BLUE = '#00AEEC';
|
||||
const INNER = SIZE - RING * 2;
|
||||
|
||||
interface Props {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressBtn({ onPress }: Props) {
|
||||
const theme = useTheme();
|
||||
const tasks = useDownloadStore((s) => s.tasks);
|
||||
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
|
||||
const hasDownloading = downloading.length > 0;
|
||||
|
||||
const avgProgress = hasDownloading
|
||||
? downloading.reduce((sum, t) => sum + t.progress, 0) / downloading.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.btn} activeOpacity={0.7}>
|
||||
{/* 进度环,绝对定位居中 */}
|
||||
{/* {hasDownloading && (
|
||||
<View style={styles.ringWrap} pointerEvents="none">
|
||||
<RingProgress progress={avgProgress} />
|
||||
</View>
|
||||
)} */}
|
||||
<Ionicons
|
||||
name="cloud-download-outline"
|
||||
size={22}
|
||||
color={hasDownloading ? BLUE : theme.iconDefault}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双半圆裁剪法绘制圆弧进度
|
||||
*
|
||||
* 两个「D 形」(实心半圆) 分别放在左/右裁剪容器中,
|
||||
* 旋转 D 形就能在容器内扫出弧形,配合白色内圆变成环形。
|
||||
*
|
||||
* 旋转轴 = D 形 View 的默认中心 = 外圆圆心,无需 transformOrigin。
|
||||
*/
|
||||
function RingProgress({ progress }: { progress: number }) {
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
|
||||
// 右半弧:进度 0→50%,右 D 从 -180°→0°(顺时针)
|
||||
const rightAngle = -180 + Math.min(p * 2, 1) * 180;
|
||||
// 左半弧:进度 50%→100%,左 D 从 180°→0°
|
||||
const leftAngle = 180 - Math.max(0, p * 2 - 1) * 180;
|
||||
|
||||
return (
|
||||
<View style={styles.ring}>
|
||||
{/* 灰色背景环 */}
|
||||
<View style={styles.ringBg} />
|
||||
|
||||
{/* ── 右裁剪:左边缘 = 圆心,只露右半 ── */}
|
||||
<View style={styles.rightClip}>
|
||||
{/* left: -SIZE/2 → D 形中心落在容器左边缘 = 外圆圆心 */}
|
||||
<View style={[styles.dRight, { transform: [{ rotate: `${rightAngle}deg` }] }]} />
|
||||
</View>
|
||||
|
||||
{/* ── 左裁剪:右边缘 = 圆心,只露左半 ── */}
|
||||
<View style={styles.leftClip}>
|
||||
{/* left: 0 → D 形中心在容器右边缘 = 外圆圆心 */}
|
||||
<View style={[styles.dLeft, { transform: [{ rotate: `${leftAngle}deg` }] }]} />
|
||||
</View>
|
||||
|
||||
{/* 白色内圆,把实心扇区变成环形 */}
|
||||
<View style={styles.inner} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
width: SIZE + 8,
|
||||
height: SIZE + 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 绝对定位居中,overflow:hidden 防止 D 形溢出
|
||||
ringWrap: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
ring: {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ringBg: {
|
||||
position: 'absolute',
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderRadius: SIZE / 2,
|
||||
borderWidth: RING,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
|
||||
// 右裁剪容器:left = SIZE/2(圆心处),宽 SIZE/2,只露右半
|
||||
rightClip: {
|
||||
position: 'absolute',
|
||||
left: SIZE / 2,
|
||||
top: 0,
|
||||
width: SIZE / 2,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 右 D 形(右半圆):left = -SIZE/2 → center = (0, SIZE/2) in clip = 外圆圆心
|
||||
dRight: {
|
||||
position: 'absolute',
|
||||
left: -SIZE / 2,
|
||||
top: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderTopRightRadius: SIZE / 2,
|
||||
borderBottomRightRadius: SIZE / 2,
|
||||
backgroundColor: BLUE,
|
||||
},
|
||||
|
||||
// 左裁剪容器:right = SIZE/2(圆心处),宽 SIZE/2,只露左半
|
||||
leftClip: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: SIZE / 2,
|
||||
height: SIZE,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 左 D 形(左半圆):left = 0 → center = (SIZE/2, SIZE/2) in clip = 外圆圆心
|
||||
dLeft: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderTopLeftRadius: SIZE / 2,
|
||||
borderBottomLeftRadius: SIZE / 2,
|
||||
backgroundColor: BLUE,
|
||||
},
|
||||
|
||||
// 白色内圆(遮住 D 形中心,留出环宽)
|
||||
inner: {
|
||||
width: INNER,
|
||||
height: INNER,
|
||||
borderRadius: INNER / 2,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
});
|
||||
202
components/DownloadSheet.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useDownload } from "../hooks/useDownload";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
bvid: string;
|
||||
cid: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
}
|
||||
|
||||
export function DownloadSheet({
|
||||
visible,
|
||||
onClose,
|
||||
bvid,
|
||||
cid,
|
||||
title,
|
||||
cover,
|
||||
qualities,
|
||||
}: Props) {
|
||||
const { tasks, startDownload, taskKey } = useDownload();
|
||||
const theme = useTheme();
|
||||
const slideAnim = useRef(new Animated.Value(300)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: visible ? 0 : 300,
|
||||
duration: 260,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [visible]);
|
||||
|
||||
if (qualities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.headerTitle, { color: theme.modalText }]}>下载视频</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={20} color={theme.modalTextSub} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
|
||||
{qualities.map((q) => {
|
||||
const key = taskKey(bvid, q.qn);
|
||||
const task = tasks[key];
|
||||
return (
|
||||
<View key={q.qn} style={styles.row}>
|
||||
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
|
||||
<View style={styles.right}>
|
||||
{!task && (
|
||||
<TouchableOpacity
|
||||
style={styles.downloadBtn}
|
||||
onPress={() =>
|
||||
startDownload(bvid, cid, q.qn, q.desc, title, cover)
|
||||
}
|
||||
>
|
||||
<Text style={styles.downloadBtnTxt}>下载</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{task?.status === "downloading" && (
|
||||
<View style={styles.progressWrap}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${Math.round(task.progress * 100)}%` as any,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressTxt}>
|
||||
{Math.round(task.progress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{task?.status === "done" && (
|
||||
<View style={styles.doneRow}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={16}
|
||||
color="#00AEEC"
|
||||
/>
|
||||
<Text style={styles.doneTxt}>已下载</Text>
|
||||
</View>
|
||||
)}
|
||||
{task?.status === "error" && (
|
||||
<View style={styles.errorWrap}>
|
||||
{!!task.error && (
|
||||
<Text style={styles.errorMsg} numberOfLines={2}>
|
||||
{task.error}
|
||||
</Text>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.retryBtn}
|
||||
onPress={() =>
|
||||
startDownload(bvid, cid, q.qn, q.desc, title, cover)
|
||||
}
|
||||
>
|
||||
<Ionicons name="refresh" size={14} color="#f44" />
|
||||
<Text style={styles.retryTxt}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={styles.footer} />
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
},
|
||||
sheet: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerTitle: { fontSize: 16, fontWeight: "700", color: "#212121" },
|
||||
closeBtn: { padding: 4 },
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#eee",
|
||||
marginBottom: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
},
|
||||
qualityLabel: { fontSize: 15, color: "#212121" },
|
||||
right: { flexDirection: "row", alignItems: "center" },
|
||||
downloadBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 14,
|
||||
},
|
||||
downloadBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
|
||||
progressWrap: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
progressTrack: {
|
||||
width: 80,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#e0e0e0",
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 },
|
||||
progressTxt: { fontSize: 12, color: "#666", minWidth: 32 },
|
||||
doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
doneTxt: { fontSize: 13, color: "#00AEEC" },
|
||||
errorWrap: { alignItems: "flex-end", gap: 2 },
|
||||
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
|
||||
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
retryTxt: { fontSize: 13, color: "#f44" },
|
||||
footer: { height: 24 },
|
||||
});
|
||||
110
components/FollowedLiveStrip.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { getFollowedLiveRooms } from "../services/api";
|
||||
import { LivePulse } from "./LivePulse";
|
||||
import { proxyImageUrl } from "../utils/imageUrl";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import type { LiveRoom } from "../services/types";
|
||||
|
||||
export function FollowedLiveStrip() {
|
||||
const { sessdata } = useAuthStore();
|
||||
const [rooms, setRooms] = useState<LiveRoom[]>([]);
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessdata) return;
|
||||
getFollowedLiveRooms()
|
||||
.then(setRooms)
|
||||
.catch(() => {});
|
||||
}, [sessdata]);
|
||||
|
||||
if (!sessdata || rooms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.bg }]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{rooms.map((room, index) => (
|
||||
<TouchableOpacity
|
||||
key={`followed-${room.roomid ?? index}`}
|
||||
style={styles.item}
|
||||
onPress={() => router.push(`/live/${room.roomid}` as any)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.pulseRow}>
|
||||
<LivePulse />
|
||||
<Text style={{ color: "#fff", fontSize: 9, marginLeft: 2 }}>
|
||||
直播
|
||||
</Text>
|
||||
</View>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(room.face) }}
|
||||
style={[styles.avatar, { backgroundColor: theme.card }]}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.name, { color: theme.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{room.uname.length > 5 ? room.uname.slice(0, 5) : room.uname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#f4f4f4",
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
item: {
|
||||
alignItems: "center",
|
||||
width: 56,
|
||||
position: "relative",
|
||||
},
|
||||
pulseRow: {
|
||||
position: "absolute",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
bottom: 18,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 10,
|
||||
zIndex: 100,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "#eee",
|
||||
},
|
||||
name: {
|
||||
fontSize: 11,
|
||||
color: "#333",
|
||||
marginTop: 4,
|
||||
textAlign: "center",
|
||||
width: 56,
|
||||
},
|
||||
});
|
||||
173
components/LanShareModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { DownloadTask } from '../store/downloadStore';
|
||||
import { startLanServer, stopLanServer, buildVideoUrl } from '../utils/lanServer';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
task: DownloadTask | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LanShareModal({ visible, task, onClose }: Props) {
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [qrImageLoaded, setQrImageLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const slideY = useRef(new Animated.Value(400)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 4 }).start();
|
||||
} else {
|
||||
slideY.setValue(400);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !task) return;
|
||||
setVideoUrl(null);
|
||||
setQrImageLoaded(false);
|
||||
setLoading(true);
|
||||
startLanServer()
|
||||
.then((baseUrl) => {
|
||||
setVideoUrl(buildVideoUrl(baseUrl, task.bvid, task.qn));
|
||||
})
|
||||
.catch(() => setVideoUrl(null))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
stopLanServer();
|
||||
};
|
||||
}, [visible, task?.bvid, task?.qn]);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!videoUrl) return;
|
||||
await Clipboard.setStringAsync(videoUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopLanServer();
|
||||
onClose();
|
||||
}
|
||||
|
||||
const qrSrc = videoUrl
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(videoUrl)}&size=400x400`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay} pointerEvents="box-none" />
|
||||
<Animated.View style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>局域网分享</Text>
|
||||
|
||||
{task && (
|
||||
<Text style={styles.taskTitle} numberOfLines={2}>
|
||||
{task.title} · {task.qdesc}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />
|
||||
) : qrSrc ? (
|
||||
<>
|
||||
<View style={styles.qrWrapper}>
|
||||
<Image
|
||||
source={{ uri: qrSrc }}
|
||||
style={styles.qr}
|
||||
onLoad={() => setQrImageLoaded(true)}
|
||||
/>
|
||||
{!qrImageLoaded && (
|
||||
<View style={styles.qrLoader}>
|
||||
<ActivityIndicator size="large" color="#00AEEC" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.urlRow} onPress={handleCopy} activeOpacity={0.7}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.urlScroll}>
|
||||
<Text style={styles.urlText}>{videoUrl}</Text>
|
||||
</ScrollView>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark-circle' : 'copy-outline'}
|
||||
size={20}
|
||||
color={copied ? '#4caf50' : '#00AEEC'}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.hint}>同一 WiFi 下,用浏览器扫码或输入链接即可播放</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.hint}>启动服务器失败,请确认已连接 WiFi12312</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={handleClose}>
|
||||
<Text style={styles.closeTxt}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetWrapper: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
taskTitle: { fontSize: 13, color: '#666', marginBottom: 16, textAlign: 'center' },
|
||||
loader: { marginVertical: 40 },
|
||||
qrWrapper: { width: 200, height: 200, marginBottom: 16 },
|
||||
qr: { width: 200, height: 200 },
|
||||
qrLoader: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f4f4f4',
|
||||
},
|
||||
urlRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginBottom: 12,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
urlScroll: { maxWidth: 260 },
|
||||
urlText: { fontSize: 12, color: '#333', fontFamily: 'monospace' },
|
||||
hint: { fontSize: 12, color: '#999', marginBottom: 20, textAlign: 'center' },
|
||||
closeBtn: { padding: 12 },
|
||||
closeTxt: { fontSize: 14, color: '#00AEEC' },
|
||||
});
|
||||
148
components/LiveCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LivePulse } from "./LivePulse";
|
||||
import type { LiveRoom } from "../services/types";
|
||||
import { formatCount } from "../utils/format";
|
||||
import { proxyImageUrl } from "../utils/imageUrl";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
const CARD_WIDTH = (width - 14) / 2;
|
||||
|
||||
interface Props {
|
||||
item: LiveRoom;
|
||||
isLivePulse?: Boolean;
|
||||
onPress?: () => void;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const LiveCard = React.memo(function LiveCard({
|
||||
item,
|
||||
onPress,
|
||||
fullWidth,
|
||||
isLivePulse = false,
|
||||
}: Props) {
|
||||
const cardWidth = fullWidth ? width - 8 : CARD_WIDTH;
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { width: cardWidth, backgroundColor: theme.card }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.thumbContainer}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.cover) }}
|
||||
style={[
|
||||
styles.thumb,
|
||||
{ width: cardWidth, height: cardWidth * 0.5625, backgroundColor: theme.card },
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.liveBadge}>
|
||||
{isLivePulse && <LivePulse />}
|
||||
<Text style={styles.liveBadgeText}>直播中</Text>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="people" size={11} color="#fff" />
|
||||
<Text style={styles.metaText}>{formatCount(item.online)}</Text>
|
||||
</View>
|
||||
<View style={styles.areaBadge}>
|
||||
<Text style={styles.areaText}>{item.area_name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View style={styles.ownerRow}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.face) }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
|
||||
{item.uname}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
marginBottom: 6,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
},
|
||||
thumbContainer: { position: "relative" },
|
||||
thumb: {
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_WIDTH * 0.5625,
|
||||
backgroundColor: "#ddd",
|
||||
},
|
||||
liveBadge: {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
left: 4,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
|
||||
meta: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 5,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
metaText: { fontSize: 10, color: "#fff" },
|
||||
areaBadge: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 4,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
areaText: { color: "#fff", fontSize: 10 },
|
||||
info: { padding: 6 },
|
||||
title: {
|
||||
fontSize: 12,
|
||||
color: "#212121",
|
||||
height: 33,
|
||||
marginBottom: 4,
|
||||
},
|
||||
ownerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
marginTop: 2,
|
||||
},
|
||||
avatar: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#eee",
|
||||
},
|
||||
owner: { fontSize: 11, color: "#999", flex: 1 },
|
||||
});
|
||||
202
components/LiveMiniPlayer.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
PanResponder,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLiveStore } from '../store/liveStore';
|
||||
import { useVideoStore } from '../store/videoStore';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
|
||||
const MINI_W = 160;
|
||||
const MINI_H = 90;
|
||||
|
||||
const LIVE_HEADERS = {};
|
||||
|
||||
function snapRelease(
|
||||
pan: Animated.ValueXY,
|
||||
curX: number,
|
||||
curY: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
) {
|
||||
const snapRight = 0;
|
||||
const snapLeft = -(sw - MINI_W - 24);
|
||||
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
|
||||
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||
Animated.spring(pan, {
|
||||
toValue: { x: snapX, y: clampedY },
|
||||
useNativeDriver: false,
|
||||
tension: 120,
|
||||
friction: 10,
|
||||
}).start();
|
||||
}
|
||||
|
||||
export function LiveMiniPlayer() {
|
||||
const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore();
|
||||
const videoMiniActive = useVideoStore(s => s.isActive);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||
const storeRef = useRef({ roomId, clearLive, router });
|
||||
storeRef.current = { roomId, clearLive, router };
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
pan.x.setValue(gs.dx);
|
||||
pan.y.setValue(gs.dy);
|
||||
},
|
||||
onPanResponderRelease: (evt) => {
|
||||
pan.flattenOffset();
|
||||
if (!isDragging.current) {
|
||||
const { locationX, locationY } = evt.nativeEvent;
|
||||
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
|
||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||
clear();
|
||||
} else {
|
||||
r.push(`/live/${rid}` as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh);
|
||||
},
|
||||
onPanResponderTerminate: () => { pan.flattenOffset(); },
|
||||
}),
|
||||
).current;
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0);
|
||||
|
||||
// Web 端降级:封面图 + LIVE 徽标
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.videoArea} />
|
||||
<View style={styles.liveBadge} pointerEvents="none">
|
||||
<View style={styles.liveDot} />
|
||||
<Text style={styles.liveText}>LIVE</Text>
|
||||
</View>
|
||||
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
|
||||
<View style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={14} color="#fff" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
// Native:实际 HLS 流播放
|
||||
const Video = require('react-native-video').default;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */}
|
||||
<View style={styles.videoArea} pointerEvents="none">
|
||||
<Video
|
||||
key={hlsUrl}
|
||||
source={{ uri: hlsUrl, headers: LIVE_HEADERS }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
controls={false}
|
||||
muted={false}
|
||||
paused={false}
|
||||
repeat={false}
|
||||
onError={clearLive}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.liveBadge} pointerEvents="none">
|
||||
<View style={styles.liveDot} />
|
||||
<Text style={styles.liveText}>LIVE</Text>
|
||||
</View>
|
||||
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
|
||||
{/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */}
|
||||
<View style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={14} color="#fff" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
width: MINI_W,
|
||||
height: MINI_H,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1a1a1a',
|
||||
overflow: 'hidden',
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
videoArea: {
|
||||
width: '100%',
|
||||
height: 66,
|
||||
backgroundColor: '#111',
|
||||
},
|
||||
liveBadge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 6,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 3,
|
||||
gap: 3,
|
||||
},
|
||||
liveDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#f00' },
|
||||
liveText: { color: '#fff', fontSize: 9, fontWeight: '700', letterSpacing: 0.5 },
|
||||
titleText: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
lineHeight: 14,
|
||||
height: 24,
|
||||
backgroundColor: '#1a1a1a',
|
||||
},
|
||||
closeBtn: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
416
components/LivePlayer.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Modal,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
interface Props {
|
||||
hlsUrl: string;
|
||||
flvUrl?: string;
|
||||
isLive: boolean;
|
||||
qualities?: { qn: number; desc: string }[];
|
||||
currentQn?: number;
|
||||
onQualityChange?: (qn: number) => void;
|
||||
}
|
||||
|
||||
const HIDE_DELAY = 3000;
|
||||
|
||||
const HEADERS = {};
|
||||
|
||||
export function LivePlayer({
|
||||
hlsUrl,
|
||||
flvUrl,
|
||||
isLive,
|
||||
qualities = [],
|
||||
currentQn = 0,
|
||||
onQualityChange,
|
||||
}: Props) {
|
||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||
const VIDEO_H = SCREEN_W * 0.5625;
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
return (
|
||||
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
|
||||
<Text style={styles.webHint}>请在手机端观看直播</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLive || !hlsUrl) {
|
||||
return (
|
||||
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
|
||||
<Ionicons name="tv-outline" size={40} color="#555" />
|
||||
<Text style={styles.offlineText}>暂未开播</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeLivePlayer
|
||||
hlsUrl={hlsUrl}
|
||||
screenW={SCREEN_W}
|
||||
screenH={SCREEN_H}
|
||||
videoH={VIDEO_H}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeLivePlayer({
|
||||
hlsUrl,
|
||||
screenW,
|
||||
screenH,
|
||||
videoH,
|
||||
qualities,
|
||||
currentQn,
|
||||
onQualityChange,
|
||||
}: {
|
||||
hlsUrl: string;
|
||||
screenW: number;
|
||||
screenH: number;
|
||||
videoH: number;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange?: (qn: number) => void;
|
||||
}) {
|
||||
const Video = require("react-native-video").default;
|
||||
const theme = useTheme();
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [buffering, setBuffering] = useState(true);
|
||||
const [showQualityPanel, setShowQualityPanel] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const videoRef = useRef<any>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Lock/unlock orientation on fullscreen toggle
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const ScreenOrientation = require("expo-screen-orientation");
|
||||
if (isFullscreen) {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
);
|
||||
} else {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* graceful degradation in Expo Go */
|
||||
}
|
||||
})();
|
||||
}, [isFullscreen]);
|
||||
|
||||
// Restore portrait on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
(async () => {
|
||||
try {
|
||||
const ScreenOrientation = require("expo-screen-orientation");
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
}, [resetHideTimer]);
|
||||
|
||||
const fsW = Math.max(screenW, screenH);
|
||||
const fsH = Math.min(screenW, screenH);
|
||||
const containerStyle = isFullscreen
|
||||
? { position: 'absolute' as const, top: 0, left: 0, width: fsW, height: fsH, zIndex: 999, elevation: 999 }
|
||||
: { width: screenW, height: videoH };
|
||||
|
||||
const currentQnDesc = qualities.find((q) => q.qn === currentQn)?.desc ?? "";
|
||||
|
||||
const videoContent = (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
<Video
|
||||
key={hlsUrl}
|
||||
ref={videoRef}
|
||||
source={{ uri: hlsUrl, headers: HEADERS }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={paused}
|
||||
onProgress={({ currentTime: ct }: { currentTime: number }) => {
|
||||
currentTimeRef.current = ct;
|
||||
}}
|
||||
onBuffer={({ isBuffering }: { isBuffering: boolean }) =>
|
||||
setBuffering(isBuffering)
|
||||
}
|
||||
onLoad={() => {
|
||||
setBuffering(false);
|
||||
if (currentTimeRef.current > 0) {
|
||||
videoRef.current?.seek(currentTimeRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{buffering && (
|
||||
<View style={styles.bufferingOverlay} pointerEvents="none">
|
||||
<Text style={styles.bufferingText}>缓冲中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
resetHideTimer();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
resetHideTimer();
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={{ flex: 1 }} />
|
||||
{qualities.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.qualityBtn}
|
||||
onPress={() => {
|
||||
setShowQualityPanel(true);
|
||||
resetHideTimer();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.qualityText}>
|
||||
{currentQnDesc || "清晰度"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => {
|
||||
if (isFullscreen) setPaused(true);
|
||||
setIsFullscreen((fs) => !fs);
|
||||
resetHideTimer();
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isFullscreen ? "contract" : "expand"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality selector panel */}
|
||||
<Modal
|
||||
visible={showQualityPanel}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowQualityPanel(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.qualityOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowQualityPanel(false)}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||
<View style={[styles.qualityPanel, { backgroundColor: theme.modalBg }]}>
|
||||
<Text style={[styles.qualityPanelTitle, { color: theme.modalText }]}>清晰度</Text>
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
||||
onPress={() => {
|
||||
onQualityChange?.(q.qn);
|
||||
setShowQualityPanel(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
{ color: theme.modalTextSub },
|
||||
currentQn === q.qn && styles.qualityItemTextActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{currentQn === q.qn && (
|
||||
<Ionicons name="checkmark" size={14} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
|
||||
return videoContent;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#000",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
webHint: { color: "#fff", fontSize: 15 },
|
||||
offlineText: { color: "#999", fontSize: 14, marginTop: 10 },
|
||||
bufferingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
bufferingText: { color: "#fff", fontSize: 13, opacity: 0.8 },
|
||||
liveBadge: {
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
liveDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "#f00",
|
||||
marginRight: 5,
|
||||
},
|
||||
liveText: {
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
centerBtn: {
|
||||
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",
|
||||
},
|
||||
bottomBar: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 24,
|
||||
backgroundColor: "rgba(0,0,0,0)",
|
||||
},
|
||||
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
||||
qualityBtn: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
borderRadius: 4,
|
||||
marginRight: 4,
|
||||
},
|
||||
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
|
||||
qualityOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
qualityPanel: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
minWidth: 180,
|
||||
},
|
||||
qualityPanelTitle: {
|
||||
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" },
|
||||
qualityItemTextActive: { color: "#00AEEC", fontWeight: "700" },
|
||||
});
|
||||
64
components/LivePulse.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, Animated } from "react-native";
|
||||
|
||||
const BAR_COUNT = 3;
|
||||
const BAR_HEIGHT = 8;
|
||||
|
||||
export function LivePulse() {
|
||||
const anims = useRef(
|
||||
Array.from({ length: BAR_COUNT }, () => new Animated.Value(0.3)),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animations = anims.map((anim, i) =>
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(i * 120),
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(anim, {
|
||||
toValue: 0.3,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
animations.forEach((a) => a.start());
|
||||
return () => animations.forEach((a) => a.stop());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={pulseStyles.container}>
|
||||
{anims.map((anim, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={[
|
||||
pulseStyles.bar,
|
||||
{
|
||||
transform: [{ scaleY: anim }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const pulseStyles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
height: BAR_HEIGHT,
|
||||
gap: 1,
|
||||
},
|
||||
bar: {
|
||||
width: 2,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 1,
|
||||
},
|
||||
});
|
||||
116
components/LoginModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useRef } from "react";
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { pollQRCode } from "../services/api";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ visible, onClose }: Props) {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const theme = useTheme();
|
||||
|
||||
const slideY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.spring(slideY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
bounciness: 4,
|
||||
}).start();
|
||||
} else {
|
||||
slideY.setValue(300);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
async function handleLogin() {
|
||||
const result = await pollQRCode('mock-key');
|
||||
if (result.code === 0 && result.cookie) {
|
||||
await login(result.cookie, '', '');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay} pointerEvents="box-none" />
|
||||
|
||||
<Animated.View
|
||||
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
|
||||
>
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<Text style={[styles.title, { color: theme.modalText }]}>登录</Text>
|
||||
|
||||
<View style={styles.iconWrap}>
|
||||
<Ionicons name="person-circle-outline" size={72} color="#00AEEC" />
|
||||
</View>
|
||||
|
||||
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
|
||||
演示模式:点击下方按钮一键登录
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginBtn}
|
||||
onPress={handleLogin}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.loginBtnText}>一键登录</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||
<Text style={[styles.closeTxt, { color: "#00AEEC" }]}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
},
|
||||
sheetWrapper: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: "600", marginBottom: 20 },
|
||||
iconWrap: { marginBottom: 16 },
|
||||
hint: { fontSize: 13, marginBottom: 24, textAlign: "center" },
|
||||
loginBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 24,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 8,
|
||||
},
|
||||
loginBtnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
closeBtn: { padding: 12 },
|
||||
closeTxt: { fontSize: 14 },
|
||||
});
|
||||
124
components/MiniPlayer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View, Text, Image, StyleSheet,
|
||||
Animated, PanResponder, Dimensions,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useVideoStore } from '../store/videoStore';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
|
||||
const MINI_W = 160;
|
||||
const MINI_H = 90;
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||
const storeRef = useRef({ bvid, clearVideo, router });
|
||||
storeRef.current = { bvid, clearVideo, router };
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
pan.x.setValue(gs.dx);
|
||||
pan.y.setValue(gs.dy);
|
||||
},
|
||||
onPanResponderRelease: (evt) => {
|
||||
pan.flattenOffset();
|
||||
if (!isDragging.current) {
|
||||
const { locationX, locationY } = evt.nativeEvent;
|
||||
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
|
||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||
clear();
|
||||
} else {
|
||||
r.push(`/video/${vid}` as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
const curX = (pan.x as any)._value;
|
||||
const curY = (pan.y as any)._value;
|
||||
const snapRight = 0;
|
||||
const snapLeft = -(sw - MINI_W - 24);
|
||||
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
|
||||
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||
Animated.spring(pan, {
|
||||
toValue: { x: snapX, y: clampedY },
|
||||
useNativeDriver: false,
|
||||
tension: 120,
|
||||
friction: 10,
|
||||
}).start();
|
||||
},
|
||||
onPanResponderTerminate: () => { pan.flattenOffset(); },
|
||||
})
|
||||
).current;
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
const bottomOffset = insets.bottom + 16;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.cover} />
|
||||
<Text style={styles.title} numberOfLines={1}>{title}</Text>
|
||||
{/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */}
|
||||
<View style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={14} color="#fff" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
width: MINI_W,
|
||||
height: MINI_H,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1a1a1a',
|
||||
overflow: 'hidden',
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
cover: { width: '100%', height: 64, backgroundColor: '#333' },
|
||||
title: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
lineHeight: 14,
|
||||
},
|
||||
closeBtn: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
731
components/NativeVideoPlayer.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { formatDuration } from "../utils/format";
|
||||
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 { getVideoShot } from "../services/api";
|
||||
import DanmakuOverlay from "./DanmakuOverlay";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
const BAR_H = 3;
|
||||
// 进度球尺寸
|
||||
const BALL = 12;
|
||||
// 活跃状态下的拖动球增大尺寸,提升触控体验
|
||||
const BALL_ACTIVE = 16;
|
||||
const HIDE_DELAY = 3000;
|
||||
|
||||
const HEADERS = {};
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
//
|
||||
function findFrameByTime(index: number[], seekTime: number): number {
|
||||
let lo = 0,
|
||||
hi = index.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (index[mid] <= seekTime) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
export interface NativeVideoPlayerRef {
|
||||
seek: (t: number) => void;
|
||||
setPaused: (v: boolean) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onFullscreen: () => void;
|
||||
style?: object;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
danmakus?: DanmakuItem[];
|
||||
isFullscreen?: boolean;
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
initialTime?: number;
|
||||
forcePaused?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
const theme = useTheme();
|
||||
|
||||
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 currentTimeRef = useRef(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const durationRef = useRef(0);
|
||||
const lastProgressUpdate = useRef(0);
|
||||
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const touchXRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(300);
|
||||
const trackRef = useRef<View>(null);
|
||||
|
||||
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);
|
||||
},
|
||||
setPaused: (v: boolean) => {
|
||||
setPaused(v);
|
||||
},
|
||||
}));
|
||||
|
||||
const currentDesc =
|
||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||
String(currentQn || "HD");
|
||||
|
||||
// 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
|
||||
useEffect(() => {
|
||||
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]);
|
||||
// 获取视频截图数据,供进度条预览使用。依赖 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);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bvid, cid]);
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (!isSeekingRef.current) {
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
||||
const showAndReset = useCallback(() => {
|
||||
setShowControls(true);
|
||||
resetHideTimer();
|
||||
}, [resetHideTimer]);
|
||||
|
||||
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
|
||||
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);
|
||||
touchXRef.current = x;
|
||||
setTouchX(x);
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
touchXRef.current = clamp(
|
||||
gs.moveX - barOffsetX.current,
|
||||
0,
|
||||
barWidthRef.current,
|
||||
);
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setTouchX(touchXRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const t = ratio * durationRef.current;
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
},
|
||||
}),
|
||||
).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 bufferedRatio = duration > 0 ? clamp(buffered / 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,
|
||||
index,
|
||||
} = shots;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
const seekTime = touchRatio * duration;
|
||||
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
|
||||
const frameIdx =
|
||||
index?.length && duration > 0
|
||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||
: clamp(
|
||||
Math.floor(touchRatio * (totalFrames - 1)),
|
||||
0,
|
||||
totalFrames - 1,
|
||||
);
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
const col = local % img_x_len;
|
||||
const row = Math.floor(local / img_x_len);
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
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,
|
||||
);
|
||||
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
paused={!!(forcePaused || paused)}
|
||||
progressUpdateInterval={500}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
currentTimeRef.current = ct;
|
||||
onTimeUpdate?.(ct);
|
||||
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
|
||||
if (isSeekingRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate.current < 450) return;
|
||||
lastProgressUpdate.current = now;
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0 && Math.abs(dur - durationRef.current) > 1) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (initialTime && initialTime > 0) {
|
||||
videoRef.current?.seek(initialTime);
|
||||
}
|
||||
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
|
||||
if (!forcePaused) {
|
||||
setPaused(true);
|
||||
requestAnimationFrame(() => setPaused(false));
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 杜比视界播放失败时自动降级到 1080P
|
||||
if (currentQn === 126) {
|
||||
onQualityChange(80);
|
||||
return;
|
||||
}
|
||||
console.warn("Video playback error:", e);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<>
|
||||
{/* 小窗口 */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
></LinearGradient>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(255,255,255,0.35)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{/* Controls */}
|
||||
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
style={styles.ctrlBtn}
|
||||
>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>
|
||||
{formatDuration(Math.floor(currentTime))}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
|
||||
<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>
|
||||
)}
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={18} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderThumbnail()}
|
||||
{/* 选清晰度 */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowQuality(false)}
|
||||
>
|
||||
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
|
||||
<Text style={[styles.qualityTitle, { color: theme.modalText }]}>选择清晰度</Text>
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
{ color: theme.modalTextSub },
|
||||
q.qn === currentQn && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
{q.qn === 126 ? " DV" : ""}
|
||||
</Text>
|
||||
{q.qn === currentQn && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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",
|
||||
},
|
||||
topBtn: { padding: 6 },
|
||||
centerBtn: {
|
||||
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",
|
||||
},
|
||||
bottomBar: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 32,
|
||||
},
|
||||
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,
|
||||
},
|
||||
trackWrapper: {
|
||||
marginHorizontal: 8,
|
||||
height: BAR_H + BALL_ACTIVE,
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
track: {
|
||||
height: BAR_H,
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
},
|
||||
trackLayer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: BAR_H,
|
||||
},
|
||||
ball: {
|
||||
position: "absolute",
|
||||
top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2,
|
||||
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,
|
||||
},
|
||||
ctrlRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
||||
timeText: {
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
marginHorizontal: 2,
|
||||
fontWeight: "600",
|
||||
},
|
||||
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" },
|
||||
});
|
||||
111
components/VideoCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { VideoItem } from "../services/types";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
const CARD_WIDTH = (width - 14) / 2;
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: theme.card }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.thumbContainer}>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||
style={[styles.thumb, { backgroundColor: theme.card }]}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
recyclingKey={item.bvid}
|
||||
/>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="play" size={11} color="#fff" />
|
||||
<Text style={styles.metaText}>
|
||||
{formatCount(item.stat?.view ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
marginBottom: 6,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
},
|
||||
thumbContainer: { position: "relative" },
|
||||
thumb: {
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_WIDTH * 0.5625,
|
||||
backgroundColor: "#ddd",
|
||||
},
|
||||
durationBadge: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 4,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
paddingVertical: 0,
|
||||
},
|
||||
durationText: { color: "#fff", fontSize: 10 },
|
||||
info: { padding: 6 },
|
||||
title: {
|
||||
fontSize: 12,
|
||||
color: "#212121",
|
||||
height: 33,
|
||||
marginBottom: 4,
|
||||
},
|
||||
owner: { fontSize: 11, color: "#999", marginTop: 2 },
|
||||
meta: {
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 5,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 0,
|
||||
gap: 2,
|
||||
},
|
||||
metaText: { fontSize: 10, color: "#fff" },
|
||||
});
|
||||
121
components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native';
|
||||
// expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
|
||||
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
|
||||
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
|
||||
import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
|
||||
import type { PlayUrlResponse, DanmakuItem } from '../services/types';
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
danmakus?: DanmakuItem[];
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
const needsRotation = !ScreenOrientation && fullscreen;
|
||||
const lastTimeRef = useRef(0);
|
||||
const portraitRef = useRef<NativeVideoPlayerRef>(null);
|
||||
|
||||
const handleEnterFullscreen = async () => {
|
||||
if (Platform.OS !== 'web')
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
const handleExitFullscreen = async () => {
|
||||
setFullscreen(false);
|
||||
if (Platform.OS !== 'web')
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (Platform.OS !== 'web')
|
||||
ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!playData) {
|
||||
return (
|
||||
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
|
||||
<Text style={styles.placeholderText}>视频加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const url = playData.durl?.[0]?.url ?? '';
|
||||
return (
|
||||
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
|
||||
<video
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */}
|
||||
{!fullscreen && (
|
||||
<NativeVideoPlayer
|
||||
ref={portraitRef}
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleEnterFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
isFullscreen={false}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fullscreen && (
|
||||
<Modal visible animationType="none" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={needsRotation
|
||||
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
|
||||
: { flex: 1, width: '100%' }
|
||||
}>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleExitFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
danmakus={danmakus}
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#fff', fontSize: 14 },
|
||||
});
|
||||
134
dev-proxy.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const express = require('express');
|
||||
const WsLib = require('ws');
|
||||
const app = express();
|
||||
|
||||
// CORS: allow any local origin (Expo web dev server)
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Buvid3, X-Sessdata');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
||||
next();
|
||||
});
|
||||
|
||||
function makeProxy(targetHost) {
|
||||
return (req, res) => {
|
||||
const buvid3 = req.headers['x-buvid3'] || '';
|
||||
const sessdata = req.headers['x-sessdata'] || '';
|
||||
const cookies = [
|
||||
buvid3 && `buvid3=${buvid3}`,
|
||||
sessdata && `SESSDATA=${sessdata}`,
|
||||
].filter(Boolean).join('; ');
|
||||
|
||||
const options = {
|
||||
hostname: targetHost,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'Referer': 'https://www.JKVideo.com',
|
||||
'Origin': 'https://www.JKVideo.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
};
|
||||
|
||||
const proxy = https.request(options, (proxyRes) => {
|
||||
// On successful QR login, extract SESSDATA from set-cookie and relay via custom header
|
||||
const setCookies = proxyRes.headers['set-cookie'] || [];
|
||||
const match = setCookies.find(c => c.includes('SESSDATA='));
|
||||
if (match) {
|
||||
const val = match.split(';')[0].replace('SESSDATA=', '');
|
||||
res.setHeader('X-Sessdata', val);
|
||||
}
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
'Content-Type': proxyRes.headers['content-type'] || 'application/json',
|
||||
});
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxy.on('error', (err) => res.status(502).json({ error: err.message }));
|
||||
req.pipe(proxy);
|
||||
};
|
||||
}
|
||||
|
||||
app.use('/video-api', makeProxy('api.JKVideo.com'));
|
||||
app.use('/passport-api', makeProxy('passport.JKVideo.com'));
|
||||
app.use('/live-api', makeProxy('api.live.JKVideo.com'));
|
||||
|
||||
// Dedicated comment proxy: buffer response and decompress by magic bytes (not Content-Encoding header)
|
||||
app.use('/comment-api', (req, res) => {
|
||||
const options = {
|
||||
hostname: 'comment.JKVideo.com',
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
'Referer': 'https://www.JKVideo.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
};
|
||||
const proxy = https.request(options, (proxyRes) => {
|
||||
res.setHeader('Content-Type', proxyRes.headers['content-type'] || 'text/xml; charset=utf-8');
|
||||
const chunks = [];
|
||||
proxyRes.on('data', chunk => chunks.push(chunk));
|
||||
proxyRes.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf[0] === 0x1f && buf[1] === 0x8b) {
|
||||
// actual gzip data — decompress regardless of Content-Encoding header
|
||||
zlib.gunzip(buf, (err, result) => {
|
||||
if (err) res.status(502).end('gunzip error: ' + err.message);
|
||||
else res.end(result);
|
||||
});
|
||||
} else {
|
||||
res.end(buf);
|
||||
}
|
||||
});
|
||||
proxyRes.on('error', (err) => res.status(502).json({ error: err.message }));
|
||||
});
|
||||
proxy.on('error', (err) => res.status(502).json({ error: err.message }));
|
||||
req.pipe(proxy);
|
||||
});
|
||||
|
||||
// Image CDN proxy — strips the host segment and forwards to the real CDN with Referer
|
||||
app.use('/img-proxy', (req, res) => {
|
||||
const parts = req.url.split('/').filter(Boolean);
|
||||
const host = parts[0];
|
||||
if (!host || !host.endsWith('.hdslb.com')) return res.status(403).end();
|
||||
req.url = '/' + parts.slice(1).join('/');
|
||||
makeProxy(host)(req, res);
|
||||
});
|
||||
|
||||
const PORT = process.env.PROXY_PORT || 3001;
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket relay — Android Expo Go often can't reach live chat servers directly.
|
||||
// Device connects here; proxy opens the upstream WSS connection and relays all frames.
|
||||
const wss = new WsLib.Server({ server, path: '/danmaku-ws' });
|
||||
wss.on('connection', (clientWs, req) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const target = url.searchParams.get('host');
|
||||
if (!target || !target.includes('JKVideo.com')) {
|
||||
clientWs.close(4001, 'invalid target');
|
||||
return;
|
||||
}
|
||||
console.log('[ws-relay] →', target);
|
||||
const upstream = new WsLib(target, { headers: { Origin: 'https://live.JKVideo.com' }, perMessageDeflate: false });
|
||||
upstream.on('open', () => console.log('[ws-relay] upstream open'));
|
||||
upstream.on('message', data => { if (clientWs.readyState === 1) clientWs.send(data, { binary: true }); });
|
||||
upstream.on('error', err => { console.error('[ws-relay] upstream error:', err.message); clientWs.close(); });
|
||||
upstream.on('close', () => clientWs.close());
|
||||
clientWs.on('message', data => { if (upstream.readyState === 1) upstream.send(data); });
|
||||
clientWs.on('close', () => upstream.close());
|
||||
clientWs.on('error', () => upstream.close());
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () =>
|
||||
console.log(`[Proxy] http://localhost:${PORT} ws://<LAN-IP>:${PORT}/danmaku-ws`)
|
||||
);
|
||||
28
eas.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"channel": "development"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"channel": "preview"
|
||||
},
|
||||
"production": {
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_APP_ENV": "production",
|
||||
"SENTRY_AUTH_TOKEN": "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",
|
||||
"SENTRY_ORG": "your-org-slug",
|
||||
"SENTRY_PROJECT": "your-project-slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
133
hooks/useCheckUpdate.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Linking, Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
const GITHUB_API = 'https://api.github.com/repos/tiajinsha/JKVideo/releases/latest';
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function useCheckUpdate() {
|
||||
const currentVersion = Constants.expoConfig?.version ?? '0.0.0';
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
|
||||
const checkUpdate = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const res = await fetch(GITHUB_API, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const latestVersion: string = data.tag_name ?? '';
|
||||
const apkAsset = (data.assets as any[]).find((a) =>
|
||||
(a.name as string).endsWith('.apk')
|
||||
);
|
||||
const downloadUrl: string = apkAsset?.browser_download_url ?? '';
|
||||
const releaseNotes: string = data.body ?? '';
|
||||
|
||||
if (compareVersions(latestVersion, currentVersion) <= 0) {
|
||||
Alert.alert('已是最新版本', `当前版本 v${currentVersion} 已是最新`);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
`发现新版本 ${latestVersion}`,
|
||||
releaseNotes || '有新版本可用,是否立即下载?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '浏览器下载',
|
||||
onPress: () => Linking.openURL(downloadUrl),
|
||||
},
|
||||
{
|
||||
text: '应用内下载',
|
||||
onPress: () => downloadAndInstall(downloadUrl, latestVersion),
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('检查失败', e?.message ?? '网络错误,请稍后重试');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openInstallSettings = () => {
|
||||
IntentLauncher.startActivityAsync(
|
||||
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
|
||||
{ data: 'package:com.anonymous.jkvideo' }
|
||||
).catch(() => {
|
||||
// 部分旧版 Android 不支持精确跳转,回退到通用安全设置
|
||||
IntentLauncher.startActivityAsync('android.settings.SECURITY_SETTINGS');
|
||||
});
|
||||
};
|
||||
|
||||
const triggerInstall = async (localUri: string) => {
|
||||
const contentUri = await FileSystem.getContentUriAsync(localUri);
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri,
|
||||
flags: 1,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
});
|
||||
};
|
||||
|
||||
const downloadAndInstall = async (url: string, version: string) => {
|
||||
if (Platform.OS !== 'android') {
|
||||
Alert.alert('提示', '自动安装仅支持 Android 设备');
|
||||
return;
|
||||
}
|
||||
const localUri = FileSystem.cacheDirectory + `JKVideo-${version}.apk`;
|
||||
try {
|
||||
setDownloadProgress(0);
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
url,
|
||||
localUri,
|
||||
{},
|
||||
({ totalBytesWritten, totalBytesExpectedToWrite }) => {
|
||||
if (totalBytesExpectedToWrite > 0) {
|
||||
setDownloadProgress(
|
||||
Math.round((totalBytesWritten / totalBytesExpectedToWrite) * 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
await downloadResumable.downloadAsync();
|
||||
setDownloadProgress(null);
|
||||
|
||||
// Android 8.0+ 需要用户在系统设置中为本应用开启「安装未知应用」权限。
|
||||
// 系统拒绝时不会抛出 JS 异常,因此下载完成后主动引导。
|
||||
Alert.alert(
|
||||
'下载完成,准备安装',
|
||||
'如果点击「安装」后提示无权限,请先点击「去设置」,为 JKVideo 开启「允许安装未知应用」,然后返回重试。',
|
||||
[
|
||||
{ text: '去设置', onPress: openInstallSettings },
|
||||
{
|
||||
text: '安装',
|
||||
onPress: () => {
|
||||
triggerInstall(localUri).catch((e: any) => {
|
||||
Alert.alert('安装失败', e?.message ?? '请在设置中开启「安装未知应用」权限后重试');
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (e: any) {
|
||||
setDownloadProgress(null);
|
||||
Alert.alert('下载失败', e?.message ?? '请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
return { currentVersion, isChecking, downloadProgress, checkUpdate };
|
||||
}
|
||||
49
hooks/useComments.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { getComments } from '../services/api';
|
||||
import type { Comment } from '../services/types';
|
||||
|
||||
export function useComments(aid: number, sort: number) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const hasMoreRef = useRef(true);
|
||||
const sortRef = useRef(sort);
|
||||
const aidRef = useRef(aid);
|
||||
const cursorRef = useRef(''); // empty = first page
|
||||
|
||||
aidRef.current = aid;
|
||||
|
||||
useEffect(() => {
|
||||
if (sortRef.current === sort) return;
|
||||
sortRef.current = sort;
|
||||
cursorRef.current = '';
|
||||
hasMoreRef.current = true;
|
||||
setComments([]);
|
||||
setHasMore(true);
|
||||
}, [sort]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current || !hasMoreRef.current || !aidRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const isFirstPage = cursorRef.current === '';
|
||||
const { replies, nextCursor, isEnd } = await getComments(aidRef.current, cursorRef.current, sortRef.current);
|
||||
cursorRef.current = nextCursor;
|
||||
setComments(prev => isFirstPage ? replies : [...prev, ...replies]);
|
||||
if (isEnd || replies.length === 0) {
|
||||
hasMoreRef.current = false;
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load comments', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { comments, loading, hasMore, load };
|
||||
}
|
||||
167
hooks/useDownload.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { AppState } from 'react-native';
|
||||
import { useDownloadStore } from '../store/downloadStore';
|
||||
import { getPlayUrlForDownload } from '../services/api';
|
||||
|
||||
const lastReportedProgress: Record<string, number> = {};
|
||||
|
||||
const QUALITY_LABELS: Record<number, string> = {
|
||||
16: '360P', 32: '480P', 64: '720P',
|
||||
80: '1080P', 112: '1080P+', 116: '1080P60',
|
||||
};
|
||||
|
||||
/** 等待 App 回到前台 */
|
||||
function waitForActive(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (AppState.currentState === 'active') { resolve(); return; }
|
||||
const sub = AppState.addEventListener('change', (s) => {
|
||||
if (s === 'active') { sub.remove(); resolve(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 当前是否在后台 */
|
||||
function isBackground() {
|
||||
return AppState.currentState !== 'active';
|
||||
}
|
||||
|
||||
/** 读取本地文件实际大小 */
|
||||
async function readFileSize(uri: string): Promise<number | undefined> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(uri, { size: true });
|
||||
if (info.exists) return (info as any).size as number;
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const { tasks, addTask, updateTask, removeTask } = useDownloadStore();
|
||||
|
||||
function taskKey(bvid: string, qn: number) { return `${bvid}_${qn}`; }
|
||||
function localPath(bvid: string, qn: number) {
|
||||
return `${FileSystem.documentDirectory}${bvid}_${qn}.mp4`;
|
||||
}
|
||||
|
||||
async function startDownload(
|
||||
bvid: string, cid: number, qn: number,
|
||||
qdesc: string, title: string, cover: string,
|
||||
) {
|
||||
const key = taskKey(bvid, qn);
|
||||
if (tasks[key]?.status === 'downloading') return;
|
||||
|
||||
addTask(key, {
|
||||
bvid, title, cover, qn,
|
||||
qdesc: qdesc || QUALITY_LABELS[qn] || String(qn),
|
||||
status: 'downloading', progress: 0, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// 最多重新拉取 URL 并重试一次(应对后台时 URL 过期的情况)
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const success = await attemptDownload(key, bvid, cid, qn);
|
||||
if (success !== 'retry') return;
|
||||
// 需要重试:重新拉取 URL
|
||||
updateTask(key, { status: 'downloading', progress: 0 });
|
||||
lastReportedProgress[key] = -1;
|
||||
}
|
||||
|
||||
updateTask(key, { status: 'error', error: '下载失败,请重试' });
|
||||
}
|
||||
|
||||
/** 执行一次下载尝试。返回 'done' | 'error' | 'retry' */
|
||||
async function attemptDownload(
|
||||
key: string, bvid: string, cid: number, qn: number,
|
||||
): Promise<'done' | 'error' | 'retry'> {
|
||||
try {
|
||||
const url = await getPlayUrlForDownload(bvid, cid, qn);
|
||||
const dest = localPath(bvid, qn);
|
||||
const headers = {};
|
||||
|
||||
const progressCallback = (p: FileSystem.DownloadProgressData) => {
|
||||
const { totalBytesWritten, totalBytesExpectedToWrite } = p;
|
||||
const progress = totalBytesExpectedToWrite > 0
|
||||
? totalBytesWritten / totalBytesExpectedToWrite : 0;
|
||||
const last = lastReportedProgress[key] ?? -1;
|
||||
if (progress - last >= 0.01) {
|
||||
lastReportedProgress[key] = progress;
|
||||
updateTask(key, { progress });
|
||||
}
|
||||
};
|
||||
|
||||
const resumable = FileSystem.createDownloadResumable(url, dest, { headers }, progressCallback);
|
||||
|
||||
// 进入后台时主动暂停,抢在 OS 断连之前
|
||||
let bgPaused = false;
|
||||
const appStateSub = AppState.addEventListener('change', async (state) => {
|
||||
if ((state === 'background' || state === 'inactive') && !bgPaused) {
|
||||
bgPaused = true;
|
||||
try { await resumable.pauseAsync(); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
let result: FileSystem.DownloadResult | null = null;
|
||||
|
||||
try {
|
||||
result = await resumable.downloadAsync();
|
||||
} catch (e: any) {
|
||||
// downloadAsync 抛出:多为后台断连(connection abort)或被 pauseAsync 中断
|
||||
if (!isBackground() && !bgPaused) {
|
||||
// 真实网络错误,非后台原因
|
||||
appStateSub.remove();
|
||||
delete lastReportedProgress[key];
|
||||
const msg = e?.message ?? '下载失败';
|
||||
updateTask(key, { status: 'error', error: msg.length > 40 ? msg.slice(0, 40) + '...' : msg });
|
||||
return 'error';
|
||||
}
|
||||
// 后台引发的中断,走下面的续传逻辑
|
||||
result = null;
|
||||
} finally {
|
||||
appStateSub.remove();
|
||||
}
|
||||
|
||||
// ── 续传逻辑:result 为 null 说明被暂停或中断 ──
|
||||
if (!result?.uri) {
|
||||
// 等 App 回到前台
|
||||
if (isBackground()) await waitForActive();
|
||||
|
||||
// 尝试从断点续传
|
||||
try {
|
||||
result = await resumable.resumeAsync();
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
// 续传仍失败(URL 可能过期),通知上层重试
|
||||
if (!result?.uri) {
|
||||
delete lastReportedProgress[key];
|
||||
return 'retry';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下载完成 ──
|
||||
delete lastReportedProgress[key];
|
||||
const fileSize = await readFileSize(result.uri);
|
||||
updateTask(key, {
|
||||
status: 'done', progress: 1, localUri: result.uri,
|
||||
...(fileSize ? { fileSize } : {}),
|
||||
});
|
||||
return 'done';
|
||||
|
||||
} catch (e: any) {
|
||||
delete lastReportedProgress[key];
|
||||
console.error('[Download] failed:', e);
|
||||
const msg = e?.message ?? '下载失败';
|
||||
updateTask(key, { status: 'error', error: msg.length > 40 ? msg.slice(0, 40) + '...' : msg });
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalUri(bvid: string, qn: number): string | undefined {
|
||||
return tasks[taskKey(bvid, qn)]?.localUri;
|
||||
}
|
||||
|
||||
function cancelDownload(bvid: string, qn: number) {
|
||||
removeTask(taskKey(bvid, qn));
|
||||
}
|
||||
|
||||
return { tasks, startDownload, getLocalUri, cancelDownload, taskKey };
|
||||
}
|
||||
103
hooks/useLiveDanmaku.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getLiveDanmakuHistory } from '../services/api';
|
||||
import type { DanmakuItem } from '../services/types';
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
|
||||
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
|
||||
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
|
||||
|
||||
// 常见礼物名列表,用于匹配
|
||||
const KNOWN_GIFTS = new Set([
|
||||
'辣条', '小心心', '打call', '干杯', '比心',
|
||||
'吃瓜', '花式夸夸', '告白气球', '小电视飞船',
|
||||
]);
|
||||
|
||||
export function useLiveDanmaku(roomId: number): {
|
||||
danmakus: DanmakuItem[];
|
||||
giftCounts: Record<string, number>;
|
||||
} {
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const [giftCounts, setGiftCounts] = useState<Record<string, number>>({});
|
||||
const seenTextsRef = useRef<Set<string>>(new Set());
|
||||
const seenAdminRef = useRef<Set<string>>(new Set());
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
setDanmakus([]);
|
||||
setGiftCounts({});
|
||||
seenTextsRef.current.clear();
|
||||
seenAdminRef.current.clear();
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
|
||||
if (cancelled) return;
|
||||
|
||||
// 去重弹幕
|
||||
const newItems = items.filter(item => {
|
||||
const key = `${item.uname ?? ''}:${item.text}`;
|
||||
if (seenTextsRef.current.has(key)) return false;
|
||||
seenTextsRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
if (newItems.length > 0) {
|
||||
setDanmakus(prev => [...prev, ...newItems]);
|
||||
}
|
||||
|
||||
// 解析 admin 消息中的礼物
|
||||
const newGifts: Record<string, number> = {};
|
||||
for (const msg of adminMsgs) {
|
||||
console.log(msg,123)
|
||||
// 去重 admin 消息
|
||||
if (seenAdminRef.current.has(msg)) continue;
|
||||
seenAdminRef.current.add(msg);
|
||||
|
||||
const match = msg.match(GIFT_PATTERN);
|
||||
if (match) {
|
||||
const giftName = match[1].trim();
|
||||
const count = parseInt(match[2], 10);
|
||||
if (KNOWN_GIFTS.has(giftName) && count > 0) {
|
||||
newGifts[giftName] = (newGifts[giftName] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(newGifts).length > 0) {
|
||||
setGiftCounts(prev => {
|
||||
const next = { ...prev };
|
||||
for (const [name, count] of Object.entries(newGifts)) {
|
||||
next[name] = (next[name] ?? 0) + count;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// 防止 seen set 无限增长
|
||||
if (seenTextsRef.current.size > 2000) {
|
||||
const arr = Array.from(seenTextsRef.current);
|
||||
seenTextsRef.current = new Set(arr.slice(-1000));
|
||||
}
|
||||
if (seenAdminRef.current.size > 500) {
|
||||
const arr = Array.from(seenAdminRef.current);
|
||||
seenAdminRef.current = new Set(arr.slice(-250));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[danmaku] poll failed:', e);
|
||||
}
|
||||
if (!cancelled) {
|
||||
timerRef.current = setTimeout(poll, POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
return { danmakus, giftCounts };
|
||||
}
|
||||
68
hooks/useLiveDetail.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/api';
|
||||
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
|
||||
|
||||
interface LiveDetailState {
|
||||
room: LiveRoomDetail | null;
|
||||
anchor: LiveAnchorInfo | null;
|
||||
stream: LiveStreamInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useLiveDetail(roomId: number) {
|
||||
const [state, setState] = useState<LiveDetailState>({
|
||||
room: null,
|
||||
anchor: null,
|
||||
stream: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 用 ref 追踪最新的 roomId,避免 cancelled 闭包问题
|
||||
const latestRoomId = useRef(roomId);
|
||||
latestRoomId.current = roomId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
|
||||
setState({ room: null, anchor: null, stream: null, loading: true, error: null });
|
||||
|
||||
const fetchId = roomId; // 捕获当前 roomId
|
||||
|
||||
async function doFetch() {
|
||||
try {
|
||||
const [room, anchor] = await Promise.all([
|
||||
getLiveRoomDetail(fetchId),
|
||||
getLiveAnchorInfo(fetchId),
|
||||
]);
|
||||
|
||||
// 仅在 roomId 未变化时更新状态(替代 cancelled 模式)
|
||||
if (latestRoomId.current !== fetchId) return;
|
||||
|
||||
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
|
||||
if (room?.live_status === 1) {
|
||||
stream = await getLiveStreamUrl(fetchId);
|
||||
}
|
||||
|
||||
if (latestRoomId.current !== fetchId) return;
|
||||
|
||||
setState({ room, anchor, stream, loading: false, error: null });
|
||||
} catch (e: any) {
|
||||
if (latestRoomId.current !== fetchId) return;
|
||||
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
|
||||
}
|
||||
}
|
||||
|
||||
doFetch();
|
||||
}, [roomId]);
|
||||
|
||||
const changeQuality = useCallback(async (qn: number) => {
|
||||
try {
|
||||
const stream = await getLiveStreamUrl(roomId, qn);
|
||||
setState(prev => ({ ...prev, stream: { ...stream, qn } }));
|
||||
} catch { /* ignore */ }
|
||||
}, [roomId]);
|
||||
|
||||
return { ...state, changeQuality };
|
||||
}
|
||||
59
hooks/useLiveList.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getLiveList } from '../services/api';
|
||||
import type { LiveRoom } from '../services/types';
|
||||
|
||||
export function useLiveList() {
|
||||
const [rooms, setRooms] = useState<LiveRoom[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const pendingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
const areaIdRef = useRef(0);
|
||||
|
||||
const load = useCallback(async (reset = false, parentAreaId?: number) => {
|
||||
if (loadingRef.current) {
|
||||
if (!reset) pendingRef.current = true;
|
||||
return;
|
||||
}
|
||||
loadingRef.current = true;
|
||||
pendingRef.current = false;
|
||||
|
||||
if (parentAreaId !== undefined) {
|
||||
areaIdRef.current = parentAreaId;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
pageRef.current = 1;
|
||||
setRooms([]);
|
||||
}
|
||||
|
||||
const page = pageRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getLiveList(page, areaIdRef.current);
|
||||
setRooms(prev => reset ? data : [...prev, ...data]);
|
||||
pageRef.current = page + 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to load live rooms', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setRefreshing(false);
|
||||
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current = false;
|
||||
load();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback((parentAreaId?: number) => {
|
||||
setRefreshing(true);
|
||||
load(true, parentAreaId);
|
||||
}, [load]);
|
||||
|
||||
return { rooms, loading, refreshing, load, refresh };
|
||||
}
|
||||
26
hooks/useRelatedVideos.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getVideoRelated } from '../services/api';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useRelatedVideos(bvid: string) {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getVideoRelated(bvid);
|
||||
setVideos(data);
|
||||
} catch (e) {
|
||||
console.warn('useRelatedVideos: failed', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [bvid]);
|
||||
|
||||
return { videos, loading, load, hasMore: false };
|
||||
}
|
||||
133
hooks/useSearch.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { searchVideos, getSearchSuggest, getHotSearch } from '../services/api';
|
||||
import type { VideoItem, SearchSuggestItem, HotSearchItem } from '../services/types';
|
||||
|
||||
const HISTORY_KEY = 'search_history';
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export type SearchSort = 'default' | 'pubdate' | 'view';
|
||||
|
||||
async function loadHistory(): Promise<string[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(HISTORY_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHistory(history: string[]) {
|
||||
await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
export function useSearch() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState<VideoItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [sort, setSort] = useState<SearchSort>('default');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestItem[]>([]);
|
||||
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
|
||||
const loadingRef = useRef(false);
|
||||
const suggestTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentSort = useRef<SearchSort>('default');
|
||||
|
||||
// Load history & hot searches on mount
|
||||
useEffect(() => {
|
||||
loadHistory().then(setHistory);
|
||||
getHotSearch().then(setHotSearches);
|
||||
}, []);
|
||||
|
||||
// Debounced suggestions
|
||||
useEffect(() => {
|
||||
if (suggestTimer.current) clearTimeout(suggestTimer.current);
|
||||
if (!keyword.trim() || keyword.trim().length < 1) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
suggestTimer.current = setTimeout(async () => {
|
||||
const items = await getSearchSuggest(keyword.trim());
|
||||
setSuggestions(items);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (suggestTimer.current) clearTimeout(suggestTimer.current);
|
||||
};
|
||||
}, [keyword]);
|
||||
|
||||
const addToHistory = useCallback(async (kw: string) => {
|
||||
const trimmed = kw.trim();
|
||||
if (!trimmed) return;
|
||||
setHistory(prev => {
|
||||
const filtered = prev.filter(h => h !== trimmed);
|
||||
const next = [trimmed, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromHistory = useCallback(async (kw: string) => {
|
||||
setHistory(prev => {
|
||||
const next = prev.filter(h => h !== kw);
|
||||
saveHistory(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearHistory = useCallback(async () => {
|
||||
setHistory([]);
|
||||
await AsyncStorage.removeItem(HISTORY_KEY);
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async (kw: string, reset = false, sortOverride?: SearchSort) => {
|
||||
if (!kw.trim() || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setSuggestions([]);
|
||||
const activeSort = sortOverride ?? currentSort.current;
|
||||
const currentPage = reset ? 1 : page;
|
||||
const orderParam = activeSort === 'pubdate' ? 'pubdate' : activeSort === 'view' ? 'click' : '';
|
||||
try {
|
||||
const items = await searchVideos(kw, currentPage, orderParam);
|
||||
if (reset) {
|
||||
setResults(items);
|
||||
setPage(2);
|
||||
addToHistory(kw);
|
||||
} else {
|
||||
setResults(prev => [...prev, ...items]);
|
||||
setPage(p => p + 1);
|
||||
}
|
||||
setHasMore(items.length >= 20);
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, addToHistory]);
|
||||
|
||||
const changeSort = useCallback((newSort: SearchSort) => {
|
||||
setSort(newSort);
|
||||
currentSort.current = newSort;
|
||||
if (keyword.trim()) {
|
||||
search(keyword, true, newSort);
|
||||
}
|
||||
}, [keyword, search]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!keyword.trim() || loadingRef.current || !hasMore) return;
|
||||
search(keyword, false);
|
||||
}, [keyword, hasMore, search]);
|
||||
|
||||
return {
|
||||
keyword, setKeyword,
|
||||
results, loading, hasMore,
|
||||
search, loadMore,
|
||||
sort, changeSort,
|
||||
history, removeFromHistory, clearHistory,
|
||||
suggestions,
|
||||
hotSearches,
|
||||
};
|
||||
}
|
||||
65
hooks/useVideoDetail.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getVideoDetail, getPlayUrl } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import type { VideoItem, PlayUrlResponse } from '../services/types';
|
||||
|
||||
export function useVideoDetail(bvid: string) {
|
||||
const [video, setVideo] = useState<VideoItem | null>(null);
|
||||
const [playData, setPlayData] = useState<PlayUrlResponse | null>(null);
|
||||
const [qualities, setQualities] = useState<{ qn: number; desc: string }[]>([]);
|
||||
const [currentQn, setCurrentQn] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cidRef = useRef<number>(0);
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
const defaultQn = trafficSaving ? 16 : 126;
|
||||
|
||||
async function fetchPlayData(cid: number, qn: number, updateList = false) {
|
||||
const data = await getPlayUrl(bvid, cid, qn);
|
||||
setPlayData(data);
|
||||
setCurrentQn(data.quality);
|
||||
if (updateList && data.accept_quality?.length) {
|
||||
setQualities(
|
||||
data.accept_quality.map((q, i) => ({
|
||||
qn: q,
|
||||
desc: data.accept_description?.[i] ?? String(q),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeQuality(qn: number) {
|
||||
await fetchPlayData(cidRef.current, qn);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const detail = await getVideoDetail(bvid);
|
||||
setVideo(detail);
|
||||
const cid = detail.pages?.[0]?.cid ?? detail.cid as number;
|
||||
cidRef.current = cid;
|
||||
await fetchPlayData(cid, defaultQn, true);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Load failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (bvid) fetchData();
|
||||
}, [bvid]);
|
||||
|
||||
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
|
||||
useEffect(() => {
|
||||
if (cidRef.current) {
|
||||
fetchPlayData(cidRef.current, defaultQn, true).catch((e) => {
|
||||
console.warn('Failed to refresh quality list after login change:', e);
|
||||
});
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
return { video, playData, loading, error, qualities, currentQn, changeQuality };
|
||||
}
|
||||
55
hooks/useVideoList.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { getRecommendFeed, getLiveList } from '../services/api';
|
||||
import type { VideoItem, LiveRoom } from '../services/types';
|
||||
|
||||
export function useVideoList() {
|
||||
const [pages, setPages] = useState<VideoItem[][]>([]);
|
||||
const [liveRooms, setLiveRooms] = useState<LiveRoom[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Use refs to avoid stale closures — load() has stable identity
|
||||
const loadingRef = useRef(false);
|
||||
const freshIdxRef = useRef(0);
|
||||
|
||||
const load = useCallback(async (reset = false) => {
|
||||
if (loadingRef.current) {
|
||||
if (reset) setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
loadingRef.current = true;
|
||||
const idx = freshIdxRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
|
||||
getRecommendFeed(idx),
|
||||
(reset || idx === 0)
|
||||
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
|
||||
: Promise.resolve([] as LiveRoom[]),
|
||||
];
|
||||
const [data, live] = await Promise.all(promises);
|
||||
setPages(prev => reset ? [data] : [...prev, data]);
|
||||
if (reset || idx === 0) {
|
||||
// Take top 2 by online count
|
||||
const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10);
|
||||
setLiveRooms(sorted);
|
||||
}
|
||||
freshIdxRef.current = idx + 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to load videos', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []); // stable — no stale closure risk
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
console.log('Refreshing video list');
|
||||
setRefreshing(true);
|
||||
load(true);
|
||||
}, [load]);
|
||||
|
||||
const videos = useMemo(() => pages.flat(), [pages]);
|
||||
return { videos, pages, liveRooms, loading, refreshing, load, refresh };
|
||||
}
|
||||
8
index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
36
metro.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const path = require('path');
|
||||
const { getSentryExpoConfig } = require("@sentry/react-native/metro");
|
||||
|
||||
const config = getSentryExpoConfig(__dirname);
|
||||
|
||||
// Ensure shims directory is watched by Metro
|
||||
config.watchFolders = [...(config.watchFolders ?? []), path.resolve(__dirname, 'shims')];
|
||||
|
||||
const originalResolveRequest = config.resolver.resolveRequest;
|
||||
|
||||
const WEB_SHIMS = {
|
||||
'react-native-pager-view': 'shims/react-native-pager-view.web.tsx',
|
||||
'@sentry/react-native': 'shims/sentry-react-native.web.tsx',
|
||||
'@dr.pogodin/react-native-static-server': 'shims/react-native-static-server.web.ts',
|
||||
'expo-network': 'shims/expo-network.web.ts',
|
||||
'expo-intent-launcher': 'shims/expo-intent-launcher.web.ts',
|
||||
'react-native-video': 'shims/react-native-video.web.tsx',
|
||||
'expo-file-system': 'shims/expo-file-system.web.ts',
|
||||
'expo-file-system/legacy': 'shims/expo-file-system.web.ts',
|
||||
'expo-clipboard': 'shims/expo-clipboard.web.ts',
|
||||
};
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (platform === 'web' && WEB_SHIMS[moduleName]) {
|
||||
return {
|
||||
filePath: path.resolve(__dirname, WEB_SHIMS[moduleName]),
|
||||
type: 'sourceFile',
|
||||
};
|
||||
}
|
||||
if (originalResolveRequest) {
|
||||
return originalResolveRequest(context, moduleName, platform);
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
10257
package-lock.json
generated
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "jkvideo",
|
||||
"version": "1.0.17",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"proxy": "node dev-proxy.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dr.pogodin/react-native-static-server": "^0.26.0",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@sentry/react-native": "~7.11.0",
|
||||
"axios": "^1.13.6",
|
||||
"expo": "~55.0.5",
|
||||
"expo-av": "^16.0.8",
|
||||
"expo-clipboard": "~55.0.9",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-dev-client": "~55.0.11",
|
||||
"expo-file-system": "~55.0.10",
|
||||
"expo-image": "~55.0.6",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "~55.0.8",
|
||||
"expo-media-library": "~55.0.10",
|
||||
"expo-network": "~55.0.9",
|
||||
"expo-router": "~55.0.4",
|
||||
"expo-screen-orientation": "~55.0.8",
|
||||
"expo-secure-store": "~55.0.9",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "~55.0.9",
|
||||
"pako": "^2.1.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-pager-view": "8.0.0",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.2",
|
||||
"express": "^4.22.1",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
BIN
public/alipay.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/p1.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/p2.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/p3.jpg
Normal file
|
After Width: | Height: | Size: 939 KiB |
BIN
public/p4.jpg
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
public/p5.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/p6.jpg
Normal file
|
After Width: | Height: | Size: 856 KiB |
BIN
public/wxpay.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
37
scripts/bump-version.js
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
// Read app.json
|
||||
const appJsonPath = path.join(root, 'app.json');
|
||||
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
|
||||
const currentVersion = appJson.expo.version;
|
||||
|
||||
// Parse semver
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
parts[2] += 1;
|
||||
const newVersion = parts.join('.');
|
||||
const newVersionCode = parts[0] * 10000 + parts[1] * 100 + parts[2];
|
||||
|
||||
// Update app.json
|
||||
appJson.expo.version = newVersion;
|
||||
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
||||
|
||||
// Update package.json
|
||||
const pkgJsonPath = path.join(root, 'package.json');
|
||||
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
||||
pkgJson.version = newVersion;
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n');
|
||||
|
||||
// Update android/app/build.gradle if it exists (skipped in CI before prebuild)
|
||||
const gradlePath = path.join(root, 'android', 'app', 'build.gradle');
|
||||
if (fs.existsSync(gradlePath)) {
|
||||
let gradle = fs.readFileSync(gradlePath, 'utf8');
|
||||
gradle = gradle.replace(/versionCode\s+\d+/, `versionCode ${newVersionCode}`);
|
||||
gradle = gradle.replace(/versionName\s+"[^"]+"/, `versionName "${newVersion}"`);
|
||||
fs.writeFileSync(gradlePath, gradle);
|
||||
}
|
||||
|
||||
console.log(newVersion);
|
||||
153
services/api.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
VideoItem,
|
||||
Comment,
|
||||
PlayUrlResponse,
|
||||
QRCodeInfo,
|
||||
VideoShotData,
|
||||
DanmakuItem,
|
||||
LiveRoom,
|
||||
LiveRoomDetail,
|
||||
LiveAnchorInfo,
|
||||
LiveStreamInfo,
|
||||
SearchSuggestItem,
|
||||
HotSearchItem,
|
||||
} from './types';
|
||||
import {
|
||||
MOCK_VIDEOS,
|
||||
MOCK_VIDEO_DETAIL,
|
||||
MOCK_PLAY_URL,
|
||||
MOCK_LIVE_ROOMS,
|
||||
MOCK_LIVE_DETAIL,
|
||||
MOCK_LIVE_ANCHOR,
|
||||
MOCK_LIVE_STREAM,
|
||||
MOCK_COMMENTS,
|
||||
MOCK_DANMAKU,
|
||||
MOCK_SEARCH_RESULTS,
|
||||
MOCK_HOT_SEARCH,
|
||||
MOCK_SEARCH_SUGGEST,
|
||||
MOCK_UPLOADER_INFO,
|
||||
MOCK_UPLOADER_VIDEOS,
|
||||
MOCK_USER_INFO,
|
||||
MOCK_QR_CODE,
|
||||
PUBLIC_VIDEOS,
|
||||
} from './mockData';
|
||||
|
||||
export async function getRecommendFeed(_freshIdx = 0): Promise<VideoItem[]> {
|
||||
return MOCK_VIDEOS;
|
||||
}
|
||||
|
||||
export async function getPopularVideos(_pn = 1): Promise<VideoItem[]> {
|
||||
return MOCK_VIDEOS;
|
||||
}
|
||||
|
||||
export function getVideoDetail(_bvid: string): Promise<VideoItem> {
|
||||
return Promise.resolve(MOCK_VIDEO_DETAIL);
|
||||
}
|
||||
|
||||
export async function getVideoRelated(_bvid: string): Promise<VideoItem[]> {
|
||||
return MOCK_VIDEOS.slice(5, 15);
|
||||
}
|
||||
|
||||
export function getPlayUrl(_bvid: string, _cid: number, _qn = 64): Promise<PlayUrlResponse> {
|
||||
return Promise.resolve(MOCK_PLAY_URL);
|
||||
}
|
||||
|
||||
export async function getPlayUrlForDownload(
|
||||
_bvid: string,
|
||||
_cid: number,
|
||||
_qn = 64,
|
||||
): Promise<string> {
|
||||
return PUBLIC_VIDEOS[0].url;
|
||||
}
|
||||
|
||||
export async function getUploaderStat(_mid: number): Promise<{ follower: number; archiveCount: number }> {
|
||||
return {
|
||||
follower: MOCK_UPLOADER_INFO.follower,
|
||||
archiveCount: MOCK_UPLOADER_INFO.archiveCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUploaderInfo(_mid: number): Promise<{ name: string; face: string; sign: string; follower: number; archiveCount: number }> {
|
||||
return MOCK_UPLOADER_INFO;
|
||||
}
|
||||
|
||||
export async function getUploaderVideos(_mid: number, pn = 1, ps = 20): Promise<{ videos: VideoItem[]; total: number }> {
|
||||
const allVideos = MOCK_UPLOADER_VIDEOS.videos;
|
||||
const start = (pn - 1) * ps;
|
||||
const videos = allVideos.slice(start, start + ps);
|
||||
return { videos, total: MOCK_UPLOADER_VIDEOS.total };
|
||||
}
|
||||
|
||||
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
|
||||
return MOCK_USER_INFO;
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
_aid: number,
|
||||
_cursor = '',
|
||||
_sort = 2,
|
||||
): Promise<{ replies: Comment[]; nextCursor: string; isEnd: boolean }> {
|
||||
return {
|
||||
replies: MOCK_COMMENTS,
|
||||
nextCursor: '',
|
||||
isEnd: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVideoShot(_bvid: string, _cid: number): Promise<VideoShotData | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateQRCode(): Promise<QRCodeInfo> {
|
||||
return MOCK_QR_CODE;
|
||||
}
|
||||
|
||||
export async function pollQRCode(_qrcode_key: string): Promise<{ code: number; cookie?: string }> {
|
||||
return { code: 0, cookie: 'mock-sessdata-2025' };
|
||||
}
|
||||
|
||||
export async function getLiveList(_page = 1, _parentAreaId = 0): Promise<LiveRoom[]> {
|
||||
return MOCK_LIVE_ROOMS;
|
||||
}
|
||||
|
||||
export async function getLiveRoomDetail(_roomId: number): Promise<LiveRoomDetail> {
|
||||
return MOCK_LIVE_DETAIL;
|
||||
}
|
||||
|
||||
export async function getLiveAnchorInfo(_roomId: number): Promise<LiveAnchorInfo> {
|
||||
return MOCK_LIVE_ANCHOR;
|
||||
}
|
||||
|
||||
export async function getLiveStreamUrl(_roomId: number, _qn = 10000): Promise<LiveStreamInfo> {
|
||||
return MOCK_LIVE_STREAM;
|
||||
}
|
||||
|
||||
export async function searchVideos(_keyword: string, _page = 1, _order = ''): Promise<VideoItem[]> {
|
||||
return MOCK_SEARCH_RESULTS;
|
||||
}
|
||||
|
||||
export async function getLiveDanmakuHistory(_roomId: number): Promise<{
|
||||
danmakus: DanmakuItem[];
|
||||
adminMsgs: string[];
|
||||
}> {
|
||||
return {
|
||||
danmakus: MOCK_DANMAKU.slice(0, 10).map(d => ({ ...d, timeline: '2025-03-26 20:00:00' })),
|
||||
adminMsgs: ['欢迎来到直播间!', '主播正在直播中,请文明发言'],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDanmaku(_cid: number): Promise<DanmakuItem[]> {
|
||||
return MOCK_DANMAKU;
|
||||
}
|
||||
|
||||
export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
|
||||
return MOCK_LIVE_ROOMS.slice(0, 3);
|
||||
}
|
||||
|
||||
export async function getSearchSuggest(_term: string): Promise<SearchSuggestItem[]> {
|
||||
return MOCK_SEARCH_SUGGEST;
|
||||
}
|
||||
|
||||
export async function getHotSearch(): Promise<HotSearchItem[]> {
|
||||
return MOCK_HOT_SEARCH;
|
||||
}
|
||||
291
services/mockData/index.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import type {
|
||||
VideoItem,
|
||||
Comment,
|
||||
PlayUrlResponse,
|
||||
QRCodeInfo,
|
||||
DanmakuItem,
|
||||
LiveRoom,
|
||||
LiveRoomDetail,
|
||||
LiveAnchorInfo,
|
||||
LiveStreamInfo,
|
||||
SearchSuggestItem,
|
||||
HotSearchItem,
|
||||
} from '../types';
|
||||
|
||||
// ─── Public-domain video URLs (Blender Foundation, CC BY 3.0) ────────────────
|
||||
const VIDEOS_BASE = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample';
|
||||
export const PUBLIC_VIDEOS = [
|
||||
{ url: `${VIDEOS_BASE}/BigBuckBunny.mp4`, duration: 596 },
|
||||
{ url: `${VIDEOS_BASE}/ElephantsDream.mp4`, duration: 653 },
|
||||
{ url: `${VIDEOS_BASE}/ForBiggerBlazes.mp4`, duration: 15 },
|
||||
{ url: `${VIDEOS_BASE}/ForBiggerEscapes.mp4`, duration: 15 },
|
||||
{ url: `${VIDEOS_BASE}/SubaruOutbackOnStreetAndDirt.mp4`, duration: 60 },
|
||||
];
|
||||
|
||||
// Public HLS live demo stream
|
||||
export const PUBLIC_HLS_URL = 'https://demo.unified-streaming.com/k8s/live/stable/univision.isml/univision.m3u8';
|
||||
|
||||
// ─── Mock Videos ─────────────────────────────────────────────────────────────
|
||||
const makeVideo = (
|
||||
id: number,
|
||||
title: string,
|
||||
ownerName: string,
|
||||
view: number,
|
||||
duration: number,
|
||||
desc = '',
|
||||
): VideoItem => ({
|
||||
bvid: `BV1mock${String(id).padStart(5, '0')}`,
|
||||
aid: 170000 + id,
|
||||
title,
|
||||
pic: `https://picsum.photos/seed/cover${id}/320/180`,
|
||||
owner: {
|
||||
mid: 1000 + id,
|
||||
name: ownerName,
|
||||
face: `https://picsum.photos/seed/face${id}/80/80`,
|
||||
},
|
||||
stat: {
|
||||
view,
|
||||
danmaku: Math.floor(view * 0.01),
|
||||
reply: Math.floor(view * 0.005),
|
||||
like: Math.floor(view * 0.08),
|
||||
coin: Math.floor(view * 0.03),
|
||||
favorite: Math.floor(view * 0.04),
|
||||
},
|
||||
duration,
|
||||
desc: desc || `${title} - 精彩内容等你来看`,
|
||||
cid: 200000 + id,
|
||||
pages: [{ cid: 200000 + id, part: 'P1' }],
|
||||
});
|
||||
|
||||
export const MOCK_VIDEOS: VideoItem[] = [
|
||||
makeVideo(1, 'React Native 开发实战:从零打造视频 App', '前端小课堂', 892341, 3612, 'React Native + Expo 完整项目开发教程'),
|
||||
makeVideo(2, '2025 最值得买的数码产品 TOP10 盘点', '数码测评君', 1234567, 847),
|
||||
makeVideo(3, '【旅行 Vlog】独自骑行川藏线全记录', '骑行者小明', 2341890, 5432),
|
||||
makeVideo(4, '零基础学 TypeScript:类型系统完全指南', 'Code时间', 543210, 4821),
|
||||
makeVideo(5, '家常红烧肉的正确做法,肥而不腻的秘诀', '厨艺达人', 3456789, 614),
|
||||
makeVideo(6, '【游戏解说】原神:深境螺旋 36 星通关思路', '游戏攻略站', 1876543, 1823),
|
||||
makeVideo(7, '自制小型无人机:从电路到飞行全过程', '创客工坊', 654321, 2156),
|
||||
makeVideo(8, '《流浪地球》系列深度解析:科幻背后的科学', '影视解说', 4321098, 1245),
|
||||
makeVideo(9, '街头摄影技巧:用手机拍出胶片感', '摄影日记', 765432, 731),
|
||||
makeVideo(10, 'Python 爬虫入门到实战:B 站数据分析', '编程达人', 432109, 3024),
|
||||
makeVideo(11, '健身房新手指南:第一个月该如何训练', '运动博主', 987654, 1132),
|
||||
makeVideo(12, '极简主义生活 30 天挑战:我的真实体验', '生活方式', 543210, 892),
|
||||
makeVideo(13, '【科普】量子计算机究竟有多厉害?', '科技前沿', 2109876, 1543),
|
||||
makeVideo(14, '钢琴自学 3 个月:我的进步汇报', '音乐小站', 432109, 423),
|
||||
makeVideo(15, '深夜食堂系列:一个人的火锅', '美食治愈系', 1234567, 512),
|
||||
makeVideo(16, 'Kubernetes 生产环境部署实战', '云原生笔记', 321098, 5421),
|
||||
makeVideo(17, '二次元周边开箱:这次花了多少钱', '动漫爱好者', 876543, 723),
|
||||
makeVideo(18, '城市骑行探店:寻找北京最好吃的煎饼', '骑行探城', 654321, 1823),
|
||||
makeVideo(19, 'After Effects 粒子特效从入门到精通', '视觉创作者', 543210, 4215),
|
||||
makeVideo(20, '古典园林建筑美学:苏州园林深度游', '人文记录', 765432, 2134),
|
||||
];
|
||||
|
||||
// ─── Mock Video Detail (single item with pages) ───────────────────────────────
|
||||
export const MOCK_VIDEO_DETAIL: VideoItem = {
|
||||
...MOCK_VIDEOS[0],
|
||||
pages: [
|
||||
{ cid: 200001, part: '第一集:环境搭建与项目初始化' },
|
||||
{ cid: 200002, part: '第二集:路由系统与页面跳转' },
|
||||
{ cid: 200003, part: '第三集:数据请求与状态管理' },
|
||||
],
|
||||
ugc_season: {
|
||||
id: 5001,
|
||||
title: 'React Native 开发系列',
|
||||
cover: 'https://picsum.photos/seed/season1/320/180',
|
||||
ep_count: 3,
|
||||
sections: [{
|
||||
episodes: [
|
||||
{ aid: 170001, bvid: 'BV1mock00001', cid: 200001, title: '第一集:环境搭建与项目初始化', arc: { pic: 'https://picsum.photos/seed/ep1/320/180', stat: { view: 120000 } } },
|
||||
{ aid: 170002, bvid: 'BV1mock00002', cid: 200002, title: '第二集:路由系统与页面跳转', arc: { pic: 'https://picsum.photos/seed/ep2/320/180', stat: { view: 98000 } } },
|
||||
{ aid: 170003, bvid: 'BV1mock00003', cid: 200003, title: '第三集:数据请求与状态管理', arc: { pic: 'https://picsum.photos/seed/ep3/320/180', stat: { view: 87000 } } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Mock Play URL (Big Buck Bunny MP4) ───────────────────────────────────────
|
||||
export const MOCK_PLAY_URL: PlayUrlResponse = {
|
||||
quality: 64,
|
||||
accept_quality: [64, 32, 16],
|
||||
accept_description: ['720P 高清', '480P 清晰', '360P 流畅'],
|
||||
durl: [
|
||||
{
|
||||
url: PUBLIC_VIDEOS[0].url,
|
||||
length: PUBLIC_VIDEOS[0].duration * 1000,
|
||||
size: 158008374,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Mock Live Rooms ──────────────────────────────────────────────────────────
|
||||
const makeLiveRoom = (id: number, title: string, uname: string, online: number, area: string, parentArea: string): LiveRoom => ({
|
||||
roomid: 10000 + id,
|
||||
uid: 2000 + id,
|
||||
title,
|
||||
uname,
|
||||
face: `https://picsum.photos/seed/lface${id}/80/80`,
|
||||
cover: `https://picsum.photos/seed/lcover${id}/320/180`,
|
||||
online,
|
||||
area_name: area,
|
||||
parent_area_name: parentArea,
|
||||
});
|
||||
|
||||
export const MOCK_LIVE_ROOMS: LiveRoom[] = [
|
||||
makeLiveRoom(1, '【王者荣耀】冲击王者段位!今晚必上!', '游戏达人_阿强', 34521, '王者荣耀', '网游'),
|
||||
makeLiveRoom(2, '手工皮具制作直播 | 今天做一个手包', '皮艺工坊', 8921, '手工', '生活'),
|
||||
makeLiveRoom(3, '午夜 Lo-Fi 音乐陪你学习/工作', 'ChillBeats', 15432, '聊天', '娱乐'),
|
||||
makeLiveRoom(4, '【原神】4.4 版本新角色实机测试', '原神攻略组', 56789, '原神', '手游'),
|
||||
makeLiveRoom(5, '开心农场直播 | 今天种番茄', '农场日记', 3241, '户外', '生活'),
|
||||
makeLiveRoom(6, '前端开发直播 | React 组件库搭建中', '码农老王', 4567, '编程', '科技'),
|
||||
makeLiveRoom(7, '街头篮球挑战赛直播', '球场风云', 23456, '篮球', '体育'),
|
||||
makeLiveRoom(8, '深夜美食 | 自制拉面挑战', '吃货小熊', 12345, '美食', '生活'),
|
||||
makeLiveRoom(9, '油画创作直播 | 荷花池系列第三幅', '画室时光', 5678, '绘画', '知识'),
|
||||
makeLiveRoom(10, '电子音乐制作 | 今天做一首 House', 'DJ小飞', 9876, '音乐', '娱乐'),
|
||||
];
|
||||
|
||||
// ─── Mock Live Detail ─────────────────────────────────────────────────────────
|
||||
export const MOCK_LIVE_DETAIL: LiveRoomDetail = {
|
||||
roomid: 10001,
|
||||
uid: 2001,
|
||||
title: '【王者荣耀】冲击王者段位!今晚必上!',
|
||||
description: '每晚 8 点开播,带你上分,欢迎关注!',
|
||||
live_status: 1,
|
||||
online: 34521,
|
||||
area_name: '王者荣耀',
|
||||
parent_area_name: '网游',
|
||||
keyframe: 'https://picsum.photos/seed/lcover1/320/180',
|
||||
};
|
||||
|
||||
// ─── Mock Live Anchor ─────────────────────────────────────────────────────────
|
||||
export const MOCK_LIVE_ANCHOR: LiveAnchorInfo = {
|
||||
uid: 2001,
|
||||
uname: '游戏达人_阿强',
|
||||
face: 'https://picsum.photos/seed/lface1/80/80',
|
||||
};
|
||||
|
||||
// ─── Mock Live Stream ─────────────────────────────────────────────────────────
|
||||
export const MOCK_LIVE_STREAM: LiveStreamInfo = {
|
||||
hlsUrl: PUBLIC_HLS_URL,
|
||||
flvUrl: '',
|
||||
qn: 10000,
|
||||
qualities: [
|
||||
{ qn: 10000, desc: '原画' },
|
||||
{ qn: 400, desc: '蓝光' },
|
||||
{ qn: 250, desc: '超清' },
|
||||
{ qn: 150, desc: '高清' },
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Mock Comments ────────────────────────────────────────────────────────────
|
||||
const makeComment = (id: number, message: string, uname: string, like: number, ctime: number, replies?: Comment[]): Comment => ({
|
||||
rpid: id,
|
||||
content: { message },
|
||||
member: {
|
||||
uname,
|
||||
avatar: `https://picsum.photos/seed/avatar${id}/80/80`,
|
||||
},
|
||||
like,
|
||||
ctime,
|
||||
replies: replies ?? null,
|
||||
});
|
||||
|
||||
export const MOCK_COMMENTS: Comment[] = [
|
||||
makeComment(1, '这个教程真的太详细了,跟着做了一遍,全部成功!感谢 UP 主!', '学习中的小白', 3421, 1711900800, [
|
||||
makeComment(101, '同感!我也跟着做了,一次就跑通了', '一起学习吧', 156, 1711901000),
|
||||
makeComment(102, '建议 UP 主出续集,期待更多内容', '热心观众', 89, 1711902000),
|
||||
]),
|
||||
makeComment(2, '视频质量非常高,讲解清晰,逻辑连贯,希望能持续更新!', '程序员小张', 2156, 1711800000),
|
||||
makeComment(3, '有一个问题想问:第 15 分钟那里的代码,我运行报错了,能帮看看吗?', '新手求助', 45, 1711700000),
|
||||
makeComment(4, '三刷了,每次都有新收获。这才是真正有价值的内容!', '资深学习者', 1876, 1711600000),
|
||||
makeComment(5, '搭配官方文档一起看效果更好,UP 主解释了很多文档里没写清楚的地方', '老码农', 987, 1711500000),
|
||||
makeComment(6, 'UP 主声音好好听,讲解也好清晰,已一键三连', '铁粉', 765, 1711400000),
|
||||
makeComment(7, '这个项目已经在我们公司用上了,非常实用,谢谢分享!', '公司 CTO', 2345, 1711300000),
|
||||
makeComment(8, '期待下一期!什么时候更新啊', '催更小队', 432, 1711200000),
|
||||
makeComment(9, '讲得太好了,我把链接发给了我所有搞技术的朋友', '传播者', 876, 1711100000),
|
||||
makeComment(10, '从零基础看到现在,终于看懂了!感动到流泪', '逆袭中', 1234, 1711000000),
|
||||
];
|
||||
|
||||
// ─── Mock Danmaku ─────────────────────────────────────────────────────────────
|
||||
export const MOCK_DANMAKU: DanmakuItem[] = [
|
||||
{ time: 3.2, mode: 1, fontSize: 25, color: 0xffffff, text: '开始了开始了!' },
|
||||
{ time: 5.8, mode: 1, fontSize: 25, color: 0x00aeec, text: '这个项目好厉害!' },
|
||||
{ time: 8.1, mode: 1, fontSize: 25, color: 0xffffff, text: '感谢 UP 主分享' },
|
||||
{ time: 12.4, mode: 1, fontSize: 25, color: 0xffdd00, text: '前排占座' },
|
||||
{ time: 15.7, mode: 1, fontSize: 25, color: 0xffffff, text: '这个思路很清晰' },
|
||||
{ time: 20.3, mode: 1, fontSize: 25, color: 0xff6699, text: '哇真的吗!' },
|
||||
{ time: 25.6, mode: 1, fontSize: 25, color: 0xffffff, text: '666666' },
|
||||
{ time: 30.1, mode: 1, fontSize: 25, color: 0x00ff00, text: '学到了学到了' },
|
||||
{ time: 35.9, mode: 1, fontSize: 25, color: 0xffffff, text: '这里有点没懂,多看几遍' },
|
||||
{ time: 42.0, mode: 1, fontSize: 25, color: 0xffd700, text: '牛!!!' },
|
||||
{ time: 48.5, mode: 1, fontSize: 25, color: 0xffffff, text: '已经收藏了' },
|
||||
{ time: 55.2, mode: 1, fontSize: 25, color: 0xff4500, text: '三连了!' },
|
||||
{ time: 62.8, mode: 1, fontSize: 25, color: 0xffffff, text: '讲得比我老师好多了' },
|
||||
{ time: 70.3, mode: 4, fontSize: 25, color: 0x00aeec, text: '精华部分来了' },
|
||||
{ time: 78.9, mode: 1, fontSize: 25, color: 0xffffff, text: '笔记已做好' },
|
||||
{ time: 85.4, mode: 1, fontSize: 25, color: 0xff69b4, text: '太棒了' },
|
||||
{ time: 92.1, mode: 1, fontSize: 25, color: 0xffffff, text: '这部分之前一直不理解' },
|
||||
{ time: 100.6, mode: 1, fontSize: 25, color: 0xadff2f, text: '醍醐灌顶!' },
|
||||
{ time: 110.2, mode: 1, fontSize: 25, color: 0xffffff, text: '求下一期!' },
|
||||
{ time: 120.8, mode: 1, fontSize: 25, color: 0x87ceeb, text: '这才是干货' },
|
||||
{ time: 130.5, mode: 1, fontSize: 25, color: 0xffffff, text: '做了好久的笔记' },
|
||||
{ time: 145.3, mode: 1, fontSize: 25, color: 0xff8c00, text: '弹幕飘过' },
|
||||
{ time: 158.7, mode: 5, fontSize: 25, color: 0xffd700, text: '收藏从未空过' },
|
||||
{ time: 172.4, mode: 1, fontSize: 25, color: 0xffffff, text: '看了三遍终于懂了' },
|
||||
{ time: 185.9, mode: 1, fontSize: 25, color: 0x00aeec, text: '这个项目已经部署上线了' },
|
||||
{ time: 200.2, mode: 1, fontSize: 25, color: 0xffffff, text: '太有用了' },
|
||||
{ time: 215.6, mode: 1, fontSize: 25, color: 0xff1493, text: '冲!' },
|
||||
{ time: 230.1, mode: 1, fontSize: 25, color: 0xffffff, text: '发给朋友了' },
|
||||
{ time: 248.8, mode: 1, fontSize: 25, color: 0x7fffd4, text: '期待续集!' },
|
||||
{ time: 265.3, mode: 4, fontSize: 25, color: 0xffd700, text: '感谢 UP 主!' },
|
||||
];
|
||||
|
||||
// ─── Mock Search ──────────────────────────────────────────────────────────────
|
||||
export const MOCK_SEARCH_RESULTS: VideoItem[] = MOCK_VIDEOS.slice(0, 10);
|
||||
|
||||
export const MOCK_HOT_SEARCH: HotSearchItem[] = [
|
||||
{ keyword: 'React Native 教程', show_name: 'React Native 教程' },
|
||||
{ keyword: '2025 科技趋势', show_name: '2025 科技趋势' },
|
||||
{ keyword: '原神新角色', show_name: '原神新角色' },
|
||||
{ keyword: '零基础学编程', show_name: '零基础学编程' },
|
||||
{ keyword: '街头摄影技巧', show_name: '街头摄影技巧' },
|
||||
{ keyword: '健身入门指南', show_name: '健身入门指南' },
|
||||
{ keyword: '美食制作教程', show_name: '美食制作教程' },
|
||||
{ keyword: '音乐制作软件', show_name: '音乐制作软件' },
|
||||
{ keyword: '旅行 vlog 拍摄', show_name: '旅行 vlog 拍摄' },
|
||||
{ keyword: 'TypeScript 最佳实践', show_name: 'TypeScript 最佳实践' },
|
||||
];
|
||||
|
||||
export const MOCK_SEARCH_SUGGEST: SearchSuggestItem[] = [
|
||||
{ value: 'React Native 开发教程', ref: 1000 },
|
||||
{ value: 'React Native 环境搭建', ref: 800 },
|
||||
{ value: 'React Native 路由跳转', ref: 650 },
|
||||
{ value: 'React Native 性能优化', ref: 500 },
|
||||
{ value: 'React Native 动画效果', ref: 420 },
|
||||
];
|
||||
|
||||
// ─── Mock Uploader ────────────────────────────────────────────────────────────
|
||||
export const MOCK_UPLOADER_INFO = {
|
||||
name: '前端小课堂',
|
||||
face: 'https://picsum.photos/seed/uploader1/80/80',
|
||||
sign: '专注前端开发教学,每周更新高质量视频教程。如果内容对你有帮助,欢迎关注!',
|
||||
follower: 328900,
|
||||
archiveCount: 156,
|
||||
};
|
||||
|
||||
export const MOCK_UPLOADER_VIDEOS = {
|
||||
videos: MOCK_VIDEOS.slice(0, 12),
|
||||
total: 156,
|
||||
};
|
||||
|
||||
// ─── Mock User Info ───────────────────────────────────────────────────────────
|
||||
export const MOCK_USER_INFO = {
|
||||
face: 'https://picsum.photos/seed/myavatar/80/80',
|
||||
uname: '访客用户',
|
||||
mid: 99999,
|
||||
};
|
||||
|
||||
// ─── Mock QR Code ─────────────────────────────────────────────────────────────
|
||||
export const MOCK_QR_CODE: QRCodeInfo = {
|
||||
url: 'https://example.com/mock-login-qr',
|
||||
qrcode_key: 'mock-qrcode-key-2025',
|
||||
};
|
||||
180
services/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export interface VideoItem {
|
||||
bvid: string;
|
||||
aid: number;
|
||||
title: string;
|
||||
pic: string;
|
||||
owner: {
|
||||
mid: number;
|
||||
name: string;
|
||||
face: string;
|
||||
};
|
||||
stat?: {
|
||||
view: number;
|
||||
danmaku: number;
|
||||
reply: number;
|
||||
like: number;
|
||||
coin: number;
|
||||
favorite: number;
|
||||
} | null;
|
||||
duration: number;
|
||||
desc: string;
|
||||
cid?: number;
|
||||
pages?: Array<{ cid: number; part: string }>;
|
||||
goto?: 'av' | 'live';
|
||||
roomid?: number;
|
||||
online?: number;
|
||||
area_name?: string;
|
||||
ugc_season?: {
|
||||
id: number;
|
||||
title: string;
|
||||
cover: string;
|
||||
ep_count: number;
|
||||
sections: Array<{
|
||||
episodes: Array<{
|
||||
aid: number;
|
||||
bvid: string;
|
||||
cid: number;
|
||||
title: string;
|
||||
arc?: { pic: string; stat?: { view: number } };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
rpid: number;
|
||||
content: { message: string };
|
||||
member: {
|
||||
uname: string;
|
||||
avatar: string;
|
||||
};
|
||||
like: number;
|
||||
ctime: number;
|
||||
replies: Comment[] | null;
|
||||
}
|
||||
|
||||
export interface DashSegmentBase {
|
||||
initialization: string;
|
||||
index_range: string;
|
||||
}
|
||||
|
||||
export interface DashVideoItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string;
|
||||
codecs: string;
|
||||
width: number;
|
||||
height: number;
|
||||
stat:any;
|
||||
frameRate: string;
|
||||
segment_base?: DashSegmentBase;
|
||||
dolby_type?: number; // 1=杜比视界
|
||||
hdr_type?: number; // 1=HDR
|
||||
}
|
||||
|
||||
export interface DashAudioItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string;
|
||||
codecs: string;
|
||||
segment_base?: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface PlayUrlResponse {
|
||||
durl?: Array<{
|
||||
url: string;
|
||||
length: number;
|
||||
size: number;
|
||||
}>;
|
||||
dash?: {
|
||||
duration: number;
|
||||
video: DashVideoItem[];
|
||||
audio: DashAudioItem[];
|
||||
};
|
||||
dolby?: {
|
||||
type: number; // 1=杜比全景声
|
||||
audio?: DashAudioItem[];
|
||||
};
|
||||
quality: number;
|
||||
accept_quality: number[];
|
||||
accept_description: string[];
|
||||
}
|
||||
|
||||
export interface QRCodeInfo {
|
||||
url: string;
|
||||
qrcode_key: string;
|
||||
}
|
||||
|
||||
export interface VideoShotData {
|
||||
img_x_len: number;
|
||||
img_y_len: number;
|
||||
img_x_size: number;
|
||||
img_y_size: number;
|
||||
image: string[];
|
||||
index: number[]; // frame index per second: index[t] = frame idx at second t
|
||||
pvdata?: string;
|
||||
}
|
||||
|
||||
export interface DanmakuItem {
|
||||
time: number; // 秒(float),弹幕出现时间
|
||||
mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定
|
||||
fontSize: number;
|
||||
color: number; // 0xRRGGBB 十进制整数
|
||||
text: string;
|
||||
uname?: string;
|
||||
isAdmin?: boolean;
|
||||
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
|
||||
medalLevel?: number;
|
||||
medalName?: string;
|
||||
timeline?: string; // "2024-01-01 12:00:00" 直播弹幕时间戳
|
||||
}
|
||||
|
||||
export interface LiveRoom {
|
||||
roomid: number;
|
||||
uid: number;
|
||||
title: string;
|
||||
uname: string;
|
||||
face: string;
|
||||
cover: string;
|
||||
online: number;
|
||||
area_name: string;
|
||||
parent_area_name: string;
|
||||
}
|
||||
|
||||
export interface LiveRoomDetail {
|
||||
roomid: number;
|
||||
uid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
live_status: number; // 1=直播中, 0=未开播
|
||||
online: number;
|
||||
area_name: string;
|
||||
parent_area_name: string;
|
||||
keyframe: string;
|
||||
}
|
||||
|
||||
export interface LiveAnchorInfo {
|
||||
uid: number;
|
||||
uname: string;
|
||||
face: string;
|
||||
}
|
||||
|
||||
export interface LiveStreamInfo {
|
||||
hlsUrl: string;
|
||||
flvUrl: string;
|
||||
qn: number;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
}
|
||||
|
||||
export interface SearchSuggestItem {
|
||||
value: string;
|
||||
ref: number;
|
||||
}
|
||||
|
||||
export interface HotSearchItem {
|
||||
keyword: string;
|
||||
show_name: string;
|
||||
icon?: string;
|
||||
}
|
||||
7
shims/expo-clipboard.web.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Web shim for expo-clipboard - use native browser clipboard API */
|
||||
export async function setStringAsync(text: string): Promise<void> {
|
||||
try { await navigator.clipboard.writeText(text); } catch {}
|
||||
}
|
||||
export async function getStringAsync(): Promise<string> {
|
||||
try { return await navigator.clipboard.readText(); } catch { return ''; }
|
||||
}
|
||||
14
shims/expo-file-system.web.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Web shim for expo-file-system */
|
||||
export const documentDirectory = '';
|
||||
export const cacheDirectory = '';
|
||||
export async function getInfoAsync(_uri: string) { return { exists: false, isDirectory: false }; }
|
||||
export async function readAsStringAsync(_uri: string) { return ''; }
|
||||
export async function writeAsStringAsync(_uri: string, _contents: string) {}
|
||||
export async function deleteAsync(_uri: string) {}
|
||||
export async function moveAsync(_opts: any) {}
|
||||
export async function copyAsync(_opts: any) {}
|
||||
export async function makeDirectoryAsync(_uri: string) {}
|
||||
export async function getContentUriAsync(_uri: string) { return ''; }
|
||||
export function createDownloadResumable(_url: string, _fileUri: string, _opts?: any, _cb?: any) {
|
||||
return { downloadAsync: async () => ({}) };
|
||||
}
|
||||
5
shims/expo-intent-launcher.web.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Web shim for expo-intent-launcher - no-op for web */
|
||||
export async function startActivityAsync(_activity: string, _params?: unknown): Promise<unknown> {
|
||||
return {};
|
||||
}
|
||||
export const ActivityAction = {};
|
||||
10
shims/expo-network.web.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Web shim for expo-network */
|
||||
export enum NetworkStateType {
|
||||
NONE = 0, UNKNOWN = 1, CELLULAR = 2, WIFI = 3, BLUETOOTH = 4,
|
||||
ETHERNET = 5, WIMAX = 6, VPN = 7, OTHER = 8,
|
||||
}
|
||||
export async function getNetworkStateAsync() {
|
||||
return { isConnected: navigator.onLine, isInternetReachable: navigator.onLine, type: NetworkStateType.UNKNOWN };
|
||||
}
|
||||
export async function getIpAddressAsync(): Promise<string> { return '0.0.0.0'; }
|
||||
export async function isAirplaneModeEnabledAsync(): Promise<boolean> { return false; }
|
||||
49
shims/react-native-pager-view.web.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Web shim for react-native-pager-view.
|
||||
* Supports setPage/setPageWithoutAnimation via imperative handle.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, type ViewStyle } from 'react-native';
|
||||
|
||||
interface PagerViewProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
initialPage?: number;
|
||||
scrollEnabled?: boolean;
|
||||
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
|
||||
onPageScrollStateChanged?: (e: any) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PagerViewHandle {
|
||||
setPage: (page: number) => void;
|
||||
setPageWithoutAnimation: (page: number) => void;
|
||||
}
|
||||
|
||||
const PagerView = React.forwardRef<PagerViewHandle, PagerViewProps>(
|
||||
({ children, style, initialPage = 0, onPageSelected }, ref) => {
|
||||
const [currentPage, setCurrentPage] = React.useState(initialPage);
|
||||
const pages = React.Children.toArray(children);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage(page: number) {
|
||||
setCurrentPage(page);
|
||||
onPageSelected?.({ nativeEvent: { position: page } });
|
||||
},
|
||||
setPageWithoutAnimation(page: number) {
|
||||
setCurrentPage(page);
|
||||
onPageSelected?.({ nativeEvent: { position: page } });
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[{ flex: 1 }, style]}>
|
||||
{pages[currentPage] ?? pages[0] ?? null}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PagerView.displayName = 'PagerView';
|
||||
|
||||
export default PagerView;
|
||||
8
shims/react-native-static-server.web.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Web shim for @dr.pogodin/react-native-static-server - no-op for web */
|
||||
export default class StaticServer {
|
||||
constructor(_port?: number, _root?: string, _options?: unknown) {}
|
||||
start(): Promise<string> { return Promise.resolve(''); }
|
||||
stop(): Promise<void> { return Promise.resolve(); }
|
||||
isRunning(): boolean { return false; }
|
||||
get origin(): string { return ''; }
|
||||
}
|
||||
42
shims/react-native-video.web.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/** Web shim for react-native-video - uses HTML5 <video> element */
|
||||
import React from 'react';
|
||||
|
||||
interface VideoProps {
|
||||
source?: { uri?: string } | number;
|
||||
style?: React.CSSProperties;
|
||||
paused?: boolean;
|
||||
muted?: boolean;
|
||||
repeat?: boolean;
|
||||
onLoad?: (data: unknown) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
onProgress?: (data: unknown) => void;
|
||||
onEnd?: () => void;
|
||||
resizeMode?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const Video = React.forwardRef<HTMLVideoElement, VideoProps>(
|
||||
({ source, style, paused, muted, repeat, onLoad, onError, onProgress, onEnd }, ref) => {
|
||||
const uri = typeof source === 'object' && source !== null ? (source as { uri?: string }).uri : undefined;
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={uri}
|
||||
style={style as React.CSSProperties}
|
||||
autoPlay={!paused}
|
||||
muted={muted}
|
||||
loop={repeat}
|
||||
onLoadedData={onLoad ? () => onLoad({}) : undefined}
|
||||
onError={onError ? (e) => onError(e) : undefined}
|
||||
onTimeUpdate={onProgress ? (e) => {
|
||||
const t = e.currentTarget;
|
||||
onProgress({ currentTime: t.currentTime, seekableDuration: t.duration });
|
||||
} : undefined}
|
||||
onEnded={onEnd}
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Video;
|
||||
30
shims/sentry-react-native.web.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/** Web shim for @sentry/react-native - no-op stubs for web platform */
|
||||
import React from 'react';
|
||||
|
||||
export const init = (_options?: unknown) => {};
|
||||
export const wrap = <T>(component: T): T => component;
|
||||
export const captureException = (_error: unknown, _hint?: unknown) => '';
|
||||
export const captureMessage = (_message: string, _level?: unknown) => '';
|
||||
export const setUser = (_user: unknown) => {};
|
||||
export const setTag = (_key: string, _value: unknown) => {};
|
||||
export const setExtra = (_key: string, _extra: unknown) => {};
|
||||
export const addBreadcrumb = (_breadcrumb: unknown) => {};
|
||||
export const configureScope = (_callback: unknown) => {};
|
||||
export const withScope = (_callback: unknown) => {};
|
||||
export const getCurrentHub = () => ({ getClient: () => undefined });
|
||||
export const ReactNativeTracing = class {};
|
||||
export const ReactNavigationInstrumentation = class {};
|
||||
export const TouchEventBoundary = ({ children }: { children: React.ReactNode }) => children;
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
{ fallback: React.ReactNode; children: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() { return { hasError: true }; }
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default { init, wrap, captureException, captureMessage, setUser, setTag, setExtra };
|
||||
68
store/authStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getUserInfo } from '../services/api';
|
||||
import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
|
||||
|
||||
interface AuthState {
|
||||
sessdata: string | null;
|
||||
uid: string | null;
|
||||
username: string | null;
|
||||
face: string | null;
|
||||
isLoggedIn: boolean;
|
||||
login: (sessdata: string, uid: string, username?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
setProfile: (face: string, username: string, uid: string) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
sessdata: null,
|
||||
uid: null,
|
||||
username: null,
|
||||
face: null,
|
||||
isLoggedIn: false,
|
||||
|
||||
login: async (sessdata, uid, username) => {
|
||||
await setSecure('SESSDATA', sessdata);
|
||||
// Migrate: remove SESSDATA from AsyncStorage if it was there before
|
||||
await AsyncStorage.removeItem('SESSDATA').catch(() => { });
|
||||
set({ sessdata, uid: uid || null, username: username || null, isLoggedIn: true });
|
||||
getUserInfo().then(async (info) => {
|
||||
await AsyncStorage.multiSet([
|
||||
['UID', String(info.mid)],
|
||||
['USERNAME', info.uname],
|
||||
['FACE', info.face],
|
||||
]).catch(() => { });
|
||||
set({ face: info.face, username: info.uname, uid: String(info.mid) });
|
||||
}).catch(() => { });
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await deleteSecure('SESSDATA');
|
||||
await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']);
|
||||
set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false });
|
||||
},
|
||||
|
||||
restore: async () => {
|
||||
// Try SecureStore first, fallback to AsyncStorage for migration
|
||||
let sessdata = await getSecure('SESSDATA');
|
||||
if (!sessdata) {
|
||||
sessdata = await AsyncStorage.getItem('SESSDATA');
|
||||
if (sessdata) {
|
||||
// Migrate from AsyncStorage to SecureStore
|
||||
await setSecure('SESSDATA', sessdata);
|
||||
await AsyncStorage.removeItem('SESSDATA');
|
||||
}
|
||||
}
|
||||
if (sessdata) {
|
||||
set({ sessdata, isLoggedIn: true });
|
||||
try {
|
||||
const info = await getUserInfo();
|
||||
await AsyncStorage.setItem('FACE', info.face);
|
||||
set({ face: info.face, username: info.uname, uid: String(info.mid) });
|
||||
} catch { }
|
||||
}
|
||||
},
|
||||
|
||||
setProfile: (face, username, uid) => set({ face, username, uid }),
|
||||
}));
|
||||
96
store/downloadStore.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface DownloadTask {
|
||||
bvid: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
qn: number;
|
||||
qdesc: string;
|
||||
status: 'downloading' | 'done' | 'error';
|
||||
progress: number; // 0-1
|
||||
fileSize?: number; // bytes
|
||||
localUri?: string;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface DownloadStore {
|
||||
tasks: Record<string, DownloadTask>; // key: `${bvid}_${qn}`
|
||||
hasLoaded: boolean;
|
||||
addTask: (key: string, task: DownloadTask) => void;
|
||||
updateTask: (key: string, patch: Partial<DownloadTask>) => void;
|
||||
removeTask: (key: string) => void;
|
||||
loadFromStorage: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'download_tasks';
|
||||
|
||||
export const useDownloadStore = create<DownloadStore>((set, get) => ({
|
||||
tasks: {},
|
||||
hasLoaded: false,
|
||||
|
||||
addTask: (key, task) => {
|
||||
const next = { ...get().tasks, [key]: task };
|
||||
set({ tasks: next });
|
||||
persistTasks(next);
|
||||
},
|
||||
|
||||
updateTask: (key, patch) => {
|
||||
set((s) => {
|
||||
const prev = s.tasks[key];
|
||||
if (!prev) return s;
|
||||
const updated = { ...prev, ...patch };
|
||||
const next = { ...s.tasks, [key]: updated };
|
||||
// 只在状态/localUri变化时持久化,progress 不持久化(避免高频写 AsyncStorage)
|
||||
if (patch.status !== undefined || patch.localUri !== undefined || patch.error !== undefined || patch.fileSize !== undefined) {
|
||||
persistTasks(next);
|
||||
}
|
||||
return { tasks: next };
|
||||
});
|
||||
},
|
||||
|
||||
removeTask: (key) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tasks };
|
||||
delete next[key];
|
||||
persistTasks(next);
|
||||
return { tasks: next };
|
||||
});
|
||||
},
|
||||
|
||||
loadFromStorage: async () => {
|
||||
// 已加载过则跳过,防止重复调用覆盖正在进行的下载状态
|
||||
if (get().hasLoaded) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const saved: Record<string, DownloadTask> = JSON.parse(raw);
|
||||
// 重启后 downloading 任务无法续传,标记为 error
|
||||
Object.keys(saved).forEach((k) => {
|
||||
if (saved[k].status === 'downloading') {
|
||||
saved[k].status = 'error';
|
||||
saved[k].error = '下载被中断,请重试';
|
||||
saved[k].progress = 0;
|
||||
}
|
||||
});
|
||||
set({ tasks: saved, hasLoaded: true });
|
||||
} else {
|
||||
set({ hasLoaded: true });
|
||||
}
|
||||
} catch {
|
||||
set({ hasLoaded: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function persistTasks(tasks: Record<string, DownloadTask>) {
|
||||
// 只持久化 done/error,downloading 重启后无法续传无需保存
|
||||
const toSave: Record<string, DownloadTask> = {};
|
||||
Object.keys(tasks).forEach((k) => {
|
||||
if (tasks[k].status !== 'downloading') {
|
||||
toSave[k] = tasks[k];
|
||||
}
|
||||
});
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)).catch(() => {});
|
||||
}
|
||||
23
store/liveStore.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface LiveStore {
|
||||
isActive: boolean;
|
||||
roomId: number;
|
||||
title: string;
|
||||
cover: string; // room.keyframe(直播截图)用于 web 端降级封面
|
||||
hlsUrl: string;
|
||||
setLive: (roomId: number, title: string, cover: string, hlsUrl: string) => void;
|
||||
clearLive: () => void;
|
||||
}
|
||||
|
||||
export const useLiveStore = create<LiveStore>(set => ({
|
||||
isActive: false,
|
||||
roomId: 0,
|
||||
title: '',
|
||||
cover: '',
|
||||
hlsUrl: '',
|
||||
setLive: (roomId, title, cover, hlsUrl) =>
|
||||
set({ isActive: true, roomId, title, cover, hlsUrl }),
|
||||
clearLive: () =>
|
||||
set({ isActive: false, roomId: 0, title: '', cover: '', hlsUrl: '' }),
|
||||
}));
|
||||
36
store/settingsStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface SettingsState {
|
||||
darkMode: boolean;
|
||||
trafficSaving: boolean;
|
||||
setDarkMode: (v: boolean) => Promise<void>;
|
||||
setTrafficSaving: (v: boolean) => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
darkMode: false,
|
||||
trafficSaving: false,
|
||||
|
||||
setDarkMode: async (v) => {
|
||||
await AsyncStorage.setItem('DARK_MODE', v ? '1' : '0');
|
||||
set({ darkMode: v });
|
||||
},
|
||||
|
||||
setTrafficSaving: async (v) => {
|
||||
await AsyncStorage.setItem('TRAFFIC_SAVING', v ? '1' : '0');
|
||||
set({ trafficSaving: v });
|
||||
},
|
||||
|
||||
restore: async () => {
|
||||
const [dm, ts] = await Promise.all([
|
||||
AsyncStorage.getItem('DARK_MODE'),
|
||||
AsyncStorage.getItem('TRAFFIC_SAVING'),
|
||||
]);
|
||||
set({
|
||||
darkMode: dm === '1',
|
||||
trafficSaving: ts === '1',
|
||||
});
|
||||
},
|
||||
}));
|
||||
19
store/videoStore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface VideoStore {
|
||||
isActive: boolean;
|
||||
bvid: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
setVideo: (bvid: string, title: string, cover: string) => void;
|
||||
clearVideo: () => void;
|
||||
}
|
||||
|
||||
export const useVideoStore = create<VideoStore>(set => ({
|
||||
isActive: false,
|
||||
bvid: '',
|
||||
title: '',
|
||||
cover: '',
|
||||
setVideo: (bvid, title, cover) => set({ isActive: true, bvid, title, cover }),
|
||||
clearVideo: () => set({ isActive: false }),
|
||||
}));
|
||||
10
test-cmd.ps1
Normal file
@@ -0,0 +1,10 @@
|
||||
$env:PATH = "C:\nvm4w\nodejs;" + $env:PATH
|
||||
subst Z: "C:\Users\Administrator\Desktop\claude code studly\reactJKVideoApp" 2>$null
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
Write-Host "=== Testing autolinking from Z:\android ===" -ForegroundColor Yellow
|
||||
$result = & cmd /c "cd /d Z:\android && node --no-warnings --eval `"require('expo/bin/autolinking')`" expo-modules-autolinking resolve --platform android --json 2>&1"
|
||||
Write-Host "Exit code: $LASTEXITCODE"
|
||||
$result | Select-Object -First 10 | Write-Host
|
||||
|
||||
subst Z: /D 2>$null
|
||||
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
27
utils/buildMpd.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// 构建 DASH MPD 文件内容
|
||||
export function buildMpd(
|
||||
videoUrl: string,
|
||||
videoCodecs: string,
|
||||
videoBandwidth: number,
|
||||
audioUrl: string,
|
||||
audioCodecs: string,
|
||||
audioBandwidth: number,
|
||||
): string {
|
||||
return `<?xml version="1.0"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" type="static" mediaPresentationDuration="PT9999S">
|
||||
<Period>
|
||||
<AdaptationSet contentType="video" mimeType="video/mp4" segmentAlignment="true">
|
||||
<Representation id="v1" codecs="${videoCodecs}" bandwidth="${videoBandwidth}">
|
||||
<BaseURL>${videoUrl}</BaseURL>
|
||||
<SegmentBase><Initialization range="0-999"/></SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet contentType="audio" mimeType="audio/mp4">
|
||||
<Representation id="a1" codecs="${audioCodecs}" bandwidth="${audioBandwidth}">
|
||||
<BaseURL>${audioUrl}</BaseURL>
|
||||
<SegmentBase><Initialization range="0-999"/></SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>`;
|
||||
}
|
||||
63
utils/cache.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
/** Get total size (bytes) of a directory recursively */
|
||||
async function dirSize(dirUri: string): Promise<number> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(dirUri);
|
||||
if (!info.exists) return 0;
|
||||
const entries = await FileSystem.readDirectoryAsync(dirUri);
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
const entryUri = dirUri.endsWith('/') ? `${dirUri}${entry}` : `${dirUri}/${entry}`;
|
||||
const entryInfo = await FileSystem.getInfoAsync(entryUri, { size: true });
|
||||
if (!entryInfo.exists) continue;
|
||||
if (entryInfo.isDirectory) {
|
||||
total += await dirSize(entryUri);
|
||||
} else {
|
||||
total += (entryInfo as any).size ?? 0;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format bytes to human-readable string */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Calculate image cache size (expo-image uses its own cache dirs) */
|
||||
export async function getImageCacheSize(): Promise<number> {
|
||||
if (Platform.OS === 'web') return 0;
|
||||
const cacheDir = FileSystem.cacheDirectory ?? '';
|
||||
return dirSize(cacheDir);
|
||||
}
|
||||
|
||||
/** Clear expo-image disk cache + expo-file-system cache directory */
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
if (Platform.OS === 'web') return;
|
||||
try {
|
||||
const { Image } = require('expo-image');
|
||||
await Image.clearDiskCache();
|
||||
await Image.clearMemoryCache();
|
||||
} catch {}
|
||||
// Also clear the general cache directory (MPD files, QR codes, etc.)
|
||||
try {
|
||||
const cacheDir = FileSystem.cacheDirectory ?? '';
|
||||
const entries = await FileSystem.readDirectoryAsync(cacheDir);
|
||||
await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const uri = `${cacheDir}${entry}`;
|
||||
try {
|
||||
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
26
utils/danmaku.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { DanmakuItem } from '../services/types';
|
||||
|
||||
export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
||||
const re = /<d p="([^"]+)">([^<]*)<\/d>/g;
|
||||
const items: DanmakuItem[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
const p = m[1].split(',');
|
||||
if (p.length < 4) continue;
|
||||
const time = parseFloat(p[0]);
|
||||
const mode = parseInt(p[1], 10);
|
||||
const text = m[2]
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').trim();
|
||||
if (!text || isNaN(time)) continue;
|
||||
if (mode !== 1 && mode !== 4 && mode !== 5) continue;
|
||||
items.push({ time, mode: mode as 1|4|5, fontSize: parseInt(p[2],10), color: parseInt(p[3],10), text });
|
||||
}
|
||||
return items.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
|
||||
export function danmakuColorToCss(color: number): string {
|
||||
return '#' + (color >>> 0 & 0xFFFFFF).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||