commit 3f826464961654d78eb351b61034cb89a23580e0 Author: Developer Date: Thu Mar 26 12:15:40 2026 +0800 init diff --git a/.claude/commands/add-doc.md b/.claude/commands/add-doc.md new file mode 100644 index 0000000..e41cc9e --- /dev/null +++ b/.claude/commands/add-doc.md @@ -0,0 +1,3 @@ +给代码加注释 $FILE_NAME +1.每一行代码上方加上注释 +2.注释内容需要简洁,使用中文 \ No newline at end of file diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..5a651f9 --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.env b/.env new file mode 100644 index 0000000..5f969f5 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +EXPO_PUBLIC_SENTRY_DSN=https://bdb940f3a950ee46ce8ba651dee9b433@o4511094585819136.ingest.de.sentry.io/4511094601810000 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..34811e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..756f34b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 使用问题 / 求助 + url: https://github.com/tiajinsha/JKVideo/discussions + about: 使用上的疑问请到 Discussions 提问 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e7c235d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..771d440 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## 改动说明 + +> 简要描述本次 PR 做了什么 + +## 改动类型 + +- [ ] Bug 修复 +- [ ] 新功能 +- [ ] 重构(不改变功能) +- [ ] 文档更新 +- [ ] 其他: + +## 关联 Issue + +> 关闭 #(Issue 编号) + +## 测试平台 + +- [ ] Android(Dev Build) +- [ ] Android(Expo Go) +- [ ] iOS +- [ ] Web + +## 截图 / 录屏(如适用) + +## 注意事项 + +- [ ] 代码中无硬编码账号信息(SESSDATA、uid 等) +- [ ] Commit 信息符合 Conventional Commits 规范 +- [ ] 已在本地测试通过 diff --git a/.github/workflows/close-invalid-issues.yml b/.github/workflows/close-invalid-issues.yml new file mode 100644 index 0000000..48fd96d --- /dev/null +++ b/.github/workflows/close-invalid-issues.yml @@ -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'], + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c980a54 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eb0967 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.kiro/steering/commit-convention.md b/.kiro/steering/commit-convention.md new file mode 100644 index 0000000..59eaa51 --- /dev/null +++ b/.kiro/steering/commit-convention.md @@ -0,0 +1,33 @@ +--- +inclusion: always +--- + +# 提交规范(Conventional Commits) + +所有 git commit 必须遵循以下格式: + +``` +(): <描述> + +[可选正文] +``` + +## 类型说明 + +| 类型 | 含义 | +|------|------| +| feat | 新功能 | +| fix | Bug 修复 | +| refactor | 重构(不改变功能) | +| docs | 文档更新 | +| chore | 构建脚本、依赖更新等 | +| style | 代码格式(不影响逻辑) | +| perf | 性能优化 | + +## 示例 + +``` +feat(danmaku): 添加弹幕字体大小设置 +fix(player): 修复 DASH MPD 解析在 Android 12 上崩溃的问题 +docs: 更新 README 快速开始步骤 +``` diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..b81b559 --- /dev/null +++ b/App.tsx @@ -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 ( + + Open up App.tsx to start working on your app! + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f79523b --- /dev/null +++ b/CHANGELOG.md @@ -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 原生播放) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..27508ec --- /dev/null +++ b/CONTRIBUTING.md @@ -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 | 含义 | +|---|---| +| `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) 提问 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d869892 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..d86d7c4 --- /dev/null +++ b/README.en.md @@ -0,0 +1,183 @@ +
+ +JKVideo + +# JKVideo + +**A feature-rich React Native video client** + +*DASH playback · Real-time danmaku · WBI signing · Live streaming · Cross-platform* + +--- + +[![React Native](https://img.shields.io/badge/React_Native-0.83-61DAFB?logo=react)](https://reactnative.dev) +[![Expo](https://img.shields.io/badge/Expo-SDK_55-000020?logo=expo)](https://expo.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript)](https://www.typescriptlang.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Web-lightgrey)](README.en.md) + +[中文](README.md) · [Quick Start](#quick-start) · [Features](#features) · [Contributing](CONTRIBUTING.md) + +
+ +--- + +## Screenshots + + + + + + + + + + + + +

Home · Inline Video · Live Cards

Video Detail · Info · Recommendations

Player · 4K HDR · Quality Switch

Downloads · LAN Share QR Code

Live Tab · Followed Streamers · Categories

Live Room · Real-time Danmaku · Guard Marks
+ +--- + +## 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 + +--- + +
+ +If this project helps you, please give it a ⭐ Star! + +
diff --git a/README.md b/README.md new file mode 100644 index 0000000..adf6326 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +
+ +JKVideo + +# JKVideo + +**高颜值的网络视频播放器 React Native 客户端** + +*A feature-rich Video app with DASH playback, real-time danmaku, WBI signing & live streaming* + +--- + +[![React Native](https://img.shields.io/badge/React_Native-0.83-61DAFB?logo=react)](https://reactnative.dev) +[![Expo](https://img.shields.io/badge/Expo-SDK_55-000020?logo=expo)](https://expo.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript)](https://www.typescriptlang.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Web-lightgrey)](README.md) + +[English](README.en.md) · [快速开始](#快速开始) · [功能亮点](#功能亮点) · [贡献](CONTRIBUTING.md) + +
+ +--- + +## 截图预览 + + + + + + + + + + + + +

首页热门 · 内联视频 · 穿插直播

首页直播 · 关注房间· 分区筛选

直播详情 · 4K HDR · 多清晰度

下载管理 · 局域网分享二维码

直播详情 ·实时弹幕 · 清晰度切换

视频弹幕 · 同步加载
+ +## 演示视频 + +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 + +--- + +
+ +如果这个项目对你有帮助,欢迎点一个 ⭐ Star! + +--- + +## 请作者喝杯咖啡 ☕ + +如果这个项目对你有所帮助,欢迎请作者喝杯咖啡,你的支持是持续开发的最大动力,感谢每一位愿意打赏的朋友! + + + + + + +
+
+ 微信支付 +
+
+ 支付宝 +
+ +
diff --git a/app.json b/app.json new file mode 100644 index 0000000..c7ed80d --- /dev/null +++ b/app.json @@ -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" + } +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..d836add --- /dev/null +++ b/app/_layout.tsx @@ -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 ( + + + + 发生错误,请重启 App}> + + + + + + + + + + + + + + + ); +} + +export default Sentry.wrap(RootLayout); diff --git a/app/creator/[mid].tsx b/app/creator/[mid].tsx new file mode 100644 index 0000000..643e2f8 --- /dev/null +++ b/app/creator/[mid].tsx @@ -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([]); + 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 ( + + {/* Top bar */} + + router.back()} style={styles.backBtn}> + + + + {info?.name ?? 'UP主主页'} + + + + + item.bvid} + showsVerticalScrollIndicator={false} + onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }} + onEndReachedThreshold={0.3} + windowSize={7} + maxToRenderPerBatch={6} + removeClippedSubviews + ListHeaderComponent={ + infoLoading ? ( + + ) : info ? ( + + + {info.name} + {info.sign ? ( + {info.sign} + ) : null} + + + {formatCount(info.follower)} + 粉丝 + + + + {formatCount(info.archiveCount)} + 视频 + + + + 全部视频({total}) + + + ) : null + } + renderItem={({ item }) => ( + router.push(`/video/${item.bvid}` as any)} + activeOpacity={0.85} + > + + + + {formatDuration(item.duration)} + + + + + {item.title} + + + + {formatCount(item.stat.view)} + + + + )} + ListEmptyComponent={ + !loading && !infoLoading ? ( + 暂无视频 + ) : null + } + ListFooterComponent={ + loading ? : null + } + /> + + ); +} + +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 }, +}); diff --git a/app/creator/_layout.tsx b/app/creator/_layout.tsx new file mode 100644 index 0000000..59b0236 --- /dev/null +++ b/app/creator/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function CreatorLayout() { + return ; +} diff --git a/app/downloads.tsx b/app/downloads.tsx new file mode 100644 index 0000000..dd4e86d --- /dev/null +++ b/app/downloads.tsx @@ -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(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 ( + + + router.back()} style={styles.backBtn}> + + + 我的下载 + + + + {sections.length === 0 ? ( + + + 暂无下载记录 + + ) : ( + item.key} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item }) => ( + { + 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={() => ( + + )} + contentContainerStyle={{ paddingBottom: 32 }} + /> + )} + + setShareTask(null)} + /> + + {/* Local file player modal */} + + + + ); +} + +function DownloadRow({ + task, + theme, + onPlay, + onDelete, + onShare, + onRetry, +}: { + task: DownloadTask & { key: string }; + theme: ReturnType; + 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 = ( + + + + {task.title} + + {task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''} + + {isDownloading && ( + + + + + {Math.round(task.progress * 100)}% + + )} + {isError && ( + + {task.error ?? '下载失败'} + + 重新下载 + + + )} + + + {isDone && ( + + + + )} + + + + + + ); + + if (isDone) { + return ( + + {rowContent} + + ); + } + + 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 }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..6c50bda --- /dev/null +++ b/app/index.tsx @@ -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("hot"); + const [liveAreaId, setLiveAreaId] = useState(0); + + const theme = useTheme(); + const [visibleBigKey, setVisibleBigKey] = useState(null); + const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); + const pagerRef = useRef(null); + + const hotListRef = useRef(null); + const liveListRef = useRef(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 ( + router.push(`/video/${row.item.bvid}` as any)} + /> + ); + } + if (row.type === "live") { + return ( + + + router.push(`/live/${row.left.roomid}` as any)} + /> + + {row.right && ( + + router.push(`/live/${row.right!.roomid}` as any)} + /> + + )} + + ); + } + const right = row.right; + return ( + + + router.push(`/video/${row.left.bvid}` as any)} + /> + + {right && ( + + router.push(`/video/${right.bvid}` as any)} + /> + + )} + + ); + }, []); + + const renderLiveItem = useCallback( + ({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => ( + + + router.push(`/live/${item.left.roomid}` as any)} + /> + + {item.right && ( + + router.push(`/live/${item.right!.roomid}` as any)} + /> + + )} + + ), + [], + ); + + // 将直播列表分成两列的行 + 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 ( + + {/* 滑动切换容器 */} + + {/* 热门列表 */} + + + 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={ + + } + onEndReached={() => load()} + onEndReachedThreshold={0.5} + extraData={visibleBigKey} + viewabilityConfig={VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChangedRef} + ListFooterComponent={ + + {loading && } + + } + onScroll={onScroll} + scrollEventThrottle={16} + windowSize={7} + maxToRenderPerBatch={6} + removeClippedSubviews={true} + /> + + + {/* 直播列表 */} + + + `live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}` + } + contentContainerStyle={{ + paddingTop: insets.top + NAV_H + 6, + paddingBottom: insets.bottom + 16, + }} + renderItem={renderLiveItem} + ListHeaderComponent={ + + + + {LIVE_AREAS.map((area) => ( + handleLiveAreaPress(area.id)} + activeOpacity={0.7} + > + + {area.name} + + + ))} + + + } + refreshControl={ + liveRefresh(liveAreaId)} + progressViewOffset={insets.top + NAV_H} + /> + } + onEndReached={() => liveLoad()} + onEndReachedThreshold={1.5} + ListFooterComponent={ + liveLoading ? ( + + + 加载中... + + ) : null + } + onScroll={onLiveScroll} + scrollEventThrottle={16} + windowSize={7} + maxToRenderPerBatch={6} + removeClippedSubviews={true} + /> + + + + {/* 绝对定位导航栏 */} + + + + (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))} + > + {isLoggedIn && face ? ( + + ) : ( + + )} + + + router.push("/search" as any)} + activeOpacity={0.7} + > + + 搜索视频、UP主... + + router.push("/downloads" as any)} + /> + + + + {TABS.map((tab) => ( + handleTabPress(tab.key)} + activeOpacity={0.7} + > + + {tab.label} + + {activeTab === tab.key && } + + ))} + + + + setShowLogin(false)} /> + + ); +} + +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", + }, +}); diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx new file mode 100644 index 0000000..d74001c --- /dev/null +++ b/app/live/[roomId].tsx @@ -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("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 ( + + {/* TopBar */} + + router.back()} style={styles.backBtn}> + + + + {room?.title ?? "直播间"} + + {isLive && hlsUrl ? ( + { + setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl); + router.back(); + }} + > + + + ) : ( + + )} + + + {/* Player */} + + + {/* TabBar */} + + setTab("intro")} + > + + 简介 + + {tab === "intro" && } + + setTab("danmaku")} + > + + 弹幕{danmakus.length > 0 ? ` ${danmakus.length}` : ""} + + {tab === "danmaku" && } + + + + {/* Content */} + {loading ? ( + + ) : error ? ( + {error} + ) : ( + <> + + + {room?.title} + + {isLive ? ( + + + 直播中 + + ) : ( + + + 未开播 + + + )} + + + + {formatCount(room?.online ?? 0)} + + + + + {room?.parent_area_name ? ( + {room.parent_area_name} + ) : null} + {room?.area_name ? ( + {room.area_name} + ) : null} + + + + + + {anchor && ( + + + {anchor.uname} + + + 关注 + + + )} + + {!!room?.description && ( + + {room.description} + + )} + + + {}} + style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]} + hideHeader + isLive + maxItems={500} + giftCounts={giftCounts} + /> + + )} + + ); +} + +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" }, +}); diff --git a/app/live/_layout.tsx b/app/live/_layout.tsx new file mode 100644 index 0000000..7f4479d --- /dev/null +++ b/app/live/_layout.tsx @@ -0,0 +1,5 @@ +import { Slot } from 'expo-router'; + +export default function LiveLayout() { + return ; +} diff --git a/app/search.tsx b/app/search.tsx new file mode 100644 index 0000000..e076e00 --- /dev/null +++ b/app/search.tsx @@ -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(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 ( + + + router.push(`/video/${item.bvid}` as any)} + /> + + {right ? ( + + router.push(`/video/${right.bvid}` as any)} + /> + + ) : ( + + )} + + ); + }, + [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 ( + + {SORT_OPTIONS.map(opt => ( + changeSort(opt.key)} + activeOpacity={0.85} + > + + {opt.label} + + + ))} + + ); + }, [hasResults, sort, changeSort, theme.card]); + + const ListEmptyComponent = () => { + if (loading) return null; + if (!keyword.trim()) return null; + return ( + + + 没有找到相关视频 + + ); + }; + + return ( + + {/* Search header */} + + router.back()} style={styles.backBtn}> + + + + handleSearch()} + returnKeyType="search" + autoFocus + autoCapitalize="none" + autoCorrect={false} + /> + {keyword.length > 0 && ( + setKeyword('')} style={styles.clearBtn}> + + + )} + + handleSearch()} activeOpacity={0.85}> + 搜索 + + + + {/* Suggestions dropdown */} + {showSuggestions && ( + + {suggestions.map((s, i) => ( + handleSuggestionPress(s.value)} + activeOpacity={0.85} + > + + {s.value} + + ))} + + )} + + {/* Pre-search: history + hot searches */} + {showPreSearch && !showSuggestions ? ( + + {/* Search history */} + {history.length > 0 && ( + + + 搜索历史 + + + + + + {history.map(h => ( + handleSearch(h)} + onLongPress={() => removeFromHistory(h)} + activeOpacity={0.85} + > + {h} + + ))} + + + )} + + {/* Hot searches */} + {hotSearches.length > 0 && ( + + 热搜榜 + {hotSearches.map((item, idx) => ( + handleSearch(item.keyword)} + activeOpacity={0.85} + > + + {idx + 1} + + + {item.show_name} + + + ))} + + )} + + {history.length === 0 && hotSearches.length === 0 && ( + + + 输入关键词搜索 + + )} + + ) : ( + /* Results list */ + } + ListEmptyComponent={} + ListFooterComponent={ + loading && results.length > 0 ? ( + + + + ) : null + } + keyboardShouldPersistTaps="handled" + /> + )} + + ); +} + +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' }, +}); diff --git a/app/settings.tsx b/app/settings.tsx new file mode 100644 index 0000000..4e35dd4 --- /dev/null +++ b/app/settings.tsx @@ -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(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 ( + + + router.back()} style={styles.backBtn}> + + + 设置 + + + + + 版本信息 + + 当前版本 + v{currentVersion} + + + + + 更新 + + {isChecking ? ( + <> + + 检查中... + + ) : downloadProgress !== null ? ( + 下载中 {downloadProgress}% + ) : ( + 检查更新 + )} + + + + + 外观 + + setDarkMode(false)} + activeOpacity={0.7} + > + 浅色 + + setDarkMode(true)} + activeOpacity={0.7} + > + 深色 + + + + + + 流量 + + setTrafficSaving(false)} + activeOpacity={0.7} + > + 标准 + + setTrafficSaving(true)} + activeOpacity={0.7} + > + 节流 + + + {trafficSaving && ( + + 封面低画质 · 首页视频不自动播放 · 视频默认 360p + + )} + + + + 存储 + + + 缓存大小 + + {cacheSize === null ? '计算中...' : formatBytes(cacheSize)} + + + + {clearingCache + ? + : 清除缓存 + } + + + + + {isLoggedIn && ( + + 退出登录 + + )} + + ); +} + +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' }, +}); diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx new file mode 100644 index 0000000..b4866c3 --- /dev/null +++ b/app/video/[bvid].tsx @@ -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("intro"); + const [danmakus, setDanmakus] = useState([]); + 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 ( + + {/* TopBar */} + + router.back()} style={styles.backBtn}> + + + + {video?.title ?? "视频详情"} + + setShowDownload(true)} + > + + + + + {/* Video player — fixed 16:9 */} + + setShowDownload(false)} + bvid={bvid as string} + cid={video?.cid ?? 0} + title={video?.title ?? ""} + cover={video?.pic ?? ""} + qualities={qualities} + /> + + {/* TabBar */} + {video && ( + + setTab("intro")} + > + + 简介 + + {tab === "intro" && } + + setTab("comments")} + > + + 评论 + {video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""} + + {tab === "comments" && } + + setTab("danmaku")} + > + + 弹幕 + {danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""} + + {tab === "danmaku" && } + + + )} + + {/* Tab content */} + {videoLoading ? ( + + ) : video ? ( + <> + {tab === "intro" && ( + + style={styles.tabScroll} + data={relatedVideos} + keyExtractor={(item) => item.bvid} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + <> + router.push(`/creator/${video.owner.mid}` as any)} + > + + + + {video.owner.name} + + {uploaderStat && ( + + {formatCount(uploaderStat.follower)}粉丝 ·{" "} + {formatCount(uploaderStat.archiveCount)}视频 + + )} + + + 查看主页 + + + + + {video.title} + + + {video.desc || "暂无简介"} + + + + + + + + + {video.ugc_season && ( + + router.replace(`/video/${epBvid}`) + } + /> + )} + + + 推荐视频 + + + + } + renderItem={({ item }) => ( + router.replace(`/video/${item.bvid}` as any)} + activeOpacity={0.85} + > + + + + + {formatDuration(item.duration)} + + + + + + {item.title} + + + + {item.owner?.name ?? ""} + + + {formatCount(item.stat?.view ?? 0)} 播放 + + + + + )} + ListEmptyComponent={ + relatedLoading ? ( + + ) : null + } + ListFooterComponent={ + relatedLoading ? ( + + ) : null + } + /> + )} + + {tab === "comments" && ( + String(c.rpid)} + renderItem={({ item }) => } + onEndReached={() => { + if (cmtHasMore && !cmtLoading) loadComments(); + }} + onEndReachedThreshold={0.3} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + + setCommentSort(2)} + > + + 热门 + + + setCommentSort(0)} + > + + 最新 + + + + } + ListFooterComponent={ + cmtLoading ? ( + + ) : !cmtHasMore && comments.length > 0 ? ( + 已加载全部评论 + ) : null + } + ListEmptyComponent={ + !cmtLoading ? ( + 暂无评论 + ) : null + } + /> + )} + + {/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */} + {}} + hideHeader={true} + style={[ + styles.danmakuTab, + tab !== "danmaku" && { display: "none" }, + ]} + /> + + ) : null} + + ); +} + +function StatBadge({ icon, count }: { icon: string; count: number }) { + return ( + + + {formatCount(count)} + + ); +} + +function SeasonSection({ + season, + currentBvid, + onEpisodePress, +}: { + season: NonNullable; + 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(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 ( + + + + 合集 · {season.title} + + {season.ep_count}个视频 + + + 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 ( + !isCurrent && onEpisodePress(ep.bvid)} + activeOpacity={0.8} + > + {ep.arc?.pic && ( + + )} + + 第{index + 1}集 + + + {ep.title} + + + ); + }} + /> + + ); +} + +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 }, +}); diff --git a/app/video/_layout.tsx b/app/video/_layout.tsx new file mode 100644 index 0000000..201f2af --- /dev/null +++ b/app/video/_layout.tsx @@ -0,0 +1,5 @@ +import { Slot } from 'expo-router'; + +export default function VideoLayout() { + return ; +} diff --git a/assets/android-icon-background.png b/assets/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/assets/android-icon-background.png differ diff --git a/assets/android-icon-foreground.png b/assets/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/assets/android-icon-foreground.png differ diff --git a/assets/android-icon-monochrome.png b/assets/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/assets/android-icon-monochrome.png differ diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/assets/splash-icon.png differ diff --git a/build-apk.ps1 b/build-apk.ps1 new file mode 100644 index 0000000..c1de5f2 --- /dev/null +++ b/build-apk.ps1 @@ -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 diff --git a/components/BigVideoCard.tsx b/components/BigVideoCard.tsx new file mode 100644 index 0000000..5b87ffd --- /dev/null +++ b/components/BigVideoCard.tsx @@ -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(); + 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(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(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 ( + + {/* Media area */} + + {/* Video player — rendered first so it sits behind the thumbnail */} + {videoUrl && !liveActive && ( +