From 6b51cd0a19681fae8e46b0f09a74fd46482fab03 Mon Sep 17 00:00:00 2001 From: zimplexing Date: Wed, 2 Jul 2025 09:09:35 +0800 Subject: [PATCH] Update --- .cursor/rules/common.mdc | 7 - .gitignore | 3 +- MIGRATION_PLAN.md | 104 -- Plan.md | 417 ----- README.md | 136 +- app.json | 26 +- app/_layout.tsx | 6 + app/detail.tsx | 306 +++- app/index.tsx | 397 ++++- app/play.tsx | 194 +++ app/search.tsx | 175 ++ assets/images/adaptive-icon.png | Bin 17547 -> 0 bytes assets/images/favicon.png | Bin 1466 -> 0 bytes assets/images/icon.png | Bin 22380 -> 51676 bytes assets/images/partial-react-logo.png | Bin 5075 -> 0 bytes assets/images/react-logo.png | Bin 6341 -> 0 bytes assets/images/react-logo@2x.png | Bin 14225 -> 0 bytes assets/images/react-logo@3x.png | Bin 21252 -> 0 bytes assets/images/splash.png | Bin 47346 -> 0 bytes assets/tv_icons/icon-400x240.png | Bin 19447 -> 6239 bytes backend/Dockerfile | 2 +- backend/src/index.docker.ts | 28 + backend/src/index.ts | 4 - backend/src/routes/index.ts | 4 - backend/src/routes/login.ts | 31 - backend/src/routes/playrecords.ts | 49 - backend/src/services/db.ts | 73 - backend/vercel.json | 18 + components/Collapsible.tsx | 45 - components/DetailButton.tsx | 55 + components/EpisodeSelectionModal.tsx | 188 ++ components/EventHandlingDemo.tsx | 246 --- components/ExternalLink.tsx | 24 - components/ExternalLink.tv.tsx | 21 - components/HelloWave.tsx | 40 - components/LoadingOverlay.tsx | 27 + components/MediaButton.tsx | 52 + components/NextEpisodeOverlay.tsx | 59 + components/ParallaxScrollView.tsx | 87 - components/PlayerControls.tsx | 233 +++ components/ScrollableRow.tv.tsx | 67 - components/VideoCard.tv.tsx | 244 ++- hooks/usePlaybackManager.ts | 229 +++ hooks/useTVRemoteHandler.ts | 114 ++ package.json | 10 +- services/api.ts | 20 +- services/m3u8.ts | 55 + web/src/app/aggregate/page.tsx | 250 --- web/src/app/api/detail/route.ts | 212 --- web/src/app/api/douban/route.ts | 205 --- web/src/app/api/login/route.ts | 29 - web/src/app/api/playrecords/route.ts | 72 - web/src/app/api/search/route.ts | 236 --- web/src/app/detail/page.tsx | 347 ---- web/src/app/douban/page.tsx | 270 --- web/src/app/globals.css | 154 -- web/src/app/layout.tsx | 46 - web/src/app/login/page.tsx | 96 -- web/src/app/page.tsx | 257 --- web/src/app/play/page.tsx | 1890 --------------------- web/src/app/search/page.tsx | 267 --- web/src/components/AggregateCard.tsx | 127 -- web/src/components/AuthProvider.tsx | 46 - web/src/components/CapsuleSwitch.tsx | 39 - web/src/components/ContinueWatching.tsx | 135 -- web/src/components/DemoCard.tsx | 114 -- web/src/components/DoubanCardSkeleton.tsx | 21 - web/src/components/MobileBottomNav.tsx | 123 -- web/src/components/MobileHeader.tsx | 25 - web/src/components/PageLayout.tsx | 49 - web/src/components/ScrollableRow.tsx | 165 -- web/src/components/Sidebar.tsx | 293 ---- web/src/components/ThemeProvider.tsx | 18 - web/src/components/ThemeToggle.tsx | 62 - web/src/components/VideoCard.tsx | 284 ---- web/src/lib/config.ts | 71 - web/src/lib/db.client.ts | 483 ------ web/src/lib/db.ts | 202 --- web/src/lib/fetchVideoDetail.ts | 73 - web/src/lib/types.ts | 19 - web/src/lib/utils.ts | 10 - web/src/styles/colors.css | 550 ------ web/src/styles/globals.css | 118 -- xml/AndroidManifest.xml | 40 + xml/res/xml/network_security_config.xml | 6 + yarn.lock | 10 +- 86 files changed, 2440 insertions(+), 8770 deletions(-) delete mode 100644 .cursor/rules/common.mdc delete mode 100644 MIGRATION_PLAN.md delete mode 100644 Plan.md create mode 100644 app/play.tsx create mode 100644 app/search.tsx delete mode 100644 assets/images/adaptive-icon.png delete mode 100644 assets/images/favicon.png delete mode 100644 assets/images/partial-react-logo.png delete mode 100644 assets/images/react-logo.png delete mode 100644 assets/images/react-logo@2x.png delete mode 100644 assets/images/react-logo@3x.png delete mode 100644 assets/images/splash.png create mode 100644 backend/src/index.docker.ts delete mode 100644 backend/src/routes/login.ts delete mode 100644 backend/src/routes/playrecords.ts delete mode 100644 backend/src/services/db.ts create mode 100644 backend/vercel.json delete mode 100644 components/Collapsible.tsx create mode 100644 components/DetailButton.tsx create mode 100644 components/EpisodeSelectionModal.tsx delete mode 100644 components/EventHandlingDemo.tsx delete mode 100644 components/ExternalLink.tsx delete mode 100644 components/ExternalLink.tv.tsx delete mode 100644 components/HelloWave.tsx create mode 100644 components/LoadingOverlay.tsx create mode 100644 components/MediaButton.tsx create mode 100644 components/NextEpisodeOverlay.tsx delete mode 100644 components/ParallaxScrollView.tsx create mode 100644 components/PlayerControls.tsx delete mode 100644 components/ScrollableRow.tv.tsx create mode 100644 hooks/usePlaybackManager.ts create mode 100644 hooks/useTVRemoteHandler.ts create mode 100644 services/m3u8.ts delete mode 100644 web/src/app/aggregate/page.tsx delete mode 100644 web/src/app/api/detail/route.ts delete mode 100644 web/src/app/api/douban/route.ts delete mode 100644 web/src/app/api/login/route.ts delete mode 100644 web/src/app/api/playrecords/route.ts delete mode 100644 web/src/app/api/search/route.ts delete mode 100644 web/src/app/detail/page.tsx delete mode 100644 web/src/app/douban/page.tsx delete mode 100644 web/src/app/globals.css delete mode 100644 web/src/app/layout.tsx delete mode 100644 web/src/app/login/page.tsx delete mode 100644 web/src/app/page.tsx delete mode 100644 web/src/app/play/page.tsx delete mode 100644 web/src/app/search/page.tsx delete mode 100644 web/src/components/AggregateCard.tsx delete mode 100644 web/src/components/AuthProvider.tsx delete mode 100644 web/src/components/CapsuleSwitch.tsx delete mode 100644 web/src/components/ContinueWatching.tsx delete mode 100644 web/src/components/DemoCard.tsx delete mode 100644 web/src/components/DoubanCardSkeleton.tsx delete mode 100644 web/src/components/MobileBottomNav.tsx delete mode 100644 web/src/components/MobileHeader.tsx delete mode 100644 web/src/components/PageLayout.tsx delete mode 100644 web/src/components/ScrollableRow.tsx delete mode 100644 web/src/components/Sidebar.tsx delete mode 100644 web/src/components/ThemeProvider.tsx delete mode 100644 web/src/components/ThemeToggle.tsx delete mode 100644 web/src/components/VideoCard.tsx delete mode 100644 web/src/lib/config.ts delete mode 100644 web/src/lib/db.client.ts delete mode 100644 web/src/lib/db.ts delete mode 100644 web/src/lib/fetchVideoDetail.ts delete mode 100644 web/src/lib/types.ts delete mode 100644 web/src/lib/utils.ts delete mode 100644 web/src/styles/colors.css delete mode 100644 web/src/styles/globals.css create mode 100644 xml/AndroidManifest.xml create mode 100644 xml/res/xml/network_security_config.xml diff --git a/.cursor/rules/common.mdc b/.cursor/rules/common.mdc deleted file mode 100644 index f202b78..0000000 --- a/.cursor/rules/common.mdc +++ /dev/null @@ -1,7 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -- 这是一个react-native android tv项目 -- 请不要修改web/目录下的文件 \ No newline at end of file diff --git a/.gitignore b/.gitignore index f837479..7aa0fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ web-build/ # The following patterns were generated by expo-cli expo-env.d.ts -# @end expo-cli \ No newline at end of file +# @end expo-cli +web/** \ No newline at end of file diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md deleted file mode 100644 index 269f247..0000000 --- a/MIGRATION_PLAN.md +++ /dev/null @@ -1,104 +0,0 @@ -# Web 功能至 React Native TV 应用迁移计划 - -## 1. 项目目标 - -将现有 `web` 项目 (基于 Next.js) 的核心功能,包括视频浏览、搜索、详情查看、播放记录和收藏,完整地迁移到 React Native TV 应用中,并为电视遥控器交互进行深度优化。 - -## 2. 核心策略 - -我们将采用**前后端分离**的核心策略: - -- **后端服务**: 将 `web/src/app/api/` 中的所有 API 逻辑剥离出来,封装成一个独立的、使用 **Express.js** 构建的 Node.js 服务。该服务将通过 **Docker** 进行容器化,部署在您的私有服务器上。 -- **TV 应用 (前端)**: React Native TV 应用将作为纯前端,负责 UI 展示和用户交互。它将通过网络请求调用上述独立的后端服务来获取和提交数据。 -- **数据持久化**: 用户数据(播放记录、收藏等)将从 `localStorage` 迁移到 React Native 的 **`AsyncStorage`**,实现应用内的本地持久化存储。 - -## 3. 架构设计 - -迁移后的系统架构如下所示: - -```mermaid -graph TD - subgraph "React Native TV 应用" - A[用户] -->|遥控器交互| B(TV 应用界面) - B -->|数据请求| C{数据服务层} - end - - subgraph "本地存储" - C -->|读/写| D[AsyncStorage (播放记录, 收藏)] - end - - subgraph "后端服务 (Docker @ 您的服务器)" - E[Express.js API 服务] - end - - subgraph "第三方数据源" - F[视频源 API] - G[豆瓣 API] - end - - C -->|API 请求| E - E -->|代理请求| F - E -->|代理请求| G -``` - -## 4. 分阶段实施计划 - -我们将迁移过程分为四个主要阶段,循序渐进,以确保每个环节都稳固可靠。 - -### 阶段一:后端服务搭建与部署 (Backend First) - -**目标**: 优先保障数据接口的可用性,为后续所有前端工作提供数据基础。 - -- **任务 1**: 在项目根目录创建 `backend/` 文件夹,并初始化一个 Node.js (Express.js) 项目。 -- **任务 2**: 将 `web/src/app/api/` 下的所有 API (search, detail, douban, login, playrecords) 的逻辑迁移到 Express 路由中。 -- **任务 3**: 编写一个高效的 `Dockerfile`,用于构建后端服务的生产环境镜像。 -- **任务 4**: 提供部署指南,说明如何在您的服务器上通过 Docker 启动服务,并配置必要的环境变量(如端口)。 -- **交付物**: 可独立运行和测试的后端 API 服务。 - -### 阶段二:TV 应用基础建设与数据层迁移 - -**目标**: 在 TV 应用中搭建好基础框架,并完成数据存储逻辑的替换。 - -- **任务 1**: 安装必要的依赖库:`@react-native-async-storage/async-storage` (用于本地存储) 和 `react-navigation` (用于页面路由)。 -- **任务 2**: 在 TV 应用中创建 `services/api.ts` 文件,封装所有对新后端服务的 `fetch` 请求。 -- **任务 3**: 在 TV 应用中创建 `services/storage.ts` 文件,使用 `AsyncStorage` 重新实现 `web/src/lib/db.client.ts` 中的所有功能(播放记录、收藏的增删改查)。 -- **交付物**: 一个可以与后端通信并能在本地存取数据的 TV 应用骨架。 - -### 阶段三:核心 UI 组件库迁移 - -**目标**: 自底向上,将 Web 组件重构为适用于 TV 平台的原生组件。 - -- **任务 1**: 识别 `web/src/components` 中的核心可复用组件,如 `VideoCard`、`ScrollableRow`、`AggregateCard` 等。 -- **任务 2**: 在 TV 项目的 `components/` 目录下,创建对应的 TV 版本组件。 - - 将 HTML 标签 (`div`, `img`) 替换为 React Native 组件 (`View`, `Image`)。 - - 将 CSS 样式转换为 React Native 的 `StyleSheet` API。 - - **关键**: 为所有可交互组件添加 `onFocus`, `onBlur` 事件处理,并使用 `Touchable` 组件实现遥控器响应,确保良好的焦点管理和导航体验。 -- **交付物**: 一套专为 TV 优化的、可复用的基础 UI 组件库。 - -### 阶段四:页面路由与功能整合 - -**目标**: 组装所有模块,将应用串联成一个完整、可用的产品。 - -- **任务 1**: 使用 `React Navigation` 库搭建 App 的整体路由结构,需要实现查看更多,详情页面,播放页等 -- **任务 2**: 创建所有核心页面 (Screens),例如 `MoreScreen`, `SearchScreen`, `DetailScreen`, `PlayerScreen`。 -- **任务 3**: 在各个页面中,集成前几个阶段完成的 `services` 和 `components`,调用后端 API 获取数据,并渲染出最终界面。 -- **任务 4**: 对整体应用进行联调测试,重点测试遥控器导航的流畅性和功能的完整性。 -- **交付物**: 功能完整的 React Native TV 应用初版。 - -## 5. 技术栈清单 - -- **TV 应用**: React Native, TypeScript, React Navigation -- **后端服务**: Node.js, Express.js -- **数据存储**: @react-native-async-storage/async-storage -- **部署**: Docker - -## 6. 风险与应对 - -- **风险**: 第三方 API 接口变更或失效。 - - **应对**: 后端服务已将 API 请求集中处理,若有变更,只需修改后端代码,无需更新客户端 App。 -- **风险**: TV 平台(Android TV, tvOS)的 UI/UX 兼容性差异。 - - **应对**: 在开发过程中,优先使用 React Native 官方推荐的跨平台组件。对特定平台的差异,可以使用 `Platform.select()` 进行适配。 - -## 7. 下一步 - -我们已就此计划达成共识。下一步建议是:**切换到“代码”模式**,并开始着手执行**阶段一**的任务,即搭建和迁移独立的后端服务。 diff --git a/Plan.md b/Plan.md deleted file mode 100644 index e242d9a..0000000 --- a/Plan.md +++ /dev/null @@ -1,417 +0,0 @@ -# MoonTV 到 MyTv 迁移可行性分析与方案 - -## 项目概述 - -### MoonTV 项目分析 - -**项目定位**: 基于 Next.js 的 Web 端影视聚合播放器 - -**核心技术栈**: - -- **前端框架**: Next.js 14 (App Router) -- **UI 库**: Tailwind CSS + React -- **语言**: TypeScript -- **视频播放器**: ArtPlayer + HLS.js -- **状态管理**: React Hooks + LocalStorage -- **部署**: Docker + Vercel - -**核心功能**: - -1. 多源影视资源聚合搜索 -2. 在线视频播放 (支持 HLS 流) -3. 影片详情展示 (演员、年份、简介等) -4. 收藏功能 (LocalStorage) -5. 播放记录 (继续观看) -6. 响应式布局 (桌面 + 移动端) -7. PWA 支持 - -**项目结构**: - -``` -MoonTV/ -├── src/ -│ ├── app/ # Next.js 14 App Router 页面 -│ │ ├── (pages)/ # 主要页面路由 -│ │ ├── api/ # 后端 API 路由 -│ │ │ ├── douban/ # 豆瓣 API 集成 -│ │ │ ├── search/ # 多源搜索 API -│ │ │ ├── detail/ # 影片详情 API -│ │ │ └── login/ # 认证 API -│ │ └── globals.css # 全局样式 -│ ├── components/ # React 组件 -│ │ ├── VideoCard.tsx # 视频卡片组件 -│ │ ├── AggregateCard.tsx # 聚合卡片组件 -│ │ ├── ScrollableRow.tsx # 横向滚动容器 -│ │ └── ... -│ └── lib/ # 工具库 -│ ├── types.ts # TypeScript 类型定义 -│ ├── db.client.ts # 本地存储操作 -│ └── config.ts # 配置管理 -├── config.json # 影视资源站点配置 -└── package.json # 项目依赖 -``` - -### MyTv 项目分析 - -**项目定位**: 基于 React Native TV 的电视端应用 - -**核心技术栈**: - -- **框架**: React Native TV (支持 Apple TV + Android TV) -- **导航**: Expo Router (文件系统路由) -- **视频播放**: react-native-media-console -- **配置**: @react-native-tvos/config-tv -- **语言**: TypeScript - -**核心功能**: - -1. TV 遥控器导航支持 -2. 焦点管理和高亮显示 -3. 视频播放 (react-native-video) -4. TV 专用 UI 组件 -5. 跨平台支持 (Apple TV + Android TV) - -## 迁移可行性分析 - -### ✅ 高度可行的方面 - -#### 1. **核心业务逻辑** - -- **数据结构兼容**: MoonTV 的 `VideoDetail` 类型定义可直接复用 -- **API 接口复用**: MoonTV 的搜索、详情 API 可通过网络请求在 TV 端调用 -- **业务流程一致**: 搜索 → 详情 → 播放的核心流程完全匹配 - -#### 2. **功能特性映射** - -| MoonTV 功能 | MyTv 对应功能 | 迁移难度 | -| ----------- | ------------------ | --------- | -| 多源搜索 | 网络 API 调用 | ⭐ 容易 | -| 影片详情 | 详情页面适配 | ⭐⭐ 中等 | -| 视频播放 | react-native-video | ⭐⭐ 中等 | -| 收藏功能 | AsyncStorage | ⭐ 容易 | -| 播放记录 | AsyncStorage | ⭐ 容易 | - -#### 3. **技术栈兼容性** - -- **TypeScript**: 两个项目都使用 TypeScript,类型定义可复用 -- **React 生态**: 核心 React 概念一致,组件逻辑可借鉴 -- **状态管理**: 都使用 Hooks,状态逻辑可迁移 - -### ⚠️ 需要重点关注的挑战 - -#### 1. **UI/UX 差异** - -- **交互模式**: Web 鼠标/触摸 → TV 遥控器 D-Pad 导航 -- **布局设计**: 响应式网页布局 → TV 固定尺寸界面 -- **焦点管理**: Web 无需焦点 → TV 必须精确控制焦点流转 - -#### 2. **视频播放器差异** - -- **MoonTV**: ArtPlayer (Web) + HLS.js -- **MyTv**: react-native-media-console + react-native-video -- **挑战**: 播放控制、进度管理、字幕等功能需要重新实现 - -#### 3. **存储机制差异** - -- **MoonTV**: LocalStorage (同步) -- **MyTv**: AsyncStorage (异步) -- **影响**: 所有本地存储操作需要异步化改造 - -### ❌ 不可直接迁移的方面 - -#### 1. **Web 特定功能** - -- PWA 功能 (离线缓存、安装到桌面) -- 豆瓣 API 的 CORS 处理 -- 浏览器特定的媒体 API - -#### 2. **部署和分发** - -- Docker/Vercel 部署 → App Store/Google Play 发布 -- Web 更新 → 应用商店审核流程 - -## 迁移方案设计 - -### 方案 A: 渐进式迁移 (推荐) - -#### 阶段 1: 基础架构搭建 (1-2 周) - -1. **创建 TV 端项目结构** - - ```typescript - MyTv/ - ├── app/ - │ ├── (tabs)/ - │ │ ├── home.tsx # 首页 - 影片推荐 - │ │ ├── search.tsx # 搜索页 - │ │ ├── library.tsx # 我的收藏/播放记录 - │ │ └── settings.tsx # 设置页 - │ ├── detail/ - │ │ └── [id].tsx # 影片详情页 - │ └── play/ - │ └── [id].tsx # 播放页面 - ├── components/ - │ ├── TVVideoCard.tsx # TV 适配的视频卡片 - │ ├── TVSearchInput.tsx # TV 搜索输入 - │ ├── TVMediaPlayer.tsx # TV 媒体播放器 - │ └── FocusableView.tsx # 可获取焦点的容器 - ├── services/ - │ ├── api.ts # MoonTV API 调用封装 - │ ├── storage.ts # AsyncStorage 封装 - │ └── player.ts # 播放器控制逻辑 - └── types/ - └── index.ts # 从 MoonTV 迁移的类型定义 - ``` - -2. **核心服务层迁移** - - ```typescript - // services/api.ts - 调用 MoonTV 后端 - class MoonTVAPI { - private baseURL = "https://your-moontv-instance.com/api"; - - async search(query: string): Promise { - const response = await fetch(`${this.baseURL}/search?q=${query}`); - return response.json(); - } - - async getDetail(source: string, id: string): Promise { - const response = await fetch( - `${this.baseURL}/detail?source=${source}&id=${id}` - ); - return response.json(); - } - } - ``` - -3. **存储层适配** - - ```typescript - // services/storage.ts - import AsyncStorage from "@react-native-async-storage/async-storage"; - - export class TVStorage { - static async getFavorites(): Promise { - const data = await AsyncStorage.getItem("favorites"); - return data ? JSON.parse(data) : []; - } - - static async addFavorite(videoId: string): Promise { - const favorites = await this.getFavorites(); - if (!favorites.includes(videoId)) { - favorites.push(videoId); - await AsyncStorage.setItem("favorites", JSON.stringify(favorites)); - } - } - } - ``` - -#### 阶段 2: 核心页面开发 (2-3 周) - -1. **搜索功能实现** - - ```typescript - // app/(tabs)/search.tsx - export default function SearchScreen() { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [focusedIndex, setFocusedIndex] = useState(0); - - const handleSearch = async (text: string) => { - const searchResults = await MoonTVAPI.search(text); - setResults(searchResults); - }; - - return ( - - - ( - setFocusedIndex(index)} - /> - )} - keyExtractor={(item) => item.id} - /> - - ); - } - ``` - -2. **详情页面开发** - ```typescript - // app/detail/[id].tsx - export default function DetailScreen() { - const { id, source } = useLocalSearchParams(); - const [detail, setDetail] = useState(null); - - useEffect(() => { - MoonTVAPI.getDetail(source as string, id as string).then(setDetail); - }, [source, id]); - - return ( - - - - - - ); - } - ``` - -#### 阶段 3: 播放功能集成 (1-2 周) - -1. **播放器组件开发** - - ```typescript - // components/TVMediaPlayer.tsx - import VideoPlayer from "react-native-media-console"; - - export function TVMediaPlayer({ source, title }: TVMediaPlayerProps) { - return ( - - ); - } - ``` - -2. **播放页面开发** - ```typescript - // app/play/[id].tsx - export default function PlayScreen() { - const { videoUrl, title } = useLocalSearchParams(); - - return ( - - - - ); - } - ``` - -#### 阶段 4: TV 专用优化 (1-2 周) - -1. **焦点导航优化** - - ```typescript - // components/FocusableView.tsx - export function FocusableView({ children, onFocus, onBlur }: Props) { - return ( - [styles.container, focused && styles.focused]} - > - {children} - - ); - } - ``` - -2. **遥控器快捷键支持** - ```typescript - // hooks/useRemoteControl.ts - export function useRemoteControl() { - useEffect(() => { - const handleKeyPress = (event: any) => { - switch (event.key) { - case "ArrowUp": // 遥控器上键 - // 处理向上导航 - break; - case "ArrowDown": // 遥控器下键 - // 处理向下导航 - break; - case "Select": // 遥控器确定键 - // 处理选择 - break; - } - }; - - // 注册事件监听器 - }, []); - } - ``` - -### 方案 B: API 服务复用 (备选) - -如果不想完全迁移前端,可以保持 MoonTV 作为 API 服务器,MyTv 作为纯客户端: - -```typescript -// MyTv 通过网络调用 MoonTV 的 API -const MOONTV_API_BASE = "https://your-moontv-instance.com/api"; - -class MoonTVClient { - async search(query: string) { - return fetch(`${MOONTV_API_BASE}/search?q=${query}`); - } - - async getVideoDetail(source: string, id: string) { - return fetch(`${MOONTV_API_BASE}/detail?source=${source}&id=${id}`); - } -} -``` - -## 实施建议 - -### 开发优先级 - -1. **高优先级** (核心功能) - - - 基础 TV 导航框架 - - 视频搜索和播放 - - 遥控器支持 - -2. **中优先级** (用户体验) - - - 收藏和播放记录 - - 详情页面丰富化 - - 性能优化 - -3. **低优先级** (增值功能) - - 个性化推荐 - - 多用户支持 - - 云同步 - -### 技术风险与缓解 - -| 风险 | 影响 | 缓解措施 | -| --------------- | ------------ | -------------------------- | -| TV 焦点管理复杂 | 开发周期延长 | 早期原型验证,分步实现 | -| 视频播放兼容性 | 功能不稳定 | 多设备测试,备用播放器方案 | -| API 调用延迟 | 用户体验差 | 本地缓存,异步加载 | -| 应用商店审核 | 发布延期 | 提前了解审核规则,合规设计 | - -### 开发资源估算 - -- **开发时间**: 6-8 周 (1-2 人团队) -- **技术栈学习**: React Native TV (1-2 周) -- **UI/UX 设计**: TV 界面设计 (1 周) -- **测试验证**: 多设备兼容性 (1 周) - -## 结论 - -**迁移可行性**: ⭐⭐⭐⭐☆ (高度可行) - -**推荐方案**: 渐进式迁移,保持 MoonTV 作为后端 API 服务,开发独立的 MyTv 客户端 - -**关键成功因素**: - -1. 合理的架构设计 (分离 API 和 UI) -2. TV 交互模式的深度理解 -3. 充分的设备兼容性测试 -4. 渐进式开发和验证 - -通过这个方案,可以充分利用 MoonTV 的成熟后端能力,同时为 TV 平台提供原生的使用体验。 diff --git a/README.md b/README.md index 56afc84..0fc2ce5 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,126 @@ -# react-native-media-console TV demo 👋 +# OrionTV 📺 -![Apple TV screen shot](https://github.com/douglowder/examples/assets/6577821/a881466f-a7a0-4c66-b1fc-33235c466997) -![Android TV screen shot](https://github.com/douglowder/examples/assets/6577821/815c8e01-8275-4cc1-bd57-b9c8bce1fb02) +一个基于 React Native TVOS 和 Expo 构建的跨平台电视应用,旨在提供流畅的视频观看体验。项目包含一个用于数据服务的 Express 后端。 -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). +## ✨ 功能特性 -This project uses +- **跨平台支持**: 同时支持 Apple TV 和 Android TV。 +- **现代化前端**: 使用 Expo、React Native TVOS 和 TypeScript 构建,性能卓越。 +- **Expo Router**: 基于文件系统的路由,使导航逻辑清晰简单。 +- **后端服务**: 配套 Express 后端,用于处理数据获取、搜索和详情展示。 +- **TV 优化的 UI**: 专为电视遥控器交互设计的用户界面。 -- the [React Native TV fork](https://github.com/react-native-tvos/react-native-tvos), which supports both phone (Android and iOS) and TV (Android TV and Apple TV) targets -- the [React Native TV config plugin](https://github.com/react-native-tvos/config-tv/tree/main/packages/config-tv) to allow Expo prebuild to modify the project's native files for TV builds -- [react-native-video](https://github.com/TheWidlarzGroup/react-native-video) package to play videos -- [react-native-media-console](https://github.com/LunatiqueCoder/react-native-media-console) for video controls +## 🛠️ 技术栈 -## 🚀 How to use +- **前端**: + - [React Native TVOS](https://github.com/react-native-tvos/react-native-tvos) + - [Expo](https://expo.dev/) (~51.0) + - [Expo Router](https://docs.expo.dev/router/introduction/) + - [Expo AV](https://docs.expo.dev/versions/latest/sdk/av/) + - TypeScript +- **后端**: + - [Node.js](https://nodejs.org/) + - [Express](https://expressjs.com/) + - [TypeScript](https://www.typescriptlang.org/) -- `cd` into the project +## 📂 项目结构 -```sh -yarn -yarn prebuild # Executes Expo prebuild with TV modifications -yarn ios # Build and run for Apple TV -yarn android # Build for Android TV +本项目采用类似 monorepo 的结构: + +``` +. +├── app/ # Expo Router 路由和页面 +├── assets/ # 静态资源 (字体, 图片, TV 图标) +├── backend/ # 后端 Express 应用 +├── components/ # React 组件 +├── constants/ # 应用常量 (颜色, 样式) +├── hooks/ # 自定义 Hooks +├── services/ # 服务层 (API, 存储) +├── package.json # 前端依赖和脚本 +└── ... ``` -## Development +## 🚀 快速开始 -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). +### 环境准备 -This project includes a [demo](./components/EventHandlingDemo.tsx) showing how to use React Native TV APIs to highlight controls as the user navigates the screen with the remote control. +请确保您的开发环境中已安装以下软件: -## TV specific file extensions +- [Node.js](https://nodejs.org/) (LTS 版本) +- [Yarn](https://yarnpkg.com/) +- [Expo CLI](https://docs.expo.dev/get-started/installation/) +- [Xcode](https://developer.apple.com/xcode/) (用于 Apple TV 开发) +- [Android Studio](https://developer.android.com/studio) (用于 Android TV 开发) -This project includes an [example Metro configuration](./metro.config.js) that allows Metro to resolve application source files with TV-specific code, indicated by specific file extensions (`*.ios.tv.tsx`, `*.android.tv.tsx`, `*.tv.tsx`). The [ExternalLink](./components/ExternalLink.tsx) component makes use of this by having a [separate TV source file](./components/ExternalLink.tv.tsx) that avoids importing packages that don't exist on Apple TV. +### 1. 后端服务 -## Learn more +首先,启动后端服务: -To learn more about developing your project with Expo, look at the following resources: +```sh +# 进入后端目录 +cd backend -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/learn): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +# 安装依赖 +yarn -## Join the community +# 启动开发服务器 +yarn dev +``` -Join our community of developers creating universal apps. +后端服务将运行在 `http://localhost:3001`。 -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +### 2. 前端应用 + +接下来,在项目根目录运行前端应用: + +```sh +# (如果还在 backend 目录) 返回根目录 +cd .. + +# 安装前端依赖 +yarn + +# [首次运行或依赖更新后] 生成原生项目文件 +# 这会根据 app.json 中的配置修改原生代码以支持 TV +yarn prebuild-tv + +# 运行在 Apple TV 模拟器或真机上 +yarn ios-tv + +# 运行在 Android TV 模拟器或真机上 +yarn android-tv +``` + +## 部署 + +### 后端部署 (Vercel) + +后端服务已配置为可以轻松部署到 [Vercel](https://vercel.com/)。 + +1. **安装 Vercel CLI** + + 如果您尚未安装,请全局安装 Vercel CLI: + + ```sh + npm install -g vercel + ``` + +2. **部署** + + 进入 `backend` 目录并运行 `vercel` 命令: + + ```sh + cd backend + vercel + ``` + + 按照 Vercel CLI 的提示完成登录和部署过程。`vercel.json` 文件已配置好所有必要的构建和路由设置。 + +## 📜 主要脚本 + +- `yarn start`: 在手机模式下启动 Metro Bundler。 +- `yarn start-tv`: 在 TV 模式下启动 Metro Bundler。 +- `yarn ios-tv`: 在 Apple TV 上构建并运行应用。 +- `yarn android-tv`: 在 Android TV 上构建并运行应用。 +- `yarn prebuild-tv`: 为 TV 构建生成原生项目文件。 +- `yarn lint`: 检查代码风格。 diff --git a/app.json b/app.json index 58b359e..81babb2 100644 --- a/app.json +++ b/app.json @@ -32,14 +32,30 @@ "experiments": { "typedRoutes": true }, - "name": "MyTVProject", - "slug": "MyTVProject", + "name": "OrionTV", + "slug": "OrionTV", + "icon": "./assets/images/icon.png", "android": { - "package": "com.tvproject" + "package": "com.oriontv", + "usesCleartextTraffic": true, + "networkSecurityConfig": "@xml/network_security_config", + "icon": "./assets/images/icon.png", + "permissions": [ + "android.permission.INTERNET", + "android.permission.ACCESS_NETWORK_STATE" + ] }, "ios": { - "bundleIdentifier": "com.tvproject" + "bundleIdentifier": "com.oriontv" }, - "scheme": "tvproject" + "scheme": "oriontv", + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "b4eddfdb-08cb-4ffc-828d-a27f493e10f7" + } + } } } diff --git a/app/_layout.tsx b/app/_layout.tsx index cb8c844..ec43e1d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,6 +7,7 @@ import { useFonts } from "expo-font"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; +import { Platform } from "react-native"; import { useColorScheme } from "@/hooks/useColorScheme"; @@ -36,6 +37,11 @@ export default function RootLayout() { + + {Platform.OS !== "web" && ( + + )} + diff --git a/app/detail.tsx b/app/detail.tsx index ec01e0b..5291fe1 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -1,33 +1,305 @@ -import React from "react"; -import { View, Text, StyleSheet } from "react-native"; -import { useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; +import { + View, + Text, + StyleSheet, + Image, + ScrollView, + ActivityIndicator, +} from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; +import { moonTVApi, SearchResult } from "@/services/api"; +import { getResolutionFromM3U8 } from "@/services/m3u8"; +import { DetailButton } from "@/components/DetailButton"; export default function DetailScreen() { - const { source, id } = useLocalSearchParams(); + const { source, q } = useLocalSearchParams(); + const router = useRouter(); + const [searchResults, setSearchResults] = useState< + (SearchResult & { resolution?: string | null })[] + >([]); + const [detail, setDetail] = useState< + (SearchResult & { resolution?: string | null }) | null + >(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (typeof source === "string" && typeof q === "string") { + const fetchDetailData = async () => { + try { + setLoading(true); + const { results } = await moonTVApi.searchVideos(q as string); + if (results && results.length > 0) { + const initialDetail = + results.find((r) => r.source === source) || results[0]; + setDetail(initialDetail); + setSearchResults(results); // Set initial results first + + // Asynchronously fetch resolutions + const resultsWithResolutions = await Promise.all( + results.map(async (searchResult) => { + try { + if ( + searchResult.episodes && + searchResult.episodes.length > 0 + ) { + const resolution = await getResolutionFromM3U8( + searchResult.episodes[0] + ); + return { ...searchResult, resolution }; + } + } catch (e) { + console.error("Failed to get resolution for source", e); + } + return searchResult; // Return original if fails + }) + ); + setSearchResults(resultsWithResolutions); + } else { + setError("未找到播放源"); + } + } catch (e) { + setError(e instanceof Error ? e.message : "获取详情失败"); + } finally { + setLoading(false); + } + }; + fetchDetailData(); + } + }, [source, q]); + + const handlePlay = (episodeName: string, episodeIndex: number) => { + if (!detail) return; + router.push({ + pathname: "/play", + params: { + source: detail.source, + id: detail.id.toString(), + episodeUrl: episodeName, // The "episode" is actually the URL + episodeIndex: episodeIndex.toString(), + title: detail.title, + poster: detail.poster, + }, + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + if (!detail) { + return ( + + 未找到详情信息 + + ); + } return ( - Detail Page - - Source: {source} - ID: {id} + + + + + + {detail.title} + + + {detail.year} + + {detail.type_name} + + + + {detail.desc} + + + + + + + + 选择播放源 共 {searchResults.length} 个 + + + {searchResults.map((item, index) => ( + setDetail(item)} + hasTVPreferredFocus={index === 0} + style={[ + styles.sourceButton, + detail?.source === item.source && + styles.sourceButtonSelected, + ]} + > + + {item.source_name} + + {item.episodes.length > 1 && ( + + + {item.episodes.length > 99 + ? "99+" + : `${item.episodes.length}`} + + + )} + {item.resolution && ( + + {item.resolution} + + )} + + ))} + + + + 播放列表 + + {detail.episodes.map((episode, index) => ( + handlePlay(episode, index)} + > + {`第 ${ + index + 1 + } 集`} + + ))} + + + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", + container: { flex: 1 }, + centered: { flex: 1, justifyContent: "center", alignItems: "center" }, + topContainer: { + flexDirection: "row", padding: 20, }, - separator: { - marginVertical: 30, - height: 1, - width: "80%", - backgroundColor: "#666", + poster: { + width: 200, + height: 300, + borderRadius: 8, + }, + infoContainer: { + flex: 1, + marginLeft: 20, + justifyContent: "flex-start", + }, + title: { + fontSize: 28, + fontWeight: "bold", + marginBottom: 10, + paddingTop: 20, + }, + metaContainer: { + flexDirection: "row", + marginBottom: 10, + }, + metaText: { + color: "#aaa", + marginRight: 10, + fontSize: 14, + }, + descriptionScrollView: { + height: 150, // Constrain height to make it scrollable + }, + description: { + fontSize: 14, + color: "#ccc", + lineHeight: 22, + }, + bottomContainer: { + paddingHorizontal: 20, + }, + sourcesContainer: { + marginTop: 20, + }, + sourcesTitle: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 10, + }, + sourceList: { + flexDirection: "row", + flexWrap: "wrap", + }, + sourceButton: { + backgroundColor: "#333", + paddingHorizontal: 15, + paddingVertical: 10, + borderRadius: 8, + margin: 5, + flexDirection: "row", + alignItems: "center", + borderWidth: 2, + borderColor: "transparent", + }, + sourceButtonSelected: { + backgroundColor: "#007bff", + }, + sourceButtonText: { + color: "white", + fontSize: 16, + }, + badge: { + backgroundColor: "red", + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + marginLeft: 8, + }, + badgeText: { + color: "white", + fontSize: 12, + fontWeight: "bold", + }, + episodesContainer: { + marginTop: 20, + }, + episodesTitle: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 10, + }, + episodeList: { + flexDirection: "row", + flexWrap: "wrap", + }, + episodeButton: { + backgroundColor: "#333", + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + margin: 5, + borderWidth: 2, + borderColor: "transparent", + }, + episodeButtonText: { + color: "white", }, }); diff --git a/app/index.tsx b/app/index.tsx index c1d57e7..061a12c 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,98 +1,302 @@ -import React, { useState, useEffect } from "react"; -import { View, StyleSheet, ActivityIndicator, FlatList } from "react-native"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { + View, + StyleSheet, + ActivityIndicator, + FlatList, + Pressable, + Dimensions, +} from "react-native"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; -import ScrollableRow from "@/components/ScrollableRow.tv"; -import { MoonTVAPI, DoubanResponse } from "@/services/api"; -import { RowItem } from "@/components/ScrollableRow.tv"; +import { moonTVApi } from "@/services/api"; +import { SearchResult } from "@/services/api"; +import { PlayRecord } from "@/services/storage"; -interface ContentRow { +export type RowItem = (SearchResult | PlayRecord) & { + id: string; + source: string; title: string; - data: RowItem[]; + poster: string; + progress?: number; + lastPlayed?: number; + episodeIndex?: number; + sourceName?: string; + totalEpisodes?: number; + year?: string; + rate?: string; +}; +import VideoCard from "@/components/VideoCard.tv"; +import { PlayRecordManager } from "@/services/storage"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useColorScheme } from "react-native"; +import { Search } from "lucide-react-native"; + +// --- 类别定义 --- +interface Category { + title: string; + type?: "movie" | "tv" | "record"; + tag?: string; } -const categories = [ - { title: "热门电影", type: "movie", tag: "热门" }, - { title: "热门剧集", type: "tv", tag: "热门" }, - { title: "豆瓣 Top250", type: "movie", tag: "top250" }, +const initialCategories: Category[] = [ + { title: "最近播放", type: "record" }, { title: "综艺", type: "tv", tag: "综艺" }, + { title: "热门剧集", type: "tv", tag: "热门" }, + { title: "热门电影", type: "movie", tag: "热门" }, + { title: "豆瓣 Top250", type: "movie", tag: "top250" }, { title: "美剧", type: "tv", tag: "美剧" }, { title: "韩剧", type: "tv", tag: "韩剧" }, { title: "日剧", type: "tv", tag: "日剧" }, { title: "日漫", type: "tv", tag: "日本动画" }, -] as const; +]; -// --- IMPORTANT --- -// Replace with your computer's LAN IP address to test on a real device or emulator. -// Find it by running `ifconfig` (macOS/Linux) or `ipconfig` (Windows). -const API_BASE_URL = "http://192.168.31.123:3001"; -const api = new MoonTVAPI(API_BASE_URL); +const NUM_COLUMNS = 5; +const { width } = Dimensions.get("window"); +const ITEM_WIDTH = width / NUM_COLUMNS - 24; export default function HomeScreen() { - const [rows, setRows] = useState([]); + const router = useRouter(); + const colorScheme = useColorScheme(); + + const [categories, setCategories] = useState(initialCategories); + const [selectedCategory, setSelectedCategory] = useState( + categories[0] + ); + const [contentData, setContentData] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - const fetchAllData = async () => { - setLoading(true); - setError(null); - try { - const promises = categories.map((category) => - api.getDoubanData(category.type, category.tag, 20) - ); - const results = await Promise.all(promises); + const [pageStart, setPageStart] = useState(0); + const [hasMore, setHasMore] = useState(true); - const newRows: ContentRow[] = results.map((result, index) => { - const category = categories[index]; - return { - title: category.title, - data: result.list.map((item) => ({ - ...item, - id: item.title, // Use title as a temporary unique id - source: "douban", // Static source for douban items - })), - }; - }); + const flatListRef = useRef(null); - setRows(newRows); - } catch (err) { - console.error("Failed to fetch data for home screen:", err); - setError("无法加载内容,请稍后重试。"); - } finally { - setLoading(false); + // --- 数据获取逻辑 --- + const fetchPlayRecords = async () => { + const records = await PlayRecordManager.getAll(); + return Object.entries(records) + .map(([key, record]) => { + const [source, id] = key.split("+"); + return { + id, + source, + title: record.title, + poster: record.cover, + progress: record.play_time / record.total_time, + lastPlayed: record.save_time, + episodeIndex: record.index, + sourceName: record.source_name, + totalEpisodes: record.total_episodes, + } as RowItem; + }) + .filter( + (record) => + record.progress !== undefined && + record.progress > 0 && + record.progress < 1 + ) + .sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0)); + }; + + const fetchData = async (category: Category, start: number) => { + if (category.type === "record") { + const records = await fetchPlayRecords(); + if (records.length === 0 && categories[0].type === "record") { + // 如果没有播放记录,则移除"最近播放"分类并选择第一个真实分类 + const newCategories = categories.slice(1); + setCategories(newCategories); + handleCategorySelect(newCategories[0]); + } else { + setContentData(records); + setHasMore(false); } - }; + setLoading(false); + return; + } - fetchAllData(); - }, []); + if (!category.type || !category.tag) return; - if (loading) { + setLoadingMore(start > 0); + setError(null); + + try { + const result = await moonTVApi.getDoubanData( + category.type, + category.tag, + 20, + start + ); + + if (result.list.length === 0) { + setHasMore(false); + } else { + const newItems = result.list.map((item) => ({ + ...item, + id: item.title, // 临时ID + source: "douban", + })) as RowItem[]; + + setContentData((prev) => + start === 0 ? newItems : [...prev, ...newItems] + ); + setPageStart((prev) => prev + result.list.length); + setHasMore(true); + } + } catch (err) { + console.error("Failed to load data:", err); + setError("加载失败,请重试"); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + // --- Effects --- + useFocusEffect( + useCallback(() => { + if (selectedCategory.type === "record") { + loadInitialData(); + } + }, [selectedCategory]) + ); + + useEffect(() => { + loadInitialData(); + }, [selectedCategory]); + + const loadInitialData = () => { + setLoading(true); + setContentData([]); + setPageStart(0); + setHasMore(true); + flatListRef.current?.scrollToOffset({ animated: false, offset: 0 }); + fetchData(selectedCategory, 0); + }; + + const loadMoreData = () => { + if ( + loading || + loadingMore || + !hasMore || + selectedCategory.type === "record" + ) + return; + fetchData(selectedCategory, pageStart); + }; + + const handleCategorySelect = (category: Category) => { + setSelectedCategory(category); + }; + + // --- 渲染组件 --- + const renderCategory = ({ item }: { item: Category }) => { + const isSelected = selectedCategory.title === item.title; return ( - - - + [ + styles.categoryButton, + isSelected && styles.categoryButtonSelected, + focused && styles.categoryButtonFocused, + ]} + onPress={() => handleCategorySelect(item)} + > + + {item.title} + + ); - } + }; - if (error) { - return ( - - {error} - - ); - } + const renderContentItem = ({ item }: { item: RowItem }) => ( + + + + ); + + const renderFooter = () => { + if (!loadingMore) return null; + return ; + }; return ( - ( - - )} - keyExtractor={(item) => item.title} - contentContainerStyle={styles.listContent} - /> + {/* 顶部导航 */} + + 首页 + [ + styles.searchButton, + focused && styles.searchButtonFocused, + ]} + onPress={() => router.push({ pathname: "/search" })} + > + + + + + {/* 分类选择器 */} + + item.title} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.categoryListContent} + /> + + + {/* 内容网格 */} + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : ( + `${item.source}-${item.id}-${index}`} + numColumns={NUM_COLUMNS} + contentContainerStyle={styles.listContent} + onEndReached={loadMoreData} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + ListEmptyComponent={ + + 该分类下暂无内容 + + } + /> + )} ); } @@ -100,14 +304,69 @@ export default function HomeScreen() { const styles = StyleSheet.create({ container: { flex: 1, + paddingTop: 40, }, centerContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, + // Header + headerContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 24, + marginBottom: 10, + }, + headerTitle: { + fontSize: 32, + fontWeight: "bold", + paddingTop: 16, + }, + searchButton: { + padding: 10, + borderRadius: 30, + }, + searchButtonFocused: { + backgroundColor: "#007AFF", + transform: [{ scale: 1.1 }], + }, + // Category Selector + categoryContainer: { + paddingBottom: 10, + }, + categoryListContent: { + paddingHorizontal: 16, + }, + categoryButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + marginHorizontal: 5, + }, + categoryButtonSelected: { + backgroundColor: "#007AFF", // A bright blue for selected state + }, + categoryButtonFocused: { + backgroundColor: "#0056b3", // A darker blue for focused state + elevation: 5, + }, + categoryText: { + fontSize: 16, + fontWeight: "500", + }, + categoryTextSelected: { + color: "#FFFFFF", + }, + // Content Grid listContent: { - paddingTop: 40, - paddingBottom: 40, + paddingHorizontal: 16, + paddingBottom: 20, + }, + itemContainer: { + margin: 8, + width: ITEM_WIDTH, + alignItems: "center", }, }); diff --git a/app/play.tsx b/app/play.tsx new file mode 100644 index 0000000..94d45ec --- /dev/null +++ b/app/play.tsx @@ -0,0 +1,194 @@ +import React, { useState, useRef } from "react"; +import { + View, + StyleSheet, + TouchableOpacity, + ActivityIndicator, +} from "react-native"; +import { useRouter } from "expo-router"; +import { Video, ResizeMode } from "expo-av"; +import { useKeepAwake } from "expo-keep-awake"; +import { ThemedView } from "@/components/ThemedView"; +import { PlayerControls } from "@/components/PlayerControls"; +import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal"; +import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler"; + +export default function PlayScreen() { + const router = useRouter(); + const videoRef = useRef