commit 61e97da938f1e07f807ef346ea3bb87b21780142 Author: JinJiangHuang Date: Sun Mar 1 14:04:44 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c80df --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +/build +TVBox.dmg +*.py \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..74f708a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jin + +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.md b/README.md new file mode 100644 index 0000000..fd4704a --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# TVBox-Swift + +![Platform iOS | macOS](https://img.shields.io/badge/Platform-iOS%20%7C%20macOS-blue.svg) +![Swift Version](https://img.shields.io/badge/Swift-5.9-orange.svg) +![Xcode Version](https://img.shields.io/badge/Xcode-15.0+-blue.svg) + +**TVBox-Swift** 是一套基于原生 Swift & SwiftUI 构建的跨平台多媒体应用核心工程。它同时支持 macOS 和 iOS 设备,致力于在双端提供无缝、现代且极其流畅的视听体验。 + +## ✨ 核心特性 + +- 🎯 **真正的原生跨平台 (Native Cross-platform)** + - 基于最新的 SwiftUI 和 SwiftData,一套代码覆盖 macOS (14.0+) 与 iOS (17.0+)。 + - 高度针对多端设备的特定交互形式进行原生级适配与优化。 +- 🎨 **前沿的 UI 视觉 (Modern UI & Glassmorphism)** + - 全新的毛玻璃 (Glassmorphism) 视觉设计体系。 + - 精美流畅的重构动画与微交互,从列表到播放页全系更新。 + - 原生级体验的暗黑模式 (Dark Mode) 全方位支持。 +- 🧩 **全方位的媒体功能栈 (Media Stack)** + - **点播中心 (VOD Center):** 整合了智能资源检索、卡片化视觉呈现与多级详情页。 + - *生态支持:* 原生支持解析主流的 CMS JSON/XML 数据源接口 (`type=0`, `type=1`)。 + - *暂不支持:* 由于 iOS/macOS 平台原生限制与环境安全,内核未集成基于 Java 引擎或 Rhino 逻辑层,**不支持** `type=3` (Spider / Jar) 等依赖动态脚本执行的复杂爬虫源。 + - **直播支持 (Live):** 稳定的流媒体播放基础构件支持。 + - **内容枢纽 (Content Hub):** 便捷的搜索、智能历史记录流、一键收藏管理。 +- ⚙️ **健壮的业务底层 (Robust Foundation)** + - **持久化:** 基于苹果全新 `SwiftData` 技术的现代化缓存与本地数据存储模块。 + - **数据层:** 兼容性极强的网络接口解析引擎,支持非标准的 JSON/XML 接口容错处理与数据清洗。 + +## 🛠 技术栈 + +* **语言**: Swift 5.9 +* **UI 框架**: SwiftUI +* **数据持久化**: SwiftData +* **工程化管理**: XcodeGen (>= 2.35) + +--- + +## 🔍 与安卓参考版差异 + +以下差异基于当前仓库代码: + +1. **源类型支持范围不同** + - Swift 版支持 `type=0/1/4`(XML/JSON/Remote)。 + - Swift 版暂不支持 `type=3 (JAR/Spider)` 动态脚本源。 +2. **配置兼容能力不同** + - 安卓版支持加密配置解密、`clan://`、相对路径修复、独立直播配置加载等。 + - Swift 版当前为常规 URL 拉取 + 注释容错解析。 +3. **解析链路能力不同** + - 安卓版支持嗅探/解析切换、规则引擎与相关过滤策略。 + - Swift 版当前仅解析并展示解析器配置数量,未接入完整播放解析链路。 +4. **播放器能力不同** + - 安卓版支持系统/IJK/EXO/外部播放器等多模式。 + - Swift 版当前支持系统 `AVPlayer` 与 `VLCKit`(iOS/macOS)双引擎切换,IJK/EXO/外部播放器仍未接入。 +5. **字幕体系不同** + - 安卓版支持字幕搜索、本地字幕、样式/延迟调节、内嵌轨道等。 + - Swift 版当前尚未接入完整字幕功能。 +6. **直播高级能力不同** + - 安卓版支持 EPG、回看/时移、分组密码、直播设置分组等。 + - Swift 版当前已支持基础直播分组与线路切换,EPG 仍为简化实现。 +7. **筛选与检索形态不同** + - 安卓版具备分类筛选、快速搜索、复选搜索等界面与交互。 + - Swift 版当前为基础搜索与分类浏览。 +8. **远程控制与推送不同** + - 安卓版具备本地控制服务与推送/遥控相关能力。 + - Swift 版当前未提供该能力。 +9. **设置项覆盖度不同** + - 安卓版设置项更细(DoH、渲染/缩放、历史条数、M3U8 净化、备份恢复等)。 + - Swift 版当前保留核心常用设置。 +10. **收藏入口闭环不同** + - 安卓版详情页支持直接加入/取消收藏。 + - Swift 版目前提供收藏页与收藏数据模型,但详情页收藏入口待完善。 + +--- + +## 🚀 快速开始 + +本项目的主 Xcode 工程文件并未直接包含在仓库代码中,以保证干净的源码树并降低拉取冲突风险。您需要通过 `XcodeGen` 生成配置文件。 + +### 1. 环境准备 +确保您的设备已安装以下必备组件: +* **macOS** (最新版本) +* **Xcode 15.0** 或以上版本 +* **Homebrew** (用于安装命令行工具) + +安装工程生成器 `XcodeGen`: +```bash +brew install xcodegen +``` + +### 2. 生成 Xcode 工程 +在终端进入本项目的根目录,执行以下命令以生成 `tvbox.xcodeproj` 文件: +```bash +xcodegen generate +``` + +### 3. 构建与运行 +打开刚刚使用 XcodeGen 生成的 `tvbox.xcodeproj`: +- 选择合适的 Scheme (`tvbox` 对应 iOS, `tvbox-macOS` 对应 macOS)。 +- 直接在设备或模拟器真机上 `Command + R` 进行编译与运行调试。 + +--- + +## 📦 macOS 独立打包 + +项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。 + +```bash +chmod +x package_mac.sh +./package_mac.sh +``` + +> **成功输出提示:** `✅ 打包完成!生成文件: TVBox-macOS.dmg` + +--- + +## 📂 简明目录结构 + +```text +├── tvbox +│ ├── Models/ # 数据模型定义 +│ ├── ViewModels/ # 业务逻辑与状态管理 (MVVM) +│ ├── Views/ # 所有的 SwiftUI 原生视图组件 +│ ├── Services/ # 网络层、配置解析层等外部服务交互 +│ ├── Persistence/ # 数据库 (SwiftData) 配置与读写管理 +│ ├── Utils/ # 通用的帮助类与扩展函数 +│ └── Assets.xcassets # 资源文件集、图标与预设全局色彩 (AccentColor) +├── project.yml # XcodeGen 的工程模板描述文件 +├── package_mac.sh # macOS DMG 打包脚本 +└── process_icon.py # 图片转换脚本套件 +``` + +--- + +## 📜 开源协议 + +本项目采用 [MIT License](./LICENSE) 开源发布。 + +--- + +## ⚠️ 开源免责声明 (Disclaimer) + +**本项目仅供编程技术交流、学习 Swift 与 SwiftUI 原生应用开发原理之目的。** + +1. 开发者不对本项目代码产生的任何直接或间接后果负责。 +2. 本项目**不提供、不内置、不分发**任何形式的具体影视资源、播放源配置接口(如接口 URL、JSON 规则等)。 +3. 使用本项目所导致的所有合法性与知识产权纠纷(包括但不限于使用者自行导入第三方规则/接口而引发的版权问题),由使用者自行承担全部法律责任。 +4. 本应用旨在作为一个本地多媒体客户端工具框架进行技术探讨,任何以此框架衍生的商业化产品与违法行为均与原作者无关。如果您下载、安装、编译或使用了本项目,即代表您理解并完全接受本免责声明之所有条款。 diff --git a/TVBox-macOS.dmg b/TVBox-macOS.dmg new file mode 100644 index 0000000..3c9b5af Binary files /dev/null and b/TVBox-macOS.dmg differ diff --git a/package_mac.sh b/package_mac.sh new file mode 100755 index 0000000..f399b7f --- /dev/null +++ b/package_mac.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +# 设置工作目录为脚本所在目录 +cd "$(dirname "$0")" + +echo "清理构建目录..." +rm -rf build +rm -f TVBox-macOS.dmg + +echo "开始构建 macOS 版本..." +xcodebuild -project tvbox.xcodeproj -scheme tvbox-macOS -configuration Release SYMROOT="$(pwd)/build" clean build + +APP_PATH="build/Release/TVBox.app" + +if [ ! -d "$APP_PATH" ]; then + echo "错误: 找不到构建好的 App: $APP_PATH" + exit 1 +fi + +echo "创建 DMG 安装临时目录..." +DMG_DIR="build/dmg_stage" +rm -rf "$DMG_DIR" +mkdir -p "$DMG_DIR" + +echo "复制 App 至临时目录..." +cp -R "$APP_PATH" "$DMG_DIR/" + +echo "创建 Applications 快捷方式..." +ln -s /Applications "$DMG_DIR/Applications" + +echo "创建 DMG 安装包..." +hdiutil create -volname "TVBox" -srcfolder "$DMG_DIR" -ov -format UDZO "TVBox-macOS.dmg" + +echo "✅ 打包完成!生成文件: TVBox-macOS.dmg" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..db3ca14 --- /dev/null +++ b/project.yml @@ -0,0 +1,83 @@ +name: tvbox +options: + bundleIdPrefix: com.tvbox + xcodeVersion: "15.0" + groupSortPosition: top + minimumXcodeGenVersion: "2.35" + +settings: + base: + SWIFT_VERSION: "5.9" + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Automatic + +packages: + VLCKitSPM: + url: https://github.com/tylerjonesio/vlckit-spm + from: 3.6.0 + +targets: + tvbox: + type: application + platform: iOS + deploymentTarget: "17.0" + dependencies: + - package: VLCKitSPM + product: VLCKitSPM + sources: + - path: tvbox + excludes: + - Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app + INFOPLIST_FILE: tvbox/Info.plist + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + GENERATE_INFOPLIST_FILE: YES + CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0.0" + PRODUCT_NAME: TVBox + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_EMIT_LOC_STRINGS: YES + SUPPORTS_MACCATALYST: YES + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: NO + configs: + Debug: + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" + + tvbox-macOS: + type: application + platform: macOS + deploymentTarget: "14.0" + dependencies: + - package: VLCKitSPM + product: VLCKitSPM + sources: + - path: tvbox + excludes: + - Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.macos + INFOPLIST_FILE: tvbox/Info.plist + GENERATE_INFOPLIST_FILE: YES + CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0.0" + PRODUCT_NAME: TVBox + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_EMIT_LOC_STRINGS: YES + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_ENTITLEMENTS: "" + configs: + Debug: + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" diff --git a/tvbox.xcodeproj/project.pbxproj b/tvbox.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e90135c --- /dev/null +++ b/tvbox.xcodeproj/project.pbxproj @@ -0,0 +1,789 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; }; + 01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; }; + 020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; }; + 058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; }; + 073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; }; + 0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; }; + 0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; }; + 100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; }; + 12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; }; + 16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; }; + 17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; }; + 19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; }; + 1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; }; + 205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; }; + 211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; }; + 217BB4F40B9348F439749C70 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; }; + 267A5751434568E302E0C902 /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D8D6F9D894DBE0594084F9 /* LiveView.swift */; }; + 2AAA54BEC34D9A528B2208ED /* SourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DFDD8C597739AD4B443C03 /* SourceService.swift */; }; + 2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; }; + 2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; }; + 32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; }; + 3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; }; + 3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; }; + 45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; }; + 485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; }; + 49FCE735A7CD8228369E3824 /* SourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DFDD8C597739AD4B443C03 /* SourceService.swift */; }; + 4C4402D4594D06EA9D76960A /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; }; + 4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131429F267F6A418C282831A /* DetailView.swift */; }; + 5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; }; + 530B86138F2A404353CE37AB /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D8D6F9D894DBE0594084F9 /* LiveView.swift */; }; + 5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; }; + 59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; }; + 5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; }; + 5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; }; + 5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; }; + 633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; }; + 6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131429F267F6A418C282831A /* DetailView.swift */; }; + 68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; }; + 69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */; }; + 6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; }; + 72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */; }; + 74D54978A79B511FDE79524D /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; }; + 7686C56E8FB63134FF878EDE /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; }; + 7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; }; + 7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; }; + 7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; }; + 80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; }; + 83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; }; + 85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; }; + 960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 1C6BDE11E85522F286D15861 /* VLCKitSPM */; }; + 969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; }; + 97B3904616111CE93232423E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; }; + 9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; }; + 9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; }; + B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; + B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; }; + BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; }; + BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; }; + BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; }; + C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; }; + D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; }; + D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; + DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; }; + E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; }; + F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; }; + F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; }; + F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; }; + FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 016DC7547EE0DE345E510288 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = ""; }; + 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; + 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + 131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; + 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = ""; }; + 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = ""; }; + 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = ""; }; + 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = ""; }; + 6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; + 6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; + 90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = ""; }; + B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = ""; }; + B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = ""; }; + B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListView.swift; sourceTree = ""; }; + BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; + BFD32454522C32D876921A30 /* CacheStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheStore.swift; sourceTree = ""; }; + CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveModels.swift; sourceTree = ""; }; + D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; + D5DFDD8C597739AD4B443C03 /* SourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceService.swift; sourceTree = ""; }; + D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiConfig.swift; sourceTree = ""; }; + D791457269BE952C496ECE55 /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; + DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = ""; }; + E5234A75D14EAE9141633FCA /* tvbox.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = tvbox.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F905D1244D5DE13DBCEE170F /* VodCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodCardView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9B1DACF57AD7C7B69241F787 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B5376B80D38F14CA8E597FA9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0DC718CBB8525FF557009CD8 /* Utils */ = { + isa = PBXGroup; + children = ( + 016DC7547EE0DE345E510288 /* Extensions.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 22CB65DE627DB94747387944 /* Services */ = { + isa = PBXGroup; + children = ( + D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */, + D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */, + D5DFDD8C597739AD4B443C03 /* SourceService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 2990EE415D2440DE11757F52 /* Player */ = { + isa = PBXGroup; + children = ( + BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */, + DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */, + ); + path = Player; + sourceTree = ""; + }; + 2B5161C9D7D5ECC250A7FFC0 /* Search */ = { + isa = PBXGroup; + children = ( + 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; + 2E56FD5A2A716ED24B655259 /* Models */ = { + isa = PBXGroup; + children = ( + DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */, + CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */, + D791457269BE952C496ECE55 /* Movie.swift */, + 0212ABEA698460948737CABE /* MovieSort.swift */, + B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */, + 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */, + AA608E781D094B187D3034FD /* VodInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; + 395C32C174B961E32AF81E4F /* Settings */ = { + isa = PBXGroup; + children = ( + A10969CAA236B75C35062BCE /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 4D47ACD45B1BF337CCB04FCC /* Views */ = { + isa = PBXGroup; + children = ( + 6B3F725811AE256C79413905 /* Common */, + 8798AB58636EE4E2C7410D3E /* Detail */, + 8AEC948634E120213D3731BD /* Favorites */, + FF7A64851C810E1636763FE2 /* History */, + FD7DFF54835B377CB9BF5A82 /* Home */, + D3F1B37CB4D7643D7F7639CE /* Live */, + 2990EE415D2440DE11757F52 /* Player */, + 2B5161C9D7D5ECC250A7FFC0 /* Search */, + 395C32C174B961E32AF81E4F /* Settings */, + 6F4341B27BB1A860CB183467 /* ContentView.swift */, + 6EE08247136FF788C621DA20 /* DesignSystem.swift */, + ); + path = Views; + sourceTree = ""; + }; + 619FDAACD7BFF9CA03E84145 = { + isa = PBXGroup; + children = ( + 8B5798117EA20DF680EFD3CA /* tvbox */, + AF26E3B8CC541C336E1ECDDB /* Products */, + ); + sourceTree = ""; + }; + 6B3F725811AE256C79413905 /* Common */ = { + isa = PBXGroup; + children = ( + 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */, + ); + path = Common; + sourceTree = ""; + }; + 837A55F21477081BDA8D1C05 /* Persistence */ = { + isa = PBXGroup; + children = ( + BFD32454522C32D876921A30 /* CacheStore.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + 8798AB58636EE4E2C7410D3E /* Detail */ = { + isa = PBXGroup; + children = ( + 131429F267F6A418C282831A /* DetailView.swift */, + B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */, + ); + path = Detail; + sourceTree = ""; + }; + 8AEC948634E120213D3731BD /* Favorites */ = { + isa = PBXGroup; + children = ( + 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */, + ); + path = Favorites; + sourceTree = ""; + }; + 8B5798117EA20DF680EFD3CA /* tvbox */ = { + isa = PBXGroup; + children = ( + 2E56FD5A2A716ED24B655259 /* Models */, + 837A55F21477081BDA8D1C05 /* Persistence */, + 22CB65DE627DB94747387944 /* Services */, + 0DC718CBB8525FF557009CD8 /* Utils */, + E1FD870365F6D4FD67730DBD /* ViewModels */, + 4D47ACD45B1BF337CCB04FCC /* Views */, + 3EE159FC0B6328166F56CE36 /* Assets.xcassets */, + 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */, + ); + path = tvbox; + sourceTree = ""; + }; + AF26E3B8CC541C336E1ECDDB /* Products */ = { + isa = PBXGroup; + children = ( + 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */, + E5234A75D14EAE9141633FCA /* tvbox.app */, + ); + name = Products; + sourceTree = ""; + }; + D3F1B37CB4D7643D7F7639CE /* Live */ = { + isa = PBXGroup; + children = ( + B3D8D6F9D894DBE0594084F9 /* LiveView.swift */, + ); + path = Live; + sourceTree = ""; + }; + E1FD870365F6D4FD67730DBD /* ViewModels */ = { + isa = PBXGroup; + children = ( + 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */, + 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */, + 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */, + 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */, + 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + FD7DFF54835B377CB9BF5A82 /* Home */ = { + isa = PBXGroup; + children = ( + 90F26F870D1B587C1292A189 /* HomeView.swift */, + F905D1244D5DE13DBCEE170F /* VodCardView.swift */, + ); + path = Home; + sourceTree = ""; + }; + FF7A64851C810E1636763FE2 /* History */ = { + isa = PBXGroup; + children = ( + DE52456C613016065CF59F9C /* HistoryView.swift */, + ); + path = History; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 11562C2D9CA08B82105BC7B6 /* tvbox */ = { + isa = PBXNativeTarget; + buildConfigurationList = 508114AC3B63D46910416F38 /* Build configuration list for PBXNativeTarget "tvbox" */; + buildPhases = ( + 89EE06EE9942A5C42303E3C1 /* Sources */, + 454DA0711019947875651FA4 /* Resources */, + B5376B80D38F14CA8E597FA9 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = tvbox; + packageProductDependencies = ( + 1C6BDE11E85522F286D15861 /* VLCKitSPM */, + ); + productName = tvbox; + productReference = E5234A75D14EAE9141633FCA /* tvbox.app */; + productType = "com.apple.product-type.application"; + }; + 793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */; + buildPhases = ( + DE6D8B68783A8C16E41932B1 /* Sources */, + EC13F9781D114457104B12F5 /* Resources */, + 9B1DACF57AD7C7B69241F787 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "tvbox-macOS"; + packageProductDependencies = ( + A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */, + ); + productName = "tvbox-macOS"; + productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4FDAB104B1C6110A770CC389 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 11562C2D9CA08B82105BC7B6 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + 793E3BB44A68A9F75C89F0C1 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 11680A25F1091D819FA55F7B /* Build configuration list for PBXProject "tvbox" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 619FDAACD7BFF9CA03E84145; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 5D3D6EC1BAD806031C371C6E /* XCRemoteSwiftPackageReference "vlckit-spm" */, + ); + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 11562C2D9CA08B82105BC7B6 /* tvbox */, + 793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 454DA0711019947875651FA4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EC13F9781D114457104B12F5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 89EE06EE9942A5C42303E3C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */, + 3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */, + DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */, + 0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */, + 2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */, + 4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */, + 969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */, + BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */, + 69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */, + 12314BE9C595577FEE1839BB /* Extensions.swift in Sources */, + F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */, + D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */, + 97B3904616111CE93232423E /* HomeView.swift in Sources */, + 5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */, + E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */, + 267A5751434568E302E0C902 /* LiveView.swift in Sources */, + 19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */, + 0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */, + 68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */, + 5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */, + 2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */, + 00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */, + 073BA70E29280BDF914166CF /* SearchView.swift in Sources */, + 1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */, + FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */, + 80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */, + 17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */, + 49FCE735A7CD8228369E3824 /* SourceService.swift in Sources */, + 85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */, + 205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */, + BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */, + BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DE6D8B68783A8C16E41932B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */, + D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */, + 6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */, + 01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */, + 16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */, + 6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */, + 7686C56E8FB63134FF878EDE /* DetailViewModel.swift in Sources */, + 83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */, + 72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */, + 5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */, + 058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */, + B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */, + 5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */, + 32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */, + 7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */, + 530B86138F2A404353CE37AB /* LiveView.swift in Sources */, + 9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */, + 485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */, + 7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */, + 5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */, + 100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */, + F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */, + 59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */, + F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */, + 020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */, + 4C4402D4594D06EA9D76960A /* SettingsViewModel.swift in Sources */, + C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */, + 2AAA54BEC34D9A528B2208ED /* SourceService.swift in Sources */, + B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */, + 217BB4F40B9348F439749C70 /* VodCardView.swift in Sources */, + 74D54978A79B511FDE79524D /* VodInfo.swift in Sources */, + 45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 05A4C2FBFE824459DAF6F032 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = tvbox/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos; + PRODUCT_NAME = TVBox; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 1D0F1FB90BBBEFD70A204F5F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + 1D806EFEE3C02D54F71A0A88 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = tvbox/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos; + PRODUCT_NAME = TVBox; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 8A2175BF0264F53B0EF674FF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = tvbox/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app; + PRODUCT_NAME = TVBox; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D7359975052DCED591BCFD09 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + E2BF967A118954FC28C8C9CE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = tvbox/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app; + PRODUCT_NAME = TVBox; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 11680A25F1091D819FA55F7B /* Build configuration list for PBXProject "tvbox" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D7359975052DCED591BCFD09 /* Debug */, + 1D0F1FB90BBBEFD70A204F5F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 508114AC3B63D46910416F38 /* Build configuration list for PBXNativeTarget "tvbox" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8A2175BF0264F53B0EF674FF /* Debug */, + E2BF967A118954FC28C8C9CE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D806EFEE3C02D54F71A0A88 /* Debug */, + 05A4C2FBFE824459DAF6F032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5D3D6EC1BAD806031C371C6E /* XCRemoteSwiftPackageReference "vlckit-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tylerjonesio/vlckit-spm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.6.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1C6BDE11E85522F286D15861 /* VLCKitSPM */ = { + isa = XCSwiftPackageProductDependency; + package = 5D3D6EC1BAD806031C371C6E /* XCRemoteSwiftPackageReference "vlckit-spm" */; + productName = VLCKitSPM; + }; + A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */ = { + isa = XCSwiftPackageProductDependency; + package = 5D3D6EC1BAD806031C371C6E /* XCRemoteSwiftPackageReference "vlckit-spm" */; + productName = VLCKitSPM; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 4FDAB104B1C6110A770CC389 /* Project object */; +} diff --git a/tvbox.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/tvbox.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/tvbox.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/tvbox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tvbox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..697021e --- /dev/null +++ b/tvbox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "294b5443f2fd75dbed7a00528cc25cb27baf812b2346df53611fbd2af4f2803d", + "pins" : [ + { + "identity" : "vlckit-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tylerjonesio/vlckit-spm", + "state" : { + "revision" : "e932bbd488872fdb74f6654d28c2f291eae03daf", + "version" : "3.6.0" + } + } + ], + "version" : 3 +} diff --git a/tvbox/Assets.xcassets/AccentColor.colorset/Contents.json b/tvbox/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..6efce1c --- /dev/null +++ b/tvbox/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.000", + "green": "0.478", + "red": "1.000" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/Contents.json b/tvbox/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b027594 --- /dev/null +++ b/tvbox/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024", + "filename": "universal_ios_1024x1024_1x.png" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "16x16", + "filename": "mac_16x16_1x.png" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "16x16", + "filename": "mac_16x16_2x.png" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "32x32", + "filename": "mac_32x32_1x.png" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "32x32", + "filename": "mac_32x32_2x.png" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "128x128", + "filename": "mac_128x128_1x.png" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "128x128", + "filename": "mac_128x128_2x.png" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "256x256", + "filename": "mac_256x256_1x.png" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "256x256", + "filename": "mac_256x256_2x.png" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "512x512", + "filename": "mac_512x512_1x.png" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "512x512", + "filename": "mac_512x512_2x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/ios_1024.png b/tvbox/Assets.xcassets/AppIcon.appiconset/ios_1024.png new file mode 100644 index 0000000..8927393 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/ios_1024.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128.png new file mode 100644 index 0000000..b1bd80d Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_1x.png new file mode 100644 index 0000000..b1bd80d Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_1x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_2x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_2x.png new file mode 100644 index 0000000..850d5e2 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_128x128_2x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16.png new file mode 100644 index 0000000..f2585c2 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_1x.png new file mode 100644 index 0000000..f2585c2 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_1x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_2x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_2x.png new file mode 100644 index 0000000..8ee6829 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_16x16_2x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256.png new file mode 100644 index 0000000..850d5e2 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_1x.png new file mode 100644 index 0000000..850d5e2 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_1x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_2x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_2x.png new file mode 100644 index 0000000..25b2dca Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_256x256_2x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32.png new file mode 100644 index 0000000..8ee6829 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_1x.png new file mode 100644 index 0000000..8ee6829 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_1x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_2x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_2x.png new file mode 100644 index 0000000..e96f294 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_32x32_2x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512.png new file mode 100644 index 0000000..25b2dca Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_1x.png new file mode 100644 index 0000000..25b2dca Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_1x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_2x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_2x.png new file mode 100644 index 0000000..8927393 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/mac_512x512_2x.png differ diff --git a/tvbox/Assets.xcassets/AppIcon.appiconset/universal_ios_1024x1024_1x.png b/tvbox/Assets.xcassets/AppIcon.appiconset/universal_ios_1024x1024_1x.png new file mode 100644 index 0000000..8927393 Binary files /dev/null and b/tvbox/Assets.xcassets/AppIcon.appiconset/universal_ios_1024x1024_1x.png differ diff --git a/tvbox/Assets.xcassets/Contents.json b/tvbox/Assets.xcassets/Contents.json new file mode 100644 index 0000000..65af1f2 --- /dev/null +++ b/tvbox/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/tvbox/Info.plist b/tvbox/Info.plist new file mode 100644 index 0000000..cf5e9dd --- /dev/null +++ b/tvbox/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/tvbox/Models/AppConfig.swift b/tvbox/Models/AppConfig.swift new file mode 100644 index 0000000..02200f8 --- /dev/null +++ b/tvbox/Models/AppConfig.swift @@ -0,0 +1,216 @@ +import Foundation + +// MARK: - 灵活类型解码器 + +/// 兼容 JSON 中数值字段可能是字符串 "0" 或整数 0 的情况 +/// Android Gson 自动处理此差异,Swift 需要手动兼容 +struct FlexibleInt: Codable, Hashable { + let value: Int + + init(_ value: Int) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { + self.value = intVal + } else if let stringVal = try? container.decode(String.self), + let intVal = Int(stringVal) { + self.value = intVal + } else { + self.value = 0 + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } +} + +/// 兼容 ext 字段可能是字符串或对象 +struct FlexibleExt: Codable, Hashable { + let stringValue: String? + let dictValue: [String: AnyCodableValue]? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let str = try? container.decode(String.self) { + self.stringValue = str + self.dictValue = nil + } else if let dict = try? container.decode([String: AnyCodableValue].self) { + self.stringValue = nil + self.dictValue = dict + } else { + self.stringValue = nil + self.dictValue = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let str = stringValue { + try container.encode(str) + } else if let dict = dictValue { + try container.encode(dict) + } + } +} + +/// 通用的 JSON 值类型,用于处理混合类型的字典 +enum AnyCodableValue: Codable, Hashable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case dict([String: AnyCodableValue]) + case array([AnyCodableValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let str = try? container.decode(String.self) { + self = .string(str) + } else if let int = try? container.decode(Int.self) { + self = .int(int) + } else if let double = try? container.decode(Double.self) { + self = .double(double) + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let dict = try? container.decode([String: AnyCodableValue].self) { + self = .dict(dict) + } else if let array = try? container.decode([AnyCodableValue].self) { + self = .array(array) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .int(let i): try container.encode(i) + case .double(let d): try container.encode(d) + case .bool(let b): try container.encode(b) + case .dict(let d): try container.encode(d) + case .array(let a): try container.encode(a) + case .null: try container.encodeNil() + } + } + + /// 将可能的任意类型转换为字符串(如果原本是字典或数组,则转为 JSON 字符串) + var stringValue: String? { + switch self { + case .string(let s): return s + case .int(let i): return String(i) + case .double(let d): return String(d) + case .bool(let b): return b ? "true" : "false" + case .null: return nil + case .dict, .array: + if let data = try? JSONEncoder().encode(self), + let str = String(data: data, encoding: .utf8) { + return str + } + return nil + } + } +} + +// MARK: - 解析接口配置 + +/// 解析接口配置 - 对应 Android 版 ParseBean.java +struct ParseBean: Codable, Identifiable, Hashable { + var id: String { name } + var name: String = "" + var url: String = "" + var type: Int = 0 // 0:嗅探 1:解析 + var ext: [String: String]? = nil + + init(name: String = "", url: String = "", type: Int = 0) { + self.name = name + self.url = url + self.type = type + } +} + +// MARK: - API 配置顶层结构 + +/// API 配置顶层结构 - 对应 JSON 配置文件格式 +struct AppConfigData: Codable { + var spider: String? + var wallpaper: String? + var sites: [SiteConfig]? + var parses: [ParseConfig]? + var lives: [LiveConfig]? + var doh: [DoHConfig]? + var rules: [RuleConfig]? + var hosts: [String]? + var flags: [String]? + var ads: [String]? + + struct SiteConfig: Codable { + var key: String? + var name: String? + var api: String? + var searchable: FlexibleInt? + var filterable: FlexibleInt? + var quickSearch: FlexibleInt? + var playerType: FlexibleInt? + var type: FlexibleInt? + var ext: AnyCodableValue? + var jar: String? + var style: AnyCodableValue? + var playUrl: String? + var categories: [String]? + var click: String? + + enum CodingKeys: String, CodingKey { + case key, name, api, searchable, filterable, quickSearch + case playerType, type, ext, jar, style, playUrl, categories, click + // type_flag 也可能出现 + } + } + + struct ParseConfig: Codable { + var name: String? + var url: String? + var type: FlexibleInt? + var ext: FlexibleExt? + } + + struct LiveConfig: Codable { + var name: String? + var url: String? + var type: FlexibleInt? + var ua: String? + var epg: String? + var logo: String? + var pass: AnyCodableValue? // Android 配置中存在 + var playerType: FlexibleInt? + var channels: [LiveChannelConfig]? + + struct LiveChannelConfig: Codable { + var name: String? + var urls: [String]? + var group: String? + var logo: String? + } + } + + struct DoHConfig: Codable { + var name: String? + var url: String? + } + + struct RuleConfig: Codable { + var name: String? + var host: String? + var hosts: [String]? + var rule: [String]? + var regex: [String]? + var filter: [String]? + var script: [String]? + } +} diff --git a/tvbox/Models/LiveModels.swift b/tvbox/Models/LiveModels.swift new file mode 100644 index 0000000..3b919ad --- /dev/null +++ b/tvbox/Models/LiveModels.swift @@ -0,0 +1,72 @@ +import Foundation + +/// 直播相关模型 - 对应 Android 版 LiveChannel*.java / Epginfo.java + +/// 直播频道分组 +struct LiveChannelGroup: Codable, Identifiable, Hashable { + var id: String { groupName } + var groupName: String = "" + var groupIndex: Int = 0 + var channels: [LiveChannelItem] = [] + var isPassword: Bool = false + + init(groupName: String = "", groupIndex: Int = 0) { + self.groupName = groupName + self.groupIndex = groupIndex + } +} + +/// 直播频道 +struct LiveChannelItem: Codable, Identifiable, Hashable { + var id: String { "\(channelName)_\(channelIndex)" } + var channelName: String = "" + var channelIndex: Int = 0 + var channelUrls: [String] = [] + var sourceIndex: Int = 0 + var sourceNum: Int { channelUrls.count } + var logo: String = "" + + init(channelName: String = "", channelIndex: Int = 0) { + self.channelName = channelName + self.channelIndex = channelIndex + } + + var currentUrl: String? { + guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first } + return channelUrls[sourceIndex] + } + + mutating func nextSource() { + if channelUrls.count > 0 { + sourceIndex = (sourceIndex + 1) % channelUrls.count + } + } +} + +/// EPG 节目信息 +struct Epginfo: Codable, Identifiable, Hashable { + var id: String { "\(title)_\(startTime)" } + var title: String = "" + var startTime: String = "" + var endTime: String = "" + var index: Int = 0 + + var isLive: Bool { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + guard let start = formatter.date(from: startTime), + let end = formatter.date(from: endTime) else { return false } + + let now = formatter.date(from: formatter.string(from: Date()))! + return now >= start && now < end + } +} + +/// EPG 日期分组 +struct LiveEpgDate: Codable, Identifiable, Hashable { + var id: String { datePresent } + var datePresent: String = "" // 显示日期 + var date: String = "" // 查询日期 + var index: Int = 0 + var isSelected: Bool = false +} diff --git a/tvbox/Models/Movie.swift b/tvbox/Models/Movie.swift new file mode 100644 index 0000000..f9670d4 --- /dev/null +++ b/tvbox/Models/Movie.swift @@ -0,0 +1,82 @@ +import Foundation + +/// 电影/视频数据模型 - 对应 Android 版 Movie.java +struct Movie: Codable { + var videoList: [Video] = [] + var pagecount: Int = 0 + var page: Int = 0 + var total: Int = 0 + var limit: Int = 0 + + /// 单个视频条目 + struct Video: Codable, Identifiable, Hashable { + var id: String + var name: String = "" + var pic: String = "" + var note: String = "" // 备注(如"更新至第20集") + var year: String = "" + var area: String = "" + var type: String = "" // 类型/分类名 + var director: String = "" + var actor: String = "" + var des: String = "" // 简介 + var sourceKey: String = "" // 来源站点 key + var tid: String = "" // 分类 ID + var last: String = "" // 最后更新 + var dt: String = "" // 日期 + + init(id: String = UUID().uuidString, name: String = "", pic: String = "", + note: String = "", sourceKey: String = "") { + self.id = id + self.name = name + self.pic = pic + self.note = note + self.sourceKey = sourceKey + } + + enum CodingKeys: String, CodingKey { + case id = "vod_id" + case name = "vod_name" + case pic = "vod_pic" + case note = "vod_remarks" + case year = "vod_year" + case area = "vod_area" + case type = "type_name" + case director = "vod_director" + case actor = "vod_actor" + case des = "vod_content" + case tid = "type_id" + case last = "vod_time" + case dt = "vod_play_from" + case sourceKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // 支持 String 或 Int 类型的 id + if let intId = try? container.decode(Int.self, forKey: .id) { + self.id = String(intId) + } else { + self.id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + } + self.name = (try? container.decode(String.self, forKey: .name)) ?? "" + self.pic = (try? container.decode(String.self, forKey: .pic)) ?? "" + self.note = (try? container.decode(String.self, forKey: .note)) ?? "" + self.year = (try? container.decode(String.self, forKey: .year)) ?? "" + self.area = (try? container.decode(String.self, forKey: .area)) ?? "" + self.type = (try? container.decode(String.self, forKey: .type)) ?? "" + self.director = (try? container.decode(String.self, forKey: .director)) ?? "" + self.actor = (try? container.decode(String.self, forKey: .actor)) ?? "" + self.des = (try? container.decode(String.self, forKey: .des)) ?? "" + self.tid = { + if let intTid = try? container.decode(Int.self, forKey: .tid) { + return String(intTid) + } + return (try? container.decode(String.self, forKey: .tid)) ?? "" + }() + self.last = (try? container.decode(String.self, forKey: .last)) ?? "" + self.dt = (try? container.decode(String.self, forKey: .dt)) ?? "" + self.sourceKey = (try? container.decode(String.self, forKey: .sourceKey)) ?? "" + } + } +} diff --git a/tvbox/Models/MovieSort.swift b/tvbox/Models/MovieSort.swift new file mode 100644 index 0000000..c3af00d --- /dev/null +++ b/tvbox/Models/MovieSort.swift @@ -0,0 +1,36 @@ +import Foundation + +/// 分类排序模型 - 对应 Android 版 MovieSort.java +struct MovieSort: Codable { + var sortList: [SortData] = [] + + /// 单个分类数据 + struct SortData: Codable, Identifiable, Hashable { + var id: String + var name: String = "" + var flag: String = "" + var filters: [SortFilter] = [] + + init(id: String = "", name: String = "", flag: String = "") { + self.id = id + self.name = name + self.flag = flag + } + + static func home() -> SortData { + SortData(id: "home", name: "推荐", flag: "1") + } + } + + /// 筛选条件 + struct SortFilter: Codable, Hashable { + var key: String = "" + var name: String = "" + var values: [SortFilterValue] = [] + + struct SortFilterValue: Codable, Hashable { + var n: String = "" // 显示名 + var v: String = "" // 值 + } + } +} diff --git a/tvbox/Models/PlayerEngine.swift b/tvbox/Models/PlayerEngine.swift new file mode 100644 index 0000000..1d3d85a --- /dev/null +++ b/tvbox/Models/PlayerEngine.swift @@ -0,0 +1,133 @@ +import Foundation + +/// 播放器引擎类型 +enum PlayerEngine: Int, CaseIterable, Identifiable { + case system = 0 + case vlc = 10 + + var id: Int { rawValue } + + var title: String { + switch self { + case .system: + return "系统播放器" + case .vlc: + return "VLC播放器" + } + } + + static var isVLCAvailable: Bool { + #if canImport(VLCKitSPM) + return true + #else + return false + #endif + } + + static var availableEngines: [PlayerEngine] { + var engines: [PlayerEngine] = [.system] + if isVLCAvailable { + engines.append(.vlc) + } + return engines + } + + static func fromStoredValue(_ rawValue: Int) -> PlayerEngine { + guard let engine = PlayerEngine(rawValue: rawValue) else { + return .system + } + + if engine == .vlc && !isVLCAvailable { + return .system + } + + return engine + } +} + +/// 视频解码模式 +enum VideoDecodeMode: Int, CaseIterable, Identifiable { + case auto = 0 + case hardware = 1 + case software = 2 + + var id: Int { rawValue } + + var title: String { + switch self { + case .auto: + return "自动" + case .hardware: + return "硬解码" + case .software: + return "软解码" + } + } + + static func fromStoredValue(_ rawValue: Int) -> VideoDecodeMode { + VideoDecodeMode(rawValue: rawValue) ?? .auto + } + + /// VLC 媒体选项 + var vlcHardwareDecodeOption: String? { + switch self { + case .auto: + // 自动模式默认硬解优先,异常时用户仍可手动切到软解。 + return "any" + case .hardware: + return "any" + case .software: + return "none" + } + } +} + +/// VLC 缓冲策略 +enum VLCBufferMode: Int, CaseIterable, Identifiable { + case lowLatency = 0 + case balanced = 1 + case smooth = 2 + + var id: Int { rawValue } + + var title: String { + switch self { + case .lowLatency: + return "低延迟" + case .balanced: + return "均衡" + case .smooth: + return "流畅优先" + } + } + + static var defaultMode: VLCBufferMode { .balanced } + + static func fromStoredValue(_ rawValue: Int) -> VLCBufferMode { + VLCBufferMode(rawValue: rawValue) ?? defaultMode + } + + var enableFrameDrop: Bool { + self == .lowLatency + } + + func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) { + switch self { + case .lowLatency: + if isLive { + return (network: 1200, live: 1200, file: 1600) + } + return (network: 1800, live: 1600, file: 2400) + case .balanced: + if isLive { + return (network: 2600, live: 2600, file: 3200) + } + return (network: 3800, live: 3200, file: 4400) + case .smooth: + if isLive { + return (network: 4200, live: 4200, file: 5200) + } + return (network: 5600, live: 4800, file: 6400) + } + } +} diff --git a/tvbox/Models/SourceBean.swift b/tvbox/Models/SourceBean.swift new file mode 100644 index 0000000..de598cf --- /dev/null +++ b/tvbox/Models/SourceBean.swift @@ -0,0 +1,52 @@ +import Foundation + +/// 视频源站点配置 - 对应 Android 版 SourceBean.java +struct SourceBean: Codable, Identifiable, Hashable { + var id: String { key } + + let key: String + let name: String + let api: String + let searchable: Int // 0:关闭搜索 1:启用搜索 + let filterable: Int // 0:首页不可选 1:首页可选 + let playerType: Int // 0:系统 1:IJK 2:EXO + let type: Int // 0:xml 1:json 3:jar 4:remote + let ext: String? + + init(key: String = "", name: String = "", api: String = "", + searchable: Int = 1, filterable: Int = 1, + playerType: Int = 0, type: Int = 1, ext: String? = nil) { + self.key = key + self.name = name + self.api = api + self.searchable = searchable + self.filterable = filterable + self.playerType = playerType + self.type = type + self.ext = ext + } + + var isSearchable: Bool { searchable == 1 } + var isFilterable: Bool { filterable == 1 } + + /// 是否在 Swift 版中受支持(type=3 为 JAR/Spider,需要 Java 运行时,暂不支持) + var isSupportedInSwift: Bool { + return type == 0 || type == 1 || type == 4 + } + + /// 类型描述 + var typeDescription: String { + switch type { + case 0: return "XML" + case 1: return "JSON" + case 3: return "JAR" + case 4: return "Remote" + default: return "未知" + } + } + + /// api 字段是否为有效 HTTP URL + var isHttpApi: Bool { + return api.hasPrefix("http://") || api.hasPrefix("https://") + } +} diff --git a/tvbox/Models/VodInfo.swift b/tvbox/Models/VodInfo.swift new file mode 100644 index 0000000..97bbd15 --- /dev/null +++ b/tvbox/Models/VodInfo.swift @@ -0,0 +1,85 @@ +import Foundation + +/// 视频详情模型 - 对应 Android 版 VodInfo.java +struct VodInfo: Codable, Identifiable { + var id: String + var name: String = "" + var pic: String = "" + var note: String = "" + var year: String = "" + var area: String = "" + var typeName: String = "" + var director: String = "" + var actor: String = "" + var des: String = "" + var sourceKey: String = "" + + /// 播放来源(线路)列表 + var playFlags: [String] = [] + /// key: flag名称, value: 剧集列表 + var playUrlMap: [String: [Episode]] = [:] + + var playFlag: String = "" // 当前选中线路 + var playIndex: Int = 0 // 当前播放剧集索引 + + /// 单集信息 + struct Episode: Codable, Identifiable, Hashable { + var id: String { name } + let name: String + let url: String + + init(name: String, url: String) { + self.name = name + self.url = url + } + } + + /// 从 Movie.Video 和详情数据构建 + static func from(video: Movie.Video, playFrom: String, playUrl: String) -> VodInfo { + var info = VodInfo(id: video.id) + info.name = video.name + info.pic = video.pic + info.note = video.note + info.year = video.year + info.area = video.area + info.typeName = video.type + info.director = video.director + info.actor = video.actor + info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + info.sourceKey = video.sourceKey + + // 解析播放列表 + // playFrom 格式: "线路1$$$线路2$$$线路3" + // playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4" + let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty } + let urls = playUrl.components(separatedBy: "$$$") + + info.playFlags = flags + for (i, flag) in flags.enumerated() { + if i < urls.count { + let episodes = urls[i].components(separatedBy: "#").compactMap { item -> Episode? in + let parts = item.components(separatedBy: "$") + guard parts.count >= 2 else { return nil } + return Episode(name: parts[0], url: parts[1]) + } + info.playUrlMap[flag] = episodes + } + } + + if let first = flags.first { + info.playFlag = first + } + + return info + } + + var currentEpisodes: [Episode] { + playUrlMap[playFlag] ?? [] + } + + var currentEpisode: Episode? { + let eps = currentEpisodes + guard playIndex >= 0, playIndex < eps.count else { return nil } + return eps[playIndex] + } +} diff --git a/tvbox/Persistence/CacheStore.swift b/tvbox/Persistence/CacheStore.swift new file mode 100644 index 0000000..4665d40 --- /dev/null +++ b/tvbox/Persistence/CacheStore.swift @@ -0,0 +1,192 @@ +import Foundation +import SwiftData + +/// SwiftData 持久化模型 - 对应 Android 版 Room 数据库 + +/// 单部剧的续播状态 +struct VodPlaybackState: Codable { + var flag: String + var episodeIndex: Int + var progressSeconds: Double +} + +/// 视频收藏 +@Model +final class VodCollect { + var vodId: String = "" + var vodName: String = "" + var vodPic: String = "" + var sourceKey: String = "" + var updateTime: Date = Date() + + init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { + self.vodId = vodId + self.vodName = vodName + self.vodPic = vodPic + self.sourceKey = sourceKey + self.updateTime = Date() + } +} + +/// 播放历史记录 +@Model +final class VodRecord { + var vodId: String = "" + var vodName: String = "" + var vodPic: String = "" + var sourceKey: String = "" + var playNote: String = "" // 如 "第5集 03:45" + var dataJson: String = "" // 续播状态 JSON(VodPlaybackState) + var updateTime: Date = Date() + + init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { + self.vodId = vodId + self.vodName = vodName + self.vodPic = vodPic + self.sourceKey = sourceKey + self.playNote = playNote + self.updateTime = Date() + } +} + +/// 通用缓存 +@Model +final class CacheItem { + @Attribute(.unique) var key: String = "" + var value: String = "" + var updateTime: Date = Date() + + init(key: String, value: String) { + self.key = key + self.value = value + self.updateTime = Date() + } +} + +/// 缓存管理器 +actor CacheStore { + static let shared = CacheStore() + + private init() {} + + @MainActor + func addCollect(_ video: Movie.Video, context: ModelContext) { + // 先检查是否已存在 + let vodId = video.id + let sourceKey = video.sourceKey + let predicate = #Predicate { item in + item.vodId == vodId && item.sourceKey == sourceKey + } + let descriptor = FetchDescriptor(predicate: predicate) + + if let existing = try? context.fetch(descriptor), !existing.isEmpty { + return // 已收藏 + } + + let collect = VodCollect( + vodId: video.id, + vodName: video.name, + vodPic: video.pic, + sourceKey: video.sourceKey + ) + context.insert(collect) + try? context.save() + } + + @MainActor + func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { + let predicate = #Predicate { item in + item.vodId == vodId && item.sourceKey == sourceKey + } + let descriptor = FetchDescriptor(predicate: predicate) + if let items = try? context.fetch(descriptor) { + for item in items { + context.delete(item) + } + try? context.save() + } + } + + @MainActor + func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool { + let predicate = #Predicate { item in + item.vodId == vodId && item.sourceKey == sourceKey + } + let descriptor = FetchDescriptor(predicate: predicate) + return (try? context.fetchCount(descriptor)) ?? 0 > 0 + } + + @MainActor + func addRecord( + _ video: Movie.Video, + playNote: String, + playbackState: VodPlaybackState? = nil, + context: ModelContext + ) { + let vodId = video.id + let sourceKey = video.sourceKey + let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) + let encodedState = Self.encodePlaybackState(playbackState) + + // 更新或插入 + if let record { + record.playNote = playNote + if let encodedState { + record.dataJson = encodedState + } + record.updateTime = Date() + } else { + let record = VodRecord( + vodId: video.id, + vodName: video.name, + vodPic: video.pic, + sourceKey: video.sourceKey, + playNote: playNote + ) + if let encodedState { + record.dataJson = encodedState + } + context.insert(record) + } + try? context.save() + } + + @MainActor + func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { + guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else { + return nil + } + return Self.decodePlaybackState(record.dataJson) + } + + @MainActor + func clearHistory(context: ModelContext) { + do { + try context.delete(model: VodRecord.self) + try context.save() + } catch { + print("清空历史记录失败: \(error)") + } + } + + @MainActor + private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { + let predicate = #Predicate { item in + item.vodId == vodId && item.sourceKey == sourceKey + } + let descriptor = FetchDescriptor(predicate: predicate) + guard let records = try? context.fetch(descriptor) else { return nil } + return records.first + } + + private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? { + guard let state else { return nil } + guard let data = try? JSONEncoder().encode(state) else { return nil } + return String(data: data, encoding: .utf8) + } + + private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(VodPlaybackState.self, from: data) + } +} diff --git a/tvbox/Services/ApiConfig.swift b/tvbox/Services/ApiConfig.swift new file mode 100644 index 0000000..18b96e4 --- /dev/null +++ b/tvbox/Services/ApiConfig.swift @@ -0,0 +1,427 @@ +import Foundation + +/// 核心配置管理器 - 对应 Android 版 ApiConfig.java +/// 负责加载和解析远程 JSON 配置,管理视频源列表 +@MainActor +class ApiConfig: ObservableObject { + static let shared = ApiConfig() + + @Published var sourceBeanList: [SourceBean] = [] + @Published var homeSourceBean: SourceBean? + @Published var parseBeanList: [ParseBean] = [] + @Published var liveChannelGroupList: [LiveChannelGroup] = [] + @Published var dohList: [(name: String, url: String)] = [] + @Published var isLoaded: Bool = false + @Published var configUrl: String = "" + @Published var wallpaper: String = "" + + private let network = NetworkManager.shared + + private init() {} + + /// 加载远程配置 + func loadConfig(from apiUrl: String) async throws { + self.configUrl = apiUrl + + let jsonStr = try await network.getString(from: apiUrl) + + // 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理) + let cleanedJson = Self.stripJsonComments(jsonStr) + + guard let data = cleanedJson.data(using: .utf8) else { + throw ConfigError.parseError("无法解析配置数据") + } + + let config = try JSONDecoder().decode(AppConfigData.self, from: data) + parseConfig(config, apiUrl: apiUrl) + } + + /// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式 + /// Android 端 Gson 原生支持注释,Swift 的 JSONDecoder 不支持 + static func stripJsonComments(_ json: String) -> String { + let lines = json.components(separatedBy: "\n") + var result: [String] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + // 跳过纯注释行(以 // 开头) + if trimmed.hasPrefix("//") { + continue + } + // 处理行尾注释:只在引号外的 // 才是注释 + let cleaned = removeInlineComment(from: line) + result.append(cleaned) + } + + var joined = result.joined(separator: "\n") + + // 修复尾部逗号问题:,] 或 ,} (注释行被删除后可能产生) + // 使用正则替换 , 后面跟着空白和 ] 或 } 的情况 + joined = joined.replacingOccurrences( + of: ",\\s*([\\]\\}])", + with: "$1", + options: .regularExpression + ) + + return joined + } + + /// 移除行内注释(只处理不在字符串内的 //) + private static func removeInlineComment(from line: String) -> String { + var inString = false + var escape = false + let chars = Array(line) + + for i in 0.. [LiveChannelGroup] { + var groups: [String: LiveChannelGroup] = [:] + var currentGroupName = "默认" + + let lines = content.components(separatedBy: .newlines) + let firstNonEmptyLine = lines.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let isM3U = firstNonEmptyLine?.uppercased().hasPrefix("#EXTM3U") == true + + // 检测是否为 M3U 格式 + if isM3U { + var currentName = "" + var currentGroup = "默认" + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#EXTINF:") { + // 解析频道名和分组 + if let nameRange = trimmed.range(of: ",", options: .backwards) { + currentName = String(trimmed[nameRange.upperBound...]).trimmingCharacters(in: .whitespaces) + } + currentGroup = "默认" + if let groupMatch = trimmed.range(of: "group-title=\"") { + let afterGroup = trimmed[groupMatch.upperBound...] + if let endQuote = afterGroup.firstIndex(of: "\"") { + currentGroup = String(afterGroup[..= 2 { + let name = parts[0].trimmingCharacters(in: .whitespaces) + let url = parts[1...].joined(separator: ",").trimmingCharacters(in: .whitespaces) + + if !name.isEmpty && Self.isLiveStreamUrl(url) { + appendChannel( + named: name, + urls: [url], + logo: "", + to: currentGroupName, + groups: &groups + ) + } + } + } + } + + return sortedGroups(from: groups) + } + + private func parseInlineLiveChannels(_ channels: [AppConfigData.LiveConfig.LiveChannelConfig]) -> [LiveChannelGroup] { + var groups: [String: LiveChannelGroup] = [:] + for channel in channels { + appendChannel( + named: channel.name ?? "", + urls: channel.urls ?? [], + logo: channel.logo ?? "", + to: channel.group ?? "其他", + groups: &groups + ) + } + return sortedGroups(from: groups) + } + + private func mergeLiveGroups(_ incomingGroups: [LiveChannelGroup], into groups: inout [String: LiveChannelGroup]) { + for group in incomingGroups { + for channel in group.channels { + appendChannel( + named: channel.channelName, + urls: channel.channelUrls, + logo: channel.logo, + to: group.groupName, + groups: &groups + ) + } + } + } + + private func appendChannel( + named channelName: String, + urls: [String], + logo: String, + to groupName: String, + groups: inout [String: LiveChannelGroup] + ) { + let normalizedName = Self.normalizeChannelName(channelName) + guard !normalizedName.isEmpty else { return } + + let validUrls = Self.uniqueLiveUrls(urls) + guard !validUrls.isEmpty else { return } + + let normalizedGroupName = Self.normalizeGroupName(groupName) + if groups[normalizedGroupName] == nil { + groups[normalizedGroupName] = LiveChannelGroup( + groupName: normalizedGroupName, + groupIndex: groups.count + ) + } + + guard var group = groups[normalizedGroupName] else { return } + + if let existingIndex = group.channels.firstIndex(where: { + Self.normalizeChannelName($0.channelName) == normalizedName + }) { + var existing = group.channels[existingIndex] + var existingUrls = Set(existing.channelUrls.map(Self.normalizeLiveUrl)) + for url in validUrls { + let normalizedUrl = Self.normalizeLiveUrl(url) + if !existingUrls.contains(normalizedUrl) { + existing.channelUrls.append(url) + existingUrls.insert(normalizedUrl) + } + } + let trimmedLogo = logo.trimmingCharacters(in: .whitespacesAndNewlines) + if existing.logo.isEmpty && !trimmedLogo.isEmpty { + existing.logo = trimmedLogo + } + group.channels[existingIndex] = existing + } else { + var item = LiveChannelItem(channelName: normalizedName, channelIndex: group.channels.count) + item.channelUrls = validUrls + item.logo = logo.trimmingCharacters(in: .whitespacesAndNewlines) + group.channels.append(item) + } + + groups[normalizedGroupName] = group + } + + private func sortedGroups(from groups: [String: LiveChannelGroup]) -> [LiveChannelGroup] { + groups.values + .sorted { $0.groupIndex < $1.groupIndex } + .map { group in + var reindexedGroup = group + reindexedGroup.channels = group.channels.enumerated().map { index, channel in + var reindexedChannel = channel + reindexedChannel.channelIndex = index + return reindexedChannel + } + return reindexedGroup + } + } + + private func resolveLiveUrl(_ urlString: String, baseConfigUrl: String) -> String { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if let url = URL(string: trimmed), url.scheme != nil { + return trimmed + } + guard let baseUrl = URL(string: baseConfigUrl), + let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else { + return trimmed + } + return resolved.absoluteString + } + + private static func uniqueLiveUrls(_ urls: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + + for url in urls { + let normalized = normalizeLiveUrl(url) + guard !normalized.isEmpty, isLiveStreamUrl(normalized), !seen.contains(normalized) else { continue } + seen.insert(normalized) + result.append(normalized) + } + + return result + } + + private static func normalizeGroupName(_ groupName: String) -> String { + let trimmed = groupName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "默认" : trimmed + } + + private static func normalizeChannelName(_ channelName: String) -> String { + channelName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func normalizeLiveUrl(_ url: String) -> String { + url.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isLiveStreamUrl(_ url: String) -> Bool { + let lowercased = url.lowercased() + return lowercased.hasPrefix("http://") + || lowercased.hasPrefix("https://") + || lowercased.hasPrefix("rtmp://") + || lowercased.hasPrefix("rtsp://") + } + + /// 获取指定 key 的源 + func getSource(key: String) -> SourceBean? { + sourceBeanList.first(where: { $0.key == key }) + } + + /// 获取可搜索的源列表 + func getSearchableSources() -> [SourceBean] { + sourceBeanList.filter { $0.isSearchable } + } + + /// 设置主页源 + func setHomeSource(_ source: SourceBean) { + self.homeSourceBean = source + UserDefaults.standard.set(source.key, forKey: HawkConfig.HOME_API) + } +} + +enum ConfigError: LocalizedError { + case parseError(String) + case networkError(String) + + var errorDescription: String? { + switch self { + case .parseError(let msg): return "配置解析错误: \(msg)" + case .networkError(let msg): return "网络错误: \(msg)" + } + } +} diff --git a/tvbox/Services/NetworkManager.swift b/tvbox/Services/NetworkManager.swift new file mode 100644 index 0000000..fc6abb6 --- /dev/null +++ b/tvbox/Services/NetworkManager.swift @@ -0,0 +1,78 @@ +import Foundation + +/// 网络请求封装 - 对应 Android 版 OkGo +class NetworkManager { + static let shared = NetworkManager() + + private let session: URLSession + private let decoder = JSONDecoder() + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.httpMaximumConnectionsPerHost = 5 + self.session = URLSession(configuration: config) + } + + /// GET 请求获取字符串 + func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String { + guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw NetworkError.invalidURL(urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) } + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.httpError(httpResponse.statusCode) + } + + guard let str = String(data: data, encoding: .utf8) else { + throw NetworkError.decodingError("UTF-8 解码失败") + } + + return str + } + + /// GET 请求解码 JSON + func getJSON(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T { + let str = try await getString(from: urlString, headers: headers) + guard let data = str.data(using: .utf8) else { + throw NetworkError.decodingError("字符串转 Data 失败") + } + return try decoder.decode(T.self, from: data) + } + + /// GET 请求获取原始 Data + func getData(from urlString: String) async throws -> Data { + guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw NetworkError.invalidURL(urlString) + } + let (data, _) = try await session.data(from: url) + return data + } +} + +enum NetworkError: LocalizedError { + case invalidURL(String) + case invalidResponse + case httpError(Int) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): return "无效的URL: \(url)" + case .invalidResponse: return "无效的响应" + case .httpError(let code): return "HTTP 错误: \(code)" + case .decodingError(let msg): return "解码错误: \(msg)" + } + } +} diff --git a/tvbox/Services/SourceService.swift b/tvbox/Services/SourceService.swift new file mode 100644 index 0000000..643ffc2 --- /dev/null +++ b/tvbox/Services/SourceService.swift @@ -0,0 +1,417 @@ +import Foundation + +/// 视频源数据服务 - 对应 Android 版 SourceViewModel.java +/// 负责从各视频源获取分类、列表、详情和搜索数据 +class SourceService { + static let shared = SourceService() + + private let network = NetworkManager.shared + + private init() {} + + // MARK: - 获取分类列表 + + /// 获取指定源的分类列表和首页推荐 + func getSort(sourceBean: SourceBean) async throws -> (sorts: [MovieSort.SortData], homeVideos: [Movie.Video]) { + let api = sourceBean.api + guard !api.isEmpty else { + throw SourceError.emptyApi + } + + // type=3 (JAR/Spider) 暂不支持 + guard sourceBean.isSupportedInSwift else { + throw SourceError.unsupportedType(sourceBean.typeDescription) + } + + // 确保 api 是有效的 HTTP URL + guard sourceBean.isHttpApi else { + throw SourceError.invalidApiUrl(api) + } + + let jsonStr: String + if sourceBean.type == 0 { + // XML 接口 + jsonStr = try await network.getString(from: api) + } else if sourceBean.type == 4 { + // Type 4: 远程接口,需要 extend 和 filter 参数 + var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true" + // 加载 extend + if let ext = sourceBean.ext, !ext.isEmpty { + let extend = await resolveExtend(ext) + if !extend.isEmpty { + let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend + url += "&extend=\(encoded)" + } + } + jsonStr = try await network.getString(from: url) + } else { + // JSON 接口 (type=1) + let url = api.contains("?") ? "\(api)&ac=class" : "\(api)?ac=class" + jsonStr = try await network.getString(from: url) + } + + var (sorts, homeVideos) = try parseSort(jsonStr, sourceBean: sourceBean) + + // 当大多数推荐视频的 vod_pic 为空时(ac=class 接口常见情况), + // 额外请求列表接口获取带完整海报的推荐视频 + let picMissingCount = homeVideos.filter { $0.pic.trimmingCharacters(in: .whitespaces).isEmpty }.count + let needsFallback = homeVideos.isEmpty || picMissingCount > homeVideos.count / 2 + + if needsFallback && (sourceBean.type == 1 || sourceBean.type == 4) { + let listUrl: String + if sourceBean.type == 4 { + // type=4 用 ac=detail 格式,与 getList 保持一致 + let ext = Data("{}".utf8).base64EncodedString() + listUrl = api.contains("?") + ? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)" + : "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)" + } else { + // type=1 用 ac=videolist 格式 + listUrl = api.contains("?") ? "\(api)&ac=videolist&pg=1" : "\(api)?ac=videolist&pg=1" + } + if let listStr = try? await network.getString(from: listUrl) { + let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? [] + if !fallback.isEmpty { + homeVideos = fallback + } + } + } + + return (sorts, homeVideos) + } + + private func parseSort(_ jsonStr: String, sourceBean: SourceBean) throws -> (sorts: [MovieSort.SortData], homeVideos: [Movie.Video]) { + guard let data = jsonStr.data(using: .utf8) else { + throw SourceError.parseError("无法解析数据") + } + + var sorts: [MovieSort.SortData] = [] + var homeVideos: [Movie.Video] = [] + + if sourceBean.type == 0 { + // XML 格式 + sorts = parseXMLCategories(from: jsonStr) + } else { + // JSON 格式 (type=1, type=4) + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + // 解析分类 + if let classList = json["class"] as? [[String: Any]] { + for cls in classList { + let id: String + if let intId = cls["type_id"] as? Int { + id = String(intId) + } else { + id = cls["type_id"] as? String ?? "" + } + let name = cls["type_name"] as? String ?? "" + sorts.append(MovieSort.SortData(id: id, name: name)) + } + } + + // 解析首页推荐视频 + if let list = json["list"] as? [[String: Any]] { + for item in list { + let decoder = JSONDecoder() + if let itemData = try? JSONSerialization.data(withJSONObject: item), + var video = try? decoder.decode(Movie.Video.self, from: itemData) { + video.sourceKey = sourceBean.key + homeVideos.append(video) + } + } + } + } + } + + return (sorts, homeVideos) + } + + private func parseXMLCategories(from xml: String) -> [MovieSort.SortData] { + // 简化的 XML 分类解析 + var sorts: [MovieSort.SortData] = [] + let pattern = "]*>([^<]+)" + if let regex = try? NSRegularExpression(pattern: pattern) { + let matches = regex.matches(in: xml, range: NSRange(xml.startIndex..., in: xml)) + for match in matches { + if let idRange = Range(match.range(at: 1), in: xml), + let nameRange = Range(match.range(at: 2), in: xml) { + let id = String(xml[idRange]) + let name = String(xml[nameRange]) + sorts.append(MovieSort.SortData(id: id, name: name)) + } + } + } + return sorts + } + + // MARK: - 获取分类视频列表 + + /// 获取分类下的视频列表 + func getList(sourceBean: SourceBean, sortData: MovieSort.SortData, page: Int = 1, filters: [String: String]? = nil) async throws -> [Movie.Video] { + let api = sourceBean.api + guard !api.isEmpty else { throw SourceError.emptyApi } + guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } + guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } + + var url: String + if sourceBean.type == 0 { + // XML 接口 + url = "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" + } else if sourceBean.type == 4 { + // Type 4: 远程接口 + url = api.contains("?") + ? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" + : "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" + + // 附加筛选参数(base64 编码) + if let filters = filters, !filters.isEmpty { + if let filterData = try? JSONSerialization.data(withJSONObject: filters), + let filterStr = String(data: filterData, encoding: .utf8) { + let ext = Data(filterStr.utf8).base64EncodedString() + url += "&ext=\(ext)" + } + } else { + let ext = Data("{}".utf8).base64EncodedString() + url += "&ext=\(ext)" + } + + // 加载 extend + if let ext = sourceBean.ext, !ext.isEmpty { + let extend = await resolveExtend(ext) + if !extend.isEmpty { + let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend + url += "&extend=\(encoded)" + } + } + } else { + // JSON 接口 (type=1) + url = api.contains("?") + ? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)" + : "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" + + // 附加筛选参数 + if let filters = filters { + for (key, value) in filters { + url += "&\(key)=\(value)" + } + } + } + + let jsonStr = try await network.getString(from: url) + return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type) + } + + private func parseVideoList(_ jsonStr: String, sourceKey: String, type: Int) throws -> [Movie.Video] { + guard let data = jsonStr.data(using: .utf8) else { + throw SourceError.parseError("无法解析数据") + } + + var videos: [Movie.Video] = [] + + if type == 0 { + videos = parseXMLVideoList(from: jsonStr, sourceKey: sourceKey) + } else { + // JSON 格式 (type=1, type=4) + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let list = json["list"] as? [[String: Any]] { + let decoder = JSONDecoder() + for item in list { + if let itemData = try? JSONSerialization.data(withJSONObject: item), + var video = try? decoder.decode(Movie.Video.self, from: itemData) { + video.sourceKey = sourceKey + videos.append(video) + } + } + } + } + + return videos + } + + private func parseXMLVideoList(from xml: String, sourceKey: String) -> [Movie.Video] { + // 简化 XML 视频列表解析 + var videos: [Movie.Video] = [] + let pattern = "" + if let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators) { + let matches = regex.matches(in: xml, range: NSRange(xml.startIndex..., in: xml)) + for match in matches { + var video = Movie.Video() + if let r = Range(match.range(at: 1), in: xml) { video.id = String(xml[r]) } + if let r = Range(match.range(at: 2), in: xml) { video.name = String(xml[r]) } + if let r = Range(match.range(at: 3), in: xml) { video.pic = String(xml[r]) } + if let r = Range(match.range(at: 4), in: xml) { video.note = String(xml[r]) } + video.sourceKey = sourceKey + videos.append(video) + } + } + return videos + } + + // MARK: - 获取详情 + + /// 获取视频详情 + func getDetail(sourceBean: SourceBean, vodId: String) async throws -> VodInfo? { + let api = sourceBean.api + guard !api.isEmpty else { throw SourceError.emptyApi } + guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } + guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } + + var url: String + if sourceBean.type == 0 { + url = "\(api)?ac=videolist&ids=\(vodId)" + } else if sourceBean.type == 4 { + // Type 4: 远程接口 + url = api.contains("?") + ? "\(api)&ac=detail&ids=\(vodId)" + : "\(api)?ac=detail&ids=\(vodId)" + + // 加载 extend + if let ext = sourceBean.ext, !ext.isEmpty { + let extend = await resolveExtend(ext) + if !extend.isEmpty { + let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend + url += "&extend=\(encoded)" + } + } + } else { + // JSON 接口 (type=1) + url = api.contains("?") + ? "\(api)&ac=detail&ids=\(vodId)" + : "\(api)?ac=detail&ids=\(vodId)" + } + + let jsonStr = try await network.getString(from: url) + return try parseDetail(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type) + } + + private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? { + guard let data = jsonStr.data(using: .utf8) else { + throw SourceError.parseError("无法解析数据") + } + + if type == 1 || type == 4 { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let list = json["list"] as? [[String: Any]], + let first = list.first { + + let decoder = JSONDecoder() + if let itemData = try? JSONSerialization.data(withJSONObject: first), + var video = try? decoder.decode(Movie.Video.self, from: itemData) { + video.sourceKey = sourceKey + + let playFrom = first["vod_play_from"] as? String ?? "" + let playUrl = first["vod_play_url"] as? String ?? "" + + return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl) + } + } + } + + return nil + } + + // MARK: - 搜索 + + /// 在指定源中搜索 + func search(sourceBean: SourceBean, keyword: String) async throws -> [Movie.Video] { + let api = sourceBean.api + guard !api.isEmpty else { throw SourceError.emptyApi } + guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } + guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } + + let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword + + var url: String + if sourceBean.type == 0 { + url = "\(api)?wd=\(encodedKeyword)" + } else if sourceBean.type == 4 { + // Type 4: 远程接口 + url = api.contains("?") + ? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false" + : "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false" + + // 加载 extend + if let ext = sourceBean.ext, !ext.isEmpty { + let extend = await resolveExtend(ext) + if !extend.isEmpty { + let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend + url += "&extend=\(encoded)" + } + } + } else { + // JSON 接口 (type=1) + url = api.contains("?") + ? "\(api)&wd=\(encodedKeyword)" + : "\(api)?wd=\(encodedKeyword)" + } + + let jsonStr = try await network.getString(from: url) + return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type) + } + + /// 多源并发搜索 + func searchAll(keyword: String) async -> [Movie.Video] { + let sources = await ApiConfig.shared.getSearchableSources() + + return await withTaskGroup(of: [Movie.Video].self) { group in + for source in sources { + // 跳过不支持的源类型 + guard source.isSupportedInSwift && source.isHttpApi else { continue } + + group.addTask { [self] in + do { + return try await self.search(sourceBean: source, keyword: keyword) + } catch { + return [] + } + } + } + + var allResults: [Movie.Video] = [] + for await results in group { + allResults.append(contentsOf: results) + } + return allResults + } + } + + // MARK: - Extend 解析 + + /// 解析 extend 参数(对应 Android 端 getFixUrl) + /// 如果 extend 是 HTTP URL,则下载其内容作为 extend 值 + /// 如果 extend 是普通字符串,则直接返回 + private func resolveExtend(_ extend: String) async -> String { + guard !extend.isEmpty else { return "" } + + // 非 HTTP URL 直接返回 + guard extend.hasPrefix("http://") || extend.hasPrefix("https://") else { + return extend + } + + // 从 HTTP URL 加载 extend 内容 + do { + let content = try await network.getString(from: extend) + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + // 如果内容过长(>2500),回退到使用原始 URL + if trimmed.count > 2500 { return extend } + return trimmed + } catch { + return extend + } + } +} + +enum SourceError: LocalizedError { + case emptyApi + case parseError(String) + case unsupportedType(String) + case invalidApiUrl(String) + + var errorDescription: String? { + switch self { + case .emptyApi: return "接口地址为空" + case .parseError(let msg): return "数据解析错误: \(msg)" + case .unsupportedType(let type): return "暂不支持 \(type) 类型的数据源,请切换其他源" + case .invalidApiUrl(let url): return "无效的接口地址: \(url)" + } + } +} diff --git a/tvbox/Utils/Extensions.swift b/tvbox/Utils/Extensions.swift new file mode 100644 index 0000000..f9e7849 --- /dev/null +++ b/tvbox/Utils/Extensions.swift @@ -0,0 +1,597 @@ +import SwiftUI +import ImageIO + +#if os(macOS) +import AppKit +#endif + +/// 配置键定义 - 对应 Android 版 HawkConfig.java +struct HawkConfig { + static let API_URL = "api_url" + static let HOME_API = "home_api" + static let HOME_REC = "home_rec" + static let PLAY_TYPE = "play_type" + static let DOH_URL = "doh_url" + static let SEARCH_VIEW = "search_view" + static let LIVE_API_URL = "live_api_url" + static let PARSE_WEBVIEW = "parse_webview" + static let IJK_CODEC = "ijk_codec" + static let RENDER_TYPE = "render_type" + static let PLAY_SCALE = "play_scale" + static let PLAY_SPEED = "play_speed" + static let PLAY_VOLUME = "play_volume" + static let PLAY_DECODE_MODE = "play_decode_mode" + static let PLAY_VLC_BUFFER_MODE = "play_vlc_buffer_mode" + static let PLAY_TIME_STEP = "play_time_step" + static let HOME_REC_STYLE = "home_rec_style" + static let HISTORY_NUM = "history_num" + static let SEARCH_HISTORY = "search_history" +} + +/// 通用 Swift 扩展 +extension String { + /// 移除 HTML 标签 + var stripHTML: String { + replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// 是否为有效 URL + var isValidURL: Bool { + guard let url = URL(string: self) else { return false } + return url.scheme == "http" || url.scheme == "https" + } +} + +extension Date { + /// 格式化日期显示 + var displayString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MM/dd HH:mm" + formatter.locale = Locale(identifier: "zh_CN") + return formatter.string(from: self) + } + + var timeString: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: self) + } + + /// 首页日期显示 + var homeDateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd EEEE HH:mm:ss" + formatter.locale = Locale(identifier: "zh_CN") + return formatter.string(from: self) + } +} + +extension Int { + /// 播放时长格式化 + var durationString: String { + let hours = self / 3600 + let minutes = (self % 3600) / 60 + let seconds = self % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} + +extension Double { + /// 播放时长格式化 + var durationString: String { + Int(self).durationString + } +} + +// MARK: - Design System (Merged from DesignSystem.swift) + +struct AppTheme { + static let primaryGradient = LinearGradient( + colors: [ + Color(hex: "1a1a2e"), + Color(hex: "16213e"), + Color(hex: "0f3460") + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let accentGradient = LinearGradient( + colors: [.orange, .red], + startPoint: .leading, + endPoint: .trailing + ) + + static let glassBackgroud = Color.white.opacity(0.1) + static let cardRadius: CGFloat = 16 + static let glassRadius: CGFloat = 20 +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// 玻璃拟态基础组件 +struct GlassBackground: ViewModifier { + var cornerRadius: CGFloat = AppTheme.glassRadius + + func body(content: Content) -> some View { + content + #if os(iOS) + .background(.ultraThinMaterial) + #else + .background(VisualEffectView().opacity(0.85)) + #endif + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.15), lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 5) + } +} + +#if os(macOS) +struct VisualEffectView: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.state = .active + view.material = .underWindowBackground + return view + } + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} +#endif + +extension View { + func glassCard(cornerRadius: CGFloat = AppTheme.glassRadius) -> some View { + self.modifier(GlassBackground(cornerRadius: cornerRadius)) + } +} + +// MARK: - Image URL + +extension URL { + /// 统一解析海报 URL,处理协议缺失和已知防盗链域名 + static func posterURL(from raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var absolute = trimmed + if absolute.hasPrefix("//") { + absolute = "https:\(absolute)" + } + + guard let originalURL = URL(string: absolute), + let host = originalURL.host?.lowercased() else { + return nil + } + + // 部分源图片域名有防盗链,直接访问会 403,走代理兜底 + if host == "img.picbf.com" { + var components = URLComponents(string: "https://images.weserv.nl/") + components?.queryItems = [URLQueryItem(name: "url", value: absolute)] + return components?.url + } + + return originalURL + } +} + +// MARK: - Cached Async Image + +/// 自定义异步图片加载组件 +/// AsyncImage 不支持自定义 header,许多图片服务器需要 Referer/User-Agent 才能正常返回图片 +/// 此组件通过自定义 URLSession 发起请求,解决海报永远加载不出来的问题 +struct CachedAsyncImage: View { + let url: URL? + @ViewBuilder let content: (Image) -> Content + @ViewBuilder let placeholder: () -> Placeholder + + @State private var loadedImage: PlatformImage? + + init( + url: URL?, + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.url = url + self.content = content + self.placeholder = placeholder + } + + var body: some View { + Group { + if let image = loadedImage { + #if os(macOS) + content(Image(nsImage: image)) + #else + content(Image(uiImage: image)) + #endif + } else { + placeholder() + } + } + .task(id: url) { + await loadImage() + } + .onDisappear { + loadedImage = nil + } + } + + private func loadImage() async { + guard let url = url else { + loadedImage = nil + return + } + + if let cached = ImageCache.shared.get(for: url) { + loadedImage = cached + return + } + + do { + let image = try await ImageLoader.shared.load(url: url) + guard !Task.isCancelled else { return } + ImageCache.shared.set(image, for: url) + loadedImage = image + } catch { + guard !Task.isCancelled else { return } + loadedImage = nil + } + } +} + +// MARK: - Platform Image Type +#if os(macOS) +typealias PlatformImage = NSImage +#else +import UIKit +typealias PlatformImage = UIImage +#endif + +// MARK: - Image Loader + +/// 使用自定义 URLSession 加载图片,支持自定义请求头 +@MainActor +final class ImageLoader { + static let shared = ImageLoader() + + private let session: URLSession + private let urlCache: URLCache + private let thumbnailMaxPixelSize: CGFloat = 420 + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 20 + config.httpMaximumConnectionsPerHost = 6 + let cache = URLCache( + memoryCapacity: 12 * 1024 * 1024, + diskCapacity: 120 * 1024 * 1024, + diskPath: "image_cache" + ) + config.urlCache = cache + config.requestCachePolicy = .useProtocolCachePolicy + self.urlCache = cache + self.session = URLSession(configuration: config) + } + + func load(url: URL) async throws -> PlatformImage { + var request = URLRequest(url: url) + request.httpMethod = "GET" + // 设置常见的浏览器 User-Agent,避免部分服务器拒绝请求 + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + forHTTPHeaderField: "User-Agent" + ) + // 部分图片服务器检查 Referer + if let host = url.host { + request.setValue("https://\(host)/", forHTTPHeaderField: "Referer") + } + + let (data, response) = try await session.data(for: request) + + // 检查 HTTP 状态码 + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw ImageLoadError.httpError(httpResponse.statusCode) + } + + let maxPixelSize = thumbnailMaxPixelSize + guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else { + throw ImageLoadError.invalidData + } + + return image + } + + var cacheUsage: (memory: Int, disk: Int) { + (urlCache.currentMemoryUsage, urlCache.currentDiskUsage) + } + + func clearCache() { + urlCache.removeAllCachedResponses() + } + + private static func decodeImage(data: Data, maxPixelSize: CGFloat) -> PlatformImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return PlatformImage(data: data) + } + + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(1, Int(maxPixelSize.rounded(.up))), + kCGImageSourceShouldCacheImmediately: true + ] + + if let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) { + #if os(macOS) + return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + #else + return UIImage(cgImage: cgImage) + #endif + } + + return PlatformImage(data: data) + } +} + +enum ImageLoadError: Error { + case httpError(Int) + case invalidData +} + +// MARK: - Image Memory Cache + +/// 简单的内存缓存,避免重复下载 +@MainActor +final class ImageCache { + static let shared = ImageCache() + + private let cache = NSCache() + + private init() { + cache.countLimit = 120 + cache.totalCostLimit = 40 * 1024 * 1024 + } + + func get(for url: URL) -> PlatformImage? { + cache.object(forKey: url as NSURL) + } + + func set(_ image: PlatformImage, for url: URL) { + cache.setObject(image, forKey: url as NSURL, cost: image.memoryCost) + } + + func clear() { + cache.removeAllObjects() + } +} + +#if os(macOS) +extension NSImage { + var memoryCost: Int { + guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return 1 } + return cgImage.bytesPerRow * cgImage.height + } +} +#else +extension UIImage { + var memoryCost: Int { + if let cgImage { + return cgImage.bytesPerRow * cgImage.height + } + let pixelWidth = size.width * scale + let pixelHeight = size.height * scale + return max(1, Int(pixelWidth * pixelHeight * 4)) + } +} +#endif + +// MARK: - Selection Modal + +/// 通用选择对话框 - 重新设计的玻璃拟态样式 +struct SelectionModal: View { + let title: String + let icon: String // SF Symbol 名称 + let items: [Item] + let selectedItem: Item? + let itemTitle: (Item) -> String + let onSelect: (Item) -> Void + let onCancel: () -> Void + + // 动画状态 + @State private var animateIn = false + @State private var hoverItemId: Item.ID? = nil + + var body: some View { + ZStack { + // 背景遮罩 + Color.black.opacity(0.4) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + animateIn = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onCancel() + } + } + .ignoresSafeArea() + + VStack(spacing: 0) { + // 顶部图标 & 标题 + VStack(spacing: 16) { + ZStack { + // 动态光晕背景 + Circle() + .fill(LinearGradient(colors: [.orange, .red], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .blur(radius: 20) + .opacity(0.4) + + // 主图标 + Image(systemName: icon) + .font(.system(size: 26, weight: .bold)) + .foregroundColor(.white) + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Color.white.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + ) + .shadow(color: Color.black.opacity(0.25), radius: 8, x: 0, y: 4) + } + .padding(.top, 28) + + Text(title) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + } + .padding(.bottom, 24) + + // 选项列表 + ScrollView(showsIndicators: false) { + VStack(spacing: 8) { + ForEach(items) { item in + Button { + withAnimation(.easeInOut(duration: 0.1)) { + onSelect(item) + } + } label: { + HStack(spacing: 16) { + Text(itemTitle(item)) + .font(.system(size: 15, weight: item == selectedItem ? .semibold : .medium)) + .foregroundColor(item == selectedItem ? .white : .white.opacity(0.7)) + + Spacer() + + if item == selectedItem { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.orange) + .font(.system(size: 18)) + .shadow(color: Color.orange.opacity(0.4), radius: 4) + } else { + Circle() + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1.5) + .frame(width: 18, height: 18) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(selectionBackground(item: item)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(selectionStroke(item: item), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .onHover { isHovering in + withAnimation(.easeInOut(duration: 0.2)) { + hoverItemId = isHovering ? item.id : nil + } + } + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: hoverItemId) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + .frame(maxHeight: 320) + + Divider() + .background(Color.white.opacity(0.1)) + .padding(.horizontal, 24) + + // 取消按钮 + Button { + withAnimation(.easeInOut(duration: 0.2)) { + animateIn = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onCancel() + } + } label: { + Text("取消") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white.opacity(0.5)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + .frame(width: 300) + .glassCard(cornerRadius: 28) + .scaleEffect(animateIn ? 1 : 0.9) + .opacity(animateIn ? 1 : 0) + .shadow(color: Color.black.opacity(0.5), radius: 40, x: 0, y: 20) + } + .onAppear { + withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { + animateIn = true + } + } + } + + private func selectionBackground(item: Item) -> Color { + if item == selectedItem { + return Color.orange.opacity(0.2) + } else if item.id == hoverItemId { + return Color.white.opacity(0.1) + } else { + return Color.white.opacity(0.04) + } + } + + private func selectionStroke(item: Item) -> Color { + if item == selectedItem { + return Color.orange.opacity(0.6) + } else if item.id == hoverItemId { + return Color.white.opacity(0.2) + } else { + return Color.clear + } + } +} + +// 模拟扩展 +extension Int: @retroactive Identifiable { + public var id: Int { self } +} diff --git a/tvbox/ViewModels/DetailViewModel.swift b/tvbox/ViewModels/DetailViewModel.swift new file mode 100644 index 0000000..879b667 --- /dev/null +++ b/tvbox/ViewModels/DetailViewModel.swift @@ -0,0 +1,459 @@ +import Foundation +import SwiftUI + +struct PlaybackQualityOption: Identifiable, Hashable { + static let autoIdentifier = "auto" + let id: String + let name: String + let url: String + + var isAuto: Bool { + id == Self.autoIdentifier + } + + static func auto(url: String) -> PlaybackQualityOption { + PlaybackQualityOption(id: autoIdentifier, name: "自动", url: url) + } +} + +/// 详情页 ViewModel +@MainActor +class DetailViewModel: ObservableObject { + @Published var vodInfo: VodInfo? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var selectedFlag: String = "" + @Published var selectedEpisodeIndex: Int = 0 + @Published var isPlaying = false + @Published var playUrl: String? + @Published var resumeSeconds: Double = 0 + @Published var qualityOptions: [PlaybackQualityOption] = [] + @Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier + private var realtimeProgressSeconds: Double = 0 + + private let sourceService = SourceService.shared + private let network = NetworkManager.shared + private var qualityBaseEpisodeURL: String = "" + private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:] + private var qualityResolveTask: Task? + private var qualityResolveToken = UUID() + + /// 加载视频详情 + func loadDetail(video: Movie.Video) async { + guard let source = ApiConfig.shared.getSource(key: video.sourceKey) + ?? ApiConfig.shared.homeSourceBean else { return } + + isLoading = true + errorMessage = nil + + do { + if let info = try await sourceService.getDetail(sourceBean: source, vodId: video.id) { + self.vodInfo = info + self.selectedFlag = info.playFlag + self.selectedEpisodeIndex = info.playIndex + self.resumeSeconds = 0 + self.realtimeProgressSeconds = 0 + if let episode = info.currentEpisode { + updateQualityOptions(for: episode.url, resetSelection: true) + } else { + resetQualityState() + } + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + /// 选择线路 + func selectFlag(_ flag: String) { + guard selectedFlag != flag else { return } + let currentIndex = selectedEpisodeIndex + + selectedFlag = flag + vodInfo?.playFlag = flag + resumeSeconds = 0 + realtimeProgressSeconds = 0 + + let episodes = vodInfo?.playUrlMap[flag] ?? [] + guard !episodes.isEmpty else { + selectedEpisodeIndex = 0 + vodInfo?.playIndex = 0 + resetQualityState() + return + } + + let targetIndex = min(max(currentIndex, 0), episodes.count - 1) + selectedEpisodeIndex = targetIndex + vodInfo?.playIndex = targetIndex + let episodeURL = episodes[targetIndex].url + updateQualityOptions(for: episodeURL, resetSelection: true) + + // 播放中切线路时,立即切换到新线路对应剧集 + if isPlaying { + playUrl = selectedPlayableURL(fallback: episodeURL) + } + } + + /// 选择剧集并播放 + func selectEpisode(index: Int) { + guard selectedEpisodeIndex != index || !isPlaying else { return } + selectedEpisodeIndex = index + vodInfo?.playIndex = index + resumeSeconds = 0 + realtimeProgressSeconds = 0 + + if let episode = vodInfo?.currentEpisode { + let shouldResetQuality = qualityBaseEpisodeURL != episode.url + updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality) + playUrl = selectedPlayableURL(fallback: episode.url) + isPlaying = true + } + } + + /// 应用历史续播状态并自动继续播放 + func applyPlaybackState(_ state: VodPlaybackState) { + guard let info = vodInfo, !info.playFlags.isEmpty else { return } + + let fallbackFlag = info.playFlag.isEmpty ? info.playFlags[0] : info.playFlag + let targetFlag = info.playFlags.contains(state.flag) ? state.flag : fallbackFlag + + selectedFlag = targetFlag + vodInfo?.playFlag = targetFlag + + let episodes = vodInfo?.playUrlMap[targetFlag] ?? [] + guard !episodes.isEmpty else { return } + + let targetIndex = min(max(state.episodeIndex, 0), episodes.count - 1) + selectedEpisodeIndex = targetIndex + vodInfo?.playIndex = targetIndex + + let progress = max(0, state.progressSeconds) + resumeSeconds = progress + realtimeProgressSeconds = progress + let episodeURL = episodes[targetIndex].url + updateQualityOptions(for: episodeURL, resetSelection: true) + playUrl = selectedPlayableURL(fallback: episodeURL) + isPlaying = true + } + + /// 选择清晰度 + func selectQuality(_ option: PlaybackQualityOption) { + guard qualityOptions.contains(option) else { return } + selectedQualityId = option.id + guard isPlaying else { return } + + let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url + guard !targetURL.isEmpty, playUrl != targetURL else { return } + + let progress = max(currentPlaybackSeconds(), 0) + resumeSeconds = progress + realtimeProgressSeconds = progress + playUrl = targetURL + } + + /// 播放器时间回调 + func updatePlaybackProgress(seconds: Double) { + guard seconds.isFinite else { return } + realtimeProgressSeconds = max(seconds, 0) + } + + /// 当前实时进度(不触发 UI 高频刷新) + func currentPlaybackSeconds() -> Double { + max(realtimeProgressSeconds, resumeSeconds) + } + + /// 仅在必要时同步快照到可观察状态 + func commitPlaybackProgressSnapshot() { + let snapshot = max(realtimeProgressSeconds, 0) + if abs(snapshot - resumeSeconds) >= 1 { + resumeSeconds = snapshot + } + } + + /// 播放下一集 + func playNext() -> Bool { + guard let info = vodInfo else { return false } + let episodes = info.currentEpisodes + if selectedEpisodeIndex + 1 < episodes.count { + selectEpisode(index: selectedEpisodeIndex + 1) + return true + } + return false + } + + /// 播放上一集 + func playPrevious() -> Bool { + if selectedEpisodeIndex > 0 { + selectEpisode(index: selectedEpisodeIndex - 1) + return true + } + return false + } + + /// 当前剧集列表 + var currentEpisodes: [VodInfo.Episode] { + vodInfo?.playUrlMap[selectedFlag] ?? [] + } + + /// 可选线路列表 + var flags: [String] { + vodInfo?.playFlags ?? [] + } + + /// 是否存在可选清晰度 + var hasQualityChoices: Bool { + qualityOptions.count > 1 + } + + private func selectedPlayableURL(fallback: String) -> String { + let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url + if let selected, !selected.isEmpty { + return selected + } + return fallback + } + + private func resetQualityState() { + qualityResolveTask?.cancel() + qualityResolveTask = nil + qualityBaseEpisodeURL = "" + qualityOptions = [] + selectedQualityId = PlaybackQualityOption.autoIdentifier + qualityResolveToken = UUID() + } + + private func updateQualityOptions(for episodeURL: String, resetSelection: Bool) { + let trimmedEpisodeURL = episodeURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedEpisodeURL.isEmpty else { + resetQualityState() + return + } + + qualityResolveTask?.cancel() + qualityResolveTask = nil + + let autoOption = PlaybackQualityOption.auto(url: trimmedEpisodeURL) + let previousSelected = selectedQualityId + + qualityBaseEpisodeURL = trimmedEpisodeURL + if resetSelection { + selectedQualityId = PlaybackQualityOption.autoIdentifier + } + + qualityOptions = [autoOption] + + if let cached = qualityOptionCache[trimmedEpisodeURL] { + qualityOptions = cached + if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) { + selectedQualityId = PlaybackQualityOption.autoIdentifier + } else if previousSelected != selectedQualityId && cached.contains(where: { $0.id == previousSelected }) { + selectedQualityId = previousSelected + } + return + } + + let token = UUID() + qualityResolveToken = token + qualityResolveTask = Task { [trimmedEpisodeURL, resetSelection, previousSelected] in + let resolved = await resolveQualityOptions(for: trimmedEpisodeURL) + guard !Task.isCancelled else { return } + guard qualityResolveToken == token, qualityBaseEpisodeURL == trimmedEpisodeURL else { return } + guard !resolved.isEmpty else { return } + + qualityOptionCache[trimmedEpisodeURL] = resolved + qualityOptions = resolved + + if resetSelection { + selectedQualityId = PlaybackQualityOption.autoIdentifier + } else if resolved.contains(where: { $0.id == selectedQualityId }) { + // 当前选择仍有效,保持不变 + } else if resolved.contains(where: { $0.id == previousSelected }) { + selectedQualityId = previousSelected + } else { + selectedQualityId = PlaybackQualityOption.autoIdentifier + } + } + } + + private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] { + guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] } + guard let playlist = try? await network.getString(from: episodeURL) else { return [] } + return Self.parseMasterPlaylist(playlist, masterURL: url) + } + + private struct HLSVariant { + let url: String + let name: String? + let height: Int? + let bandwidth: Int? + } + + private static func looksLikeHLSURL(_ url: URL) -> Bool { + let lowercased = url.absoluteString.lowercased() + if lowercased.contains(".m3u8") { return true } + let ext = url.pathExtension.lowercased() + return ext == "m3u8" || ext == "m3u" + } + + private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] { + guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] } + + let lines = content.components(separatedBy: .newlines) + var variants: [HLSVariant] = [] + var index = 0 + + while index < lines.count { + let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix("#EXT-X-STREAM-INF:") else { + index += 1 + continue + } + + let attributeString = String(line.dropFirst("#EXT-X-STREAM-INF:".count)) + let attributes = parseAttributeMap(attributeString) + + var uri: String? + var nextIndex = index + 1 + while nextIndex < lines.count { + let candidate = lines[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.isEmpty { + nextIndex += 1 + continue + } + if candidate.hasPrefix("#") { + nextIndex += 1 + continue + } + uri = candidate + break + } + + if let uri, !uri.isEmpty { + let resolvedURL = URL(string: uri, relativeTo: masterURL)?.absoluteURL.absoluteString ?? uri + let name = attributes["NAME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let bandwidth = attributes["BANDWIDTH"].flatMap(Int.init) + let height: Int? + if let resolution = attributes["RESOLUTION"] { + let parts = resolution.split(separator: "x") + if parts.count == 2 { + height = Int(parts[1]) + } else { + height = nil + } + } else { + height = nil + } + + variants.append(HLSVariant( + url: resolvedURL, + name: name?.isEmpty == true ? nil : name, + height: height, + bandwidth: bandwidth + )) + } + + index = nextIndex + 1 + } + + guard !variants.isEmpty else { return [] } + + var seenURLs = Set() + let deduped = variants.filter { variant in + let inserted = seenURLs.insert(variant.url).inserted + return inserted + } + + let sorted = deduped.sorted { lhs, rhs in + let lhsHeight = lhs.height ?? -1 + let rhsHeight = rhs.height ?? -1 + if lhsHeight != rhsHeight { + return lhsHeight > rhsHeight + } + let lhsBandwidth = lhs.bandwidth ?? -1 + let rhsBandwidth = rhs.bandwidth ?? -1 + if lhsBandwidth != rhsBandwidth { + return lhsBandwidth > rhsBandwidth + } + return lhs.url < rhs.url + } + + let masterURLString = masterURL.absoluteString + var displayNameCount: [String: Int] = [:] + let options = sorted.enumerated().map { offset, variant -> PlaybackQualityOption in + let baseName: String + if let name = variant.name, !name.isEmpty { + baseName = name + } else if let height = variant.height { + baseName = "\(height)p" + } else if let bandwidth = variant.bandwidth, bandwidth > 0 { + baseName = "\(bandwidth / 1000)K" + } else { + baseName = "清晰度\(offset + 1)" + } + + let newCount = (displayNameCount[baseName] ?? 0) + 1 + displayNameCount[baseName] = newCount + let finalName = newCount > 1 ? "\(baseName) \(newCount)" : baseName + + return PlaybackQualityOption(id: variant.url, name: finalName, url: variant.url) + }.filter { !$0.url.isEmpty && $0.url != masterURLString } + + guard options.count >= 2 else { return [] } + + var merged = [PlaybackQualityOption.auto(url: masterURLString)] + merged.append(contentsOf: options) + return merged + } + + private static func parseAttributeMap(_ raw: String) -> [String: String] { + var result: [String: String] = [:] + let pairs = splitAttributes(raw) + for pair in pairs { + let components = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard components.count == 2 else { continue } + let key = String(components[0]).trimmingCharacters(in: .whitespacesAndNewlines) + var value = String(components[1]).trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { + value.removeFirst() + value.removeLast() + } + if !key.isEmpty { + result[key] = value + } + } + return result + } + + private static func splitAttributes(_ raw: String) -> [String] { + var parts: [String] = [] + var buffer = "" + var inQuotes = false + + for char in raw { + if char == "\"" { + inQuotes.toggle() + buffer.append(char) + continue + } + + if char == "," && !inQuotes { + let item = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + if !item.isEmpty { + parts.append(item) + } + buffer.removeAll(keepingCapacity: true) + continue + } + + buffer.append(char) + } + + let tail = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + if !tail.isEmpty { + parts.append(tail) + } + return parts + } +} diff --git a/tvbox/ViewModels/HomeViewModel.swift b/tvbox/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..c277cf9 --- /dev/null +++ b/tvbox/ViewModels/HomeViewModel.swift @@ -0,0 +1,125 @@ +import Foundation +import SwiftUI + +/// 首页 ViewModel +@MainActor +class HomeViewModel: ObservableObject { + @Published var sorts: [MovieSort.SortData] = [] + @Published var selectedSort: MovieSort.SortData? + @Published var homeVideos: [Movie.Video] = [] + @Published var categoryVideos: [Movie.Video] = [] + @Published var isLoading = false + @Published var currentPage = 1 + @Published var hasMore = true + @Published var errorMessage: String? + + private let sourceService = SourceService.shared + + /// 加载分类列表 + func loadSorts() async { + guard let source = ApiConfig.shared.homeSourceBean else { return } + isLoading = true + errorMessage = nil + + do { + let result = try await sourceService.getSort(sourceBean: source) + + // 添加"推荐"到首位 + var allSorts = [MovieSort.SortData.home()] + allSorts.append(contentsOf: result.sorts) + + self.sorts = allSorts + self.homeVideos = result.homeVideos + + if selectedSort == nil { + selectedSort = allSorts.first + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + /// 选择分类 + func selectSort(_ sort: MovieSort.SortData) { + selectedSort = sort + errorMessage = nil + categoryVideos = [] + currentPage = 1 + hasMore = true + + if sort.id == "home" { + return + } else { + Task { + await loadCategoryVideos(page: 1, sort: sort) + } + } + } + + /// 加载分类视频列表 + private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async { + guard sort.id != "home" else { return } + guard let source = ApiConfig.shared.homeSourceBean else { return } + guard !isLoading else { return } + + isLoading = true + defer { isLoading = false } + + do { + let videos = try await sourceService.getList(sourceBean: source, sortData: sort, page: page) + + // 分类切换过程中,丢弃旧请求结果 + guard selectedSort?.id == sort.id else { return } + + if page == 1 { + categoryVideos = videos + } else { + categoryVideos.append(contentsOf: videos) + } + currentPage = page + hasMore = !videos.isEmpty + } catch { + guard selectedSort?.id == sort.id else { return } + errorMessage = error.localizedDescription + } + } + + /// 加载下一页 + func loadMore() async { + guard let lastItem = categoryVideos.last else { return } + await loadMoreIfNeeded(currentItem: lastItem) + } + + /// 当最后一个元素出现时触发加载下一页 + func loadMoreIfNeeded(currentItem: Movie.Video) async { + guard selectedSort?.id != "home" else { return } + guard hasMore, !isLoading else { return } + guard categoryVideos.last?.id == currentItem.id else { return } + guard let sort = selectedSort else { return } + + let nextPage = currentPage + 1 + await loadCategoryVideos(page: nextPage, sort: sort) + } + + /// 刷新 + func refresh() async { + currentPage = 1 + hasMore = true + categoryVideos = [] + errorMessage = nil + await loadSorts() + + guard let sort = selectedSort else { return } + if sort.id == "home" { return } + + if let matchedSort = sorts.first(where: { $0.id == sort.id }) { + selectedSort = matchedSort + await loadCategoryVideos(page: 1, sort: matchedSort) + } else if let firstCategory = sorts.first(where: { $0.id != "home" }) { + selectedSort = firstCategory + await loadCategoryVideos(page: 1, sort: firstCategory) + } + } +} diff --git a/tvbox/ViewModels/LiveViewModel.swift b/tvbox/ViewModels/LiveViewModel.swift new file mode 100644 index 0000000..6a8288f --- /dev/null +++ b/tvbox/ViewModels/LiveViewModel.swift @@ -0,0 +1,91 @@ +import Foundation +import SwiftUI + +/// 直播 ViewModel +@MainActor +class LiveViewModel: ObservableObject { + @Published var channelGroups: [LiveChannelGroup] = [] + @Published var selectedGroupIndex: Int = 0 + @Published var selectedChannelIndex: Int = 0 + @Published var currentChannel: LiveChannelItem? + @Published var epgList: [Epginfo] = [] + @Published var isLoading = false + @Published var showChannelList = false + + /// 加载直播频道 + func loadChannels() { + self.channelGroups = ApiConfig.shared.liveChannelGroupList + if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first { + currentChannel = firstChannel + } + } + + /// 选择频道分组 + func selectGroup(_ index: Int) { + guard index >= 0, index < channelGroups.count else { return } + selectedGroupIndex = index + selectedChannelIndex = 0 + if let first = channelGroups[index].channels.first { + selectChannel(first) + } + } + + /// 选择频道 + func selectChannel(_ channel: LiveChannelItem) { + currentChannel = channel + loadEPG(for: channel) + } + + /// 上一个频道 + func previousChannel() { + guard !channelGroups.isEmpty else { return } + if selectedChannelIndex > 0 { + selectedChannelIndex -= 1 + } else if selectedGroupIndex > 0 { + selectedGroupIndex -= 1 + selectedChannelIndex = channelGroups[selectedGroupIndex].channels.count - 1 + } + if let ch = channelGroups[selectedGroupIndex].channels[safe: selectedChannelIndex] { + selectChannel(ch) + } + } + + /// 下一个频道 + func nextChannel() { + guard !channelGroups.isEmpty else { return } + let group = channelGroups[selectedGroupIndex] + if selectedChannelIndex < group.channels.count - 1 { + selectedChannelIndex += 1 + } else if selectedGroupIndex < channelGroups.count - 1 { + selectedGroupIndex += 1 + selectedChannelIndex = 0 + } + if let ch = channelGroups[selectedGroupIndex].channels[safe: selectedChannelIndex] { + selectChannel(ch) + } + } + + /// 切换线路 + func switchSource() { + currentChannel?.nextSource() + } + + /// 当前频道列表 + var currentChannels: [LiveChannelItem] { + guard selectedGroupIndex < channelGroups.count else { return [] } + return channelGroups[selectedGroupIndex].channels + } + + /// 加载 EPG 节目单 + private func loadEPG(for channel: LiveChannelItem) { + // EPG 加载 - 简化实现 + epgList = [] + } +} + +// 安全数组下标访问 +extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/tvbox/ViewModels/SearchViewModel.swift b/tvbox/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..43e420a --- /dev/null +++ b/tvbox/ViewModels/SearchViewModel.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftUI + +/// 搜索 ViewModel +@MainActor +class SearchViewModel: ObservableObject { + @Published var keyword: String = "" + @Published var results: [Movie.Video] = [] + @Published var isSearching = false + @Published var searchHistory: [String] = [] + @Published var errorMessage: String? + + private let sourceService = SourceService.shared + + init() { + loadSearchHistory() + } + + /// 执行搜索 + func search() async { + let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + isSearching = true + errorMessage = nil + results = [] + + // 保存搜索历史 + addToHistory(trimmed) + + let videos = await sourceService.searchAll(keyword: trimmed) + self.results = videos + + if videos.isEmpty { + errorMessage = "未找到相关内容" + } + + isSearching = false + } + + /// 在指定源搜索 + func searchInSource(_ source: SourceBean) async { + let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + isSearching = true + + do { + let videos = try await sourceService.search(sourceBean: source, keyword: trimmed) + self.results = videos + } catch { + errorMessage = error.localizedDescription + } + + isSearching = false + } + + // MARK: - 搜索历史 + + private func loadSearchHistory() { + searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? [] + } + + private func addToHistory(_ keyword: String) { + searchHistory.removeAll { $0 == keyword } + searchHistory.insert(keyword, at: 0) + if searchHistory.count > 20 { + searchHistory = Array(searchHistory.prefix(20)) + } + UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) + } + + func clearHistory() { + searchHistory = [] + UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY) + } + + func removeFromHistory(_ keyword: String) { + searchHistory.removeAll { $0 == keyword } + UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) + } +} diff --git a/tvbox/ViewModels/SettingsViewModel.swift b/tvbox/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..051331f --- /dev/null +++ b/tvbox/ViewModels/SettingsViewModel.swift @@ -0,0 +1,134 @@ +import Foundation +import SwiftUI + +/// 设置 ViewModel +@MainActor +class SettingsViewModel: ObservableObject { + @Published var apiUrl: String = "" + @Published var isLoadingConfig = false + @Published var configError: String? + @Published var configSuccess = false + @Published var apiHistory: [String] = [] + @Published var playerEngine: PlayerEngine = .system + @Published var decodeMode: VideoDecodeMode = .auto + @Published var vlcBufferMode: VLCBufferMode = .defaultMode + @Published var playTimeStep: Int = 10 + @Published var cacheSizeString: String = "0 KB" + + let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60] + let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines + let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases + let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases + + init() { + apiUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? "" + loadApiHistory() + playerEngine = PlayerEngine.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TYPE) + ) + decodeMode = VideoDecodeMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) + ) + vlcBufferMode = VLCBufferMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) + ) + + let savedStep = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) + playTimeStep = savedStep > 0 ? savedStep : 10 + refreshCacheSize() + } + + /// 加载配置 + func loadConfig() async { + let trimmed = apiUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + configError = "请输入配置地址" + return + } + + isLoadingConfig = true + configError = nil + configSuccess = false + + do { + try await ApiConfig.shared.loadConfig(from: trimmed) + UserDefaults.standard.set(trimmed, forKey: HawkConfig.API_URL) + addToApiHistory(trimmed) + configSuccess = true + } catch { + configError = error.localizedDescription + } + + isLoadingConfig = false + } + + // MARK: - API 历史 + + private func loadApiHistory() { + apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? [] + } + + private func addToApiHistory(_ url: String) { + apiHistory.removeAll { $0 == url } + apiHistory.insert(url, at: 0) + if apiHistory.count > 10 { + apiHistory = Array(apiHistory.prefix(10)) + } + UserDefaults.standard.set(apiHistory, forKey: "api_history") + } + + func removeApiHistory(_ url: String) { + apiHistory.removeAll { $0 == url } + UserDefaults.standard.set(apiHistory, forKey: "api_history") + } + + /// 清除所有缓存 + func clearCache() { + URLCache.shared.removeAllCachedResponses() + ImageLoader.shared.clearCache() + ImageCache.shared.clear() + refreshCacheSize() + } + + /// 设置快进步长 + func setPlayTimeStep(_ step: Int) { + guard step > 0 else { return } + playTimeStep = step + UserDefaults.standard.set(step, forKey: HawkConfig.PLAY_TIME_STEP) + } + + /// 设置播放器内核 + func setPlayerEngine(_ engine: PlayerEngine) { + guard playerEngineOptions.contains(engine) else { return } + playerEngine = engine + UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE) + } + + /// 设置视频解码模式 + func setDecodeMode(_ mode: VideoDecodeMode) { + guard decodeModeOptions.contains(mode) else { return } + decodeMode = mode + UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_DECODE_MODE) + } + + /// 设置 VLC 缓冲策略 + func setVLCBufferMode(_ mode: VLCBufferMode) { + guard vlcBufferModeOptions.contains(mode) else { return } + vlcBufferMode = mode + UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) + } + + private func refreshCacheSize() { + let sharedDisk = URLCache.shared.currentDiskUsage + let imageDisk = ImageLoader.shared.cacheUsage.disk + cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk) + } + + private static func formatSize(bytes: Int) -> String { + let size = max(0, bytes) + if size < 1024 * 1024 { + return String(format: "%.1f KB", Double(size) / 1024.0) + } + return String(format: "%.1f MB", Double(size) / 1024.0 / 1024.0) + } +} diff --git a/tvbox/Views/Common/EmptyStateView.swift b/tvbox/Views/Common/EmptyStateView.swift new file mode 100644 index 0000000..cfae375 --- /dev/null +++ b/tvbox/Views/Common/EmptyStateView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct EmptyStateView: View { + let icon: String + let title: String + var message: String? = nil + + var body: some View { + VStack(spacing: 20) { + ZStack { + Circle() + .fill(Color.orange.opacity(0.15)) + .frame(width: 120, height: 120) + .overlay( + Circle().stroke(Color.orange.opacity(0.3), lineWidth: 1) + ) + + Image(systemName: icon) + .font(.system(size: 46, weight: .light)) + .foregroundStyle( + LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + } + .padding(.bottom, 8) + + Text(title) + .font(.title3.bold()) + .foregroundColor(.white.opacity(0.9)) + .tracking(1) + + if let message = message { + Text(message) + .font(.callout) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + .padding(40) + .glassCard(cornerRadius: 30) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift new file mode 100644 index 0000000..491dd07 --- /dev/null +++ b/tvbox/Views/ContentView.swift @@ -0,0 +1,279 @@ +import SwiftUI + +/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航 +struct ContentView: View { + @EnvironmentObject var appState: AppState + @StateObject private var settingsVM = SettingsViewModel() + @State private var selectedTab = 0 + @State private var showSetup = false + + var body: some View { + Group { + if appState.isConfigLoaded { + mainTabView + } else { + setupView + } + } + .preferredColorScheme(.dark) + .onAppear { + // 自动加载已保存的配置 + let savedUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? "" + if !savedUrl.isEmpty { + Task { + await appState.loadConfig(url: savedUrl) + } + } + } + } + + // MARK: - 主界面 + + private var mainTabView: some View { + #if os(iOS) + TabView(selection: $selectedTab) { + HomeView() + .tabItem { + Label("首页", systemImage: "house.fill") + } + .tag(0) + + LiveView() + .tabItem { + Label("直播", systemImage: "tv.fill") + } + .tag(1) + + SearchView() + .tabItem { + Label("搜索", systemImage: "magnifyingglass") + } + .tag(2) + + FavoritesView() + .tabItem { + Label("收藏", systemImage: "heart.fill") + } + .tag(3) + + SettingsView() + .tabItem { + Label("设置", systemImage: "gearshape.fill") + } + .tag(4) + } + .tint(.orange) + #else + NavigationSplitView(columnVisibility: $appState.splitViewVisibility) { + List(selection: $selectedTab) { + Label("首页", systemImage: "house.fill") + .tag(0) + Label("直播", systemImage: "tv.fill") + .tag(1) + Label("搜索", systemImage: "magnifyingglass") + .tag(2) + Label("收藏", systemImage: "heart.fill") + .tag(3) + Label("历史", systemImage: "clock.fill") + .tag(5) + Label("设置", systemImage: "gearshape.fill") + .tag(4) + } + .navigationTitle("TVBox") + .listStyle(.sidebar) + } detail: { + switch selectedTab { + case 0: HomeView() + case 1: LiveView() + case 2: SearchView() + case 3: FavoritesView() + case 4: SettingsView() + case 5: HistoryView() + default: HomeView() + } + } + #endif + } + + // MARK: - 首次配置页面 + + private var setupView: some View { + ZStack { + // 背景装饰 + AppTheme.primaryGradient + .ignoresSafeArea() + + // 装饰性光晕 + VStack { + HStack { + Circle() + .fill(Color.orange.opacity(0.15)) + .frame(width: 300, height: 300) + .blur(radius: 80) + .offset(x: -100, y: -100) + Spacer() + } + Spacer() + HStack { + Spacer() + Circle() + .fill(Color.red.opacity(0.15)) + .frame(width: 300, height: 300) + .blur(radius: 80) + .offset(x: 100, y: 100) + } + } + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 32) { + // Logo 区域 + VStack(spacing: 20) { + ZStack { + Circle() + .fill(AppTheme.accentGradient) + .frame(width: 100, height: 100) + .blur(radius: 20) + .opacity(0.5) + + Image(systemName: "play.tv.fill") + .font(.system(size: 80)) + .foregroundStyle( + AppTheme.accentGradient + ) + .shadow(color: .red.opacity(0.3), radius: 15, x: 0, y: 10) + } + + VStack(spacing: 8) { + Text("TVBox") + .font(.system(size: 48, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + .tracking(2) + + Text("极致视听 · 简洁至上") + .font(.subheadline) + .foregroundColor(.white.opacity(0.6)) + .tracking(4) + } + } + .padding(.top, 60) + + // 输入表单 + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 12) { + Text("接口配置") + .font(.headline) + .foregroundColor(.white) + .padding(.leading, 4) + + HStack { + Image(systemName: "link") + .foregroundColor(.orange) + TextField("请输入接口地址 (URL)", text: $settingsVM.apiUrl) + .textFieldStyle(.plain) + .foregroundColor(.white) + #if os(iOS) + .autocapitalization(.none) + .keyboardType(.URL) + #endif + + Button { + #if os(iOS) + if let text = UIPasteboard.general.string { + settingsVM.apiUrl = text + } + #else + if let text = NSPasteboard.general.string(forType: .string) { + settingsVM.apiUrl = text + } + #endif + } label: { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.orange) + } + .buttonStyle(.plain) + } + .padding() + .glassCard(cornerRadius: 15) + } + + // 确认按钮 + Button { + Task { + await settingsVM.loadConfig() + if settingsVM.configSuccess { + await appState.loadConfig(url: settingsVM.apiUrl) + } + } + } label: { + HStack { + if settingsVM.isLoadingConfig { + ProgressView() + .tint(.white) + .padding(.trailing, 8) + } + Text(settingsVM.isLoadingConfig ? "正在解析配置..." : "开启影音之旅") + .fontWeight(.bold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(AppTheme.accentGradient) + .foregroundColor(.white) + .clipShape(Capsule()) + .shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6) + } + .disabled(settingsVM.isLoadingConfig || settingsVM.apiUrl.isEmpty) + + // 历史记录 + if !settingsVM.apiHistory.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("最近使用") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + .padding(.horizontal, 4) + + ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in + Button { + settingsVM.apiUrl = url + } label: { + HStack { + Image(systemName: "clock.arrow.2.circlepath") + .font(.caption) + Text(url) + .font(.caption) + .lineLimit(1) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 8)) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .foregroundColor(.white.opacity(0.7)) + .glassCard(cornerRadius: 10) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 30) + + // 错误提示 + if let error = settingsVM.configError { + HStack { + Image(systemName: "exclamationmark.circle.fill") + Text(error) + } + .font(.caption) + .foregroundColor(.red) + .padding() + .glassCard(cornerRadius: 10) + .padding(.horizontal, 30) + } + + Spacer(minLength: 50) + } + } + } + } +} diff --git a/tvbox/Views/DesignSystem.swift b/tvbox/Views/DesignSystem.swift new file mode 100644 index 0000000..a8e9a41 --- /dev/null +++ b/tvbox/Views/DesignSystem.swift @@ -0,0 +1 @@ +// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets. diff --git a/tvbox/Views/Detail/DetailView.swift b/tvbox/Views/Detail/DetailView.swift new file mode 100644 index 0000000..c99d482 --- /dev/null +++ b/tvbox/Views/Detail/DetailView.swift @@ -0,0 +1,588 @@ +import SwiftUI +import SwiftData +#if os(macOS) +import AppKit +#endif + +/// 详情页 - 对应 Android 版 DetailActivity +struct DetailView: View { + let video: Movie.Video + @StateObject private var viewModel = DetailViewModel() + @StateObject private var sharedVLCController = VLCPlayerController() + @EnvironmentObject var appState: AppState + @Environment(\.modelContext) private var modelContext + @State private var showFullScreen = false + @State private var lastPersistedProgress: Double = 0 + @State private var isCollected = false + + var body: some View { + ScrollView { + VStack(spacing: 0) { + // 播放器区域 + if viewModel.isPlaying, let url = viewModel.playUrl, !showFullScreen { + PlayerView( + urlString: url, + startPosition: viewModel.currentPlaybackSeconds(), + onProgressChanged: handlePlaybackProgress, + onPlaybackEnded: playNextEpisodeIfNeeded, + onToggleFullScreen: { + openFullScreenPlayer() + }, + canPlayNext: canPlayNextEpisode, + onPlayNext: playNextEpisodeIfNeeded, + vlcController: sharedVLCController + ) + .id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)") + .aspectRatio(16/9, contentMode: .fit) + .background(Color.black) + .onTapGesture(count: 2) { + openFullScreenPlayer() + } + } + + // 视频信息 + videoInfoSection + .padding(.horizontal, 20) + .padding(.top, 16) + + // 线路选择 + if viewModel.flags.count > 1 { + flagSelector + .padding(.horizontal, 20) + .padding(.top, 16) + } + + // 清晰度选择 + if viewModel.hasQualityChoices { + qualitySelector + .padding(.horizontal, 20) + .padding(.top, 16) + } + + // 剧集列表 + if !viewModel.currentEpisodes.isEmpty { + episodeSection + .padding(.top, 16) + } + + // 简介 + if let info = viewModel.vodInfo, !info.des.isEmpty { + descriptionSection(info.des) + .padding(.horizontal, 20) + .padding(.top, 16) + } + } + .padding(.bottom, 40) + } + .background(AppTheme.primaryGradient) + .navigationTitle(video.name) + #if os(macOS) + .toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar) + #endif + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task(id: "\(video.sourceKey)-\(video.id)") { + await viewModel.loadDetail(video: video) + restorePlaybackFromHistory() + refreshCollectState() + } + .onDisappear { + viewModel.commitPlaybackProgressSnapshot() + persistHistoryIfNeeded(force: true) + showFullScreen = false + sharedVLCController.stop() + #if os(macOS) + appState.exitPlayerFullScreen() + #endif + } + #if os(macOS) + .overlay { + if showFullScreen, let url = viewModel.playUrl { + FullScreenPlayerView( + urlString: url, + startPosition: viewModel.currentPlaybackSeconds(), + onProgressChanged: handlePlaybackProgress, + onPlaybackEnded: playNextEpisodeIfNeeded, + canPlayNext: canPlayNextEpisode, + onPlayNext: playNextEpisodeIfNeeded, + vlcController: sharedVLCController, + onCloseRequested: closeMacFullScreenOverlay + ) + .ignoresSafeArea() + .transition(.opacity) + .zIndex(2) + } + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in + if showFullScreen { + showFullScreen = false + } + appState.exitPlayerFullScreen() + } + #endif + #if os(iOS) + .fullScreenCover(isPresented: $showFullScreen) { + if let url = viewModel.playUrl { + FullScreenPlayerView( + urlString: url, + startPosition: viewModel.currentPlaybackSeconds(), + onProgressChanged: handlePlaybackProgress, + onPlaybackEnded: playNextEpisodeIfNeeded, + canPlayNext: canPlayNextEpisode, + onPlayNext: playNextEpisodeIfNeeded, + vlcController: sharedVLCController + ) + } + } + #endif + } + + // MARK: - 视频信息 + + @ViewBuilder + private var videoInfoSection: some View { + HStack(alignment: .top, spacing: 20) { + videoPoster + + videoDetails + + Spacer() + } + .padding(15) + .glassCard(cornerRadius: AppTheme.glassRadius) + } + + @ViewBuilder + private var videoPoster: some View { + CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in + image.resizable().aspectRatio(2/3, contentMode: .fill) + } placeholder: { + ZStack { + Color.white.opacity(0.05) + Image(systemName: "film.fill").foregroundColor(.white.opacity(0.2)) + } + .aspectRatio(2/3, contentMode: .fill) + } + .frame(width: 130) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius)) + .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5) + } + + @ViewBuilder + private var videoDetails: some View { + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.vodInfo?.name ?? video.name) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + if let info = viewModel.vodInfo { + VStack(alignment: .leading, spacing: 6) { + infoRow("年份", info.year) + infoRow("地区", info.area) + infoRow("类型", info.typeName) + infoRow("导演", info.director) + infoRow("演员", info.actor) + } + } + + Spacer(minLength: 10) + + HStack(spacing: 10) { + playButton + collectButton + } + } + } + + @ViewBuilder + private var playButton: some View { + if !viewModel.isPlaying && viewModel.vodInfo != nil { + Button { + viewModel.selectEpisode(index: 0) + saveHistoryForCurrentEpisode() + } label: { + HStack(spacing: 8) { + Image(systemName: "play.fill") + Text("立即播放") + } + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 28) + .padding(.vertical, 14) + .background(AppTheme.accentGradient) + .clipShape(Capsule()) + .shadow(color: .red.opacity(0.4), radius: 10, x: 0, y: 5) + } + .buttonStyle(.plain) + } + } + + private var collectButton: some View { + Button { + toggleCollect() + } label: { + HStack(spacing: 8) { + Image(systemName: isCollected ? "heart.fill" : "heart") + Text(isCollected ? "已收藏" : "收藏") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + Group { + if isCollected { + AppTheme.accentGradient + } else { + Color.white.opacity(0.08) + } + } + ) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(Color.white.opacity(isCollected ? 0 : 0.2), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func infoRow(_ label: String, _ value: String) -> some View { + if !value.isEmpty { + HStack(alignment: .top, spacing: 4) { + Text(label) + .font(.caption) + .foregroundColor(.gray) + .frame(width: 36, alignment: .leading) + Text(value) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + } + } + } + + // MARK: - 线路选择 + + @ViewBuilder + private var flagSelector: some View { + VStack(alignment: .leading, spacing: 12) { + Text("播放线路") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + + flagScrollView + } + .padding(15) + .glassCard(cornerRadius: AppTheme.glassRadius) + } + + @ViewBuilder + private var flagScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(viewModel.flags, id: \.self) { flag in + flagButton(flag) + } + } + } + } + + @ViewBuilder + private func flagButton(_ flag: String) -> some View { + Button { + withAnimation { + viewModel.selectFlag(flag) + } + if viewModel.isPlaying { + saveHistoryForCurrentEpisode() + } + } label: { + Text(flag) + .font(.system(size: 14, weight: viewModel.selectedFlag == flag ? .bold : .medium)) + .foregroundColor(viewModel.selectedFlag == flag ? .white : .white.opacity(0.6)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + ZStack { + if viewModel.selectedFlag == flag { + AppTheme.accentGradient + } else { + Color.white.opacity(0.05) + } + } + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + // MARK: - 清晰度选择 + + @ViewBuilder + private var qualitySelector: some View { + VStack(alignment: .leading, spacing: 12) { + Text("视频清晰度") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(viewModel.qualityOptions) { option in + qualityButton(option) + } + } + } + } + .padding(15) + .glassCard(cornerRadius: AppTheme.glassRadius) + } + + @ViewBuilder + private func qualityButton(_ option: PlaybackQualityOption) -> some View { + Button { + withAnimation { + viewModel.selectQuality(option) + } + if viewModel.isPlaying { + saveHistoryForCurrentEpisode() + } + } label: { + Text(option.name) + .font(.system(size: 14, weight: viewModel.selectedQualityId == option.id ? .bold : .medium)) + .foregroundColor(viewModel.selectedQualityId == option.id ? .white : .white.opacity(0.6)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + ZStack { + if viewModel.selectedQualityId == option.id { + AppTheme.accentGradient + } else { + Color.white.opacity(0.05) + } + } + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + // MARK: - 剧集列表 + + private var episodeSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("选集播放") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 20) + + EpisodeListView( + episodes: viewModel.currentEpisodes, + selectedIndex: viewModel.selectedEpisodeIndex, + onSelect: { index in + withAnimation { + viewModel.selectEpisode(index: index) + } + saveHistoryForCurrentEpisode() + } + ) + } + } + + // MARK: - 简介 + + private func descriptionSection(_ des: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("影片简介") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + + Text(des) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .lineSpacing(4) + .lineLimit(nil) + } + .padding(15) + .glassCard(cornerRadius: AppTheme.glassRadius) + } + + private var canPlayNextEpisode: Bool { + viewModel.selectedEpisodeIndex + 1 < viewModel.currentEpisodes.count + } + + private func saveHistoryForCurrentEpisode(progressOverride: Double? = nil) { + let episodeName = viewModel.vodInfo?.currentEpisode?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let episodeLabel = episodeName.isEmpty ? "第\(viewModel.selectedEpisodeIndex + 1)集" : episodeName + let progress = max(progressOverride ?? viewModel.currentPlaybackSeconds(), 0) + let timeLabel = progress > 0 ? Int(progress).durationString : "" + let playNote = timeLabel.isEmpty ? episodeLabel : "\(episodeLabel) \(timeLabel)" + + let playbackState = VodPlaybackState( + flag: viewModel.selectedFlag, + episodeIndex: viewModel.selectedEpisodeIndex, + progressSeconds: progress + ) + + Task { @MainActor in + CacheStore.shared.addRecord( + video, + playNote: playNote, + playbackState: playbackState, + context: modelContext + ) + } + } + + private func handlePlaybackProgress(_ seconds: Double, _: Double?) { + viewModel.updatePlaybackProgress(seconds: seconds) + persistHistoryIfNeeded(force: false, currentProgress: seconds) + } + + private func persistHistoryIfNeeded(force: Bool, currentProgress: Double? = nil) { + guard viewModel.isPlaying else { return } + let progress = max(currentProgress ?? viewModel.currentPlaybackSeconds(), 0) + guard progress.isFinite else { return } + + if !force && abs(progress - lastPersistedProgress) < 20 { + return + } + + lastPersistedProgress = progress + saveHistoryForCurrentEpisode(progressOverride: progress) + } + + private func restorePlaybackFromHistory() { + guard let playbackState = CacheStore.shared.getPlaybackState( + vodId: video.id, + sourceKey: video.sourceKey, + context: modelContext + ) else { return } + + viewModel.applyPlaybackState(playbackState) + lastPersistedProgress = max(playbackState.progressSeconds, 0) + } + + private func refreshCollectState() { + isCollected = CacheStore.shared.isCollected( + vodId: video.id, + sourceKey: video.sourceKey, + context: modelContext + ) + } + + private func toggleCollect() { + if isCollected { + CacheStore.shared.removeCollect( + vodId: video.id, + sourceKey: video.sourceKey, + context: modelContext + ) + } else { + CacheStore.shared.addCollect(video, context: modelContext) + } + refreshCollectState() + } + + private func playNextEpisodeIfNeeded() { + var moved = false + withAnimation { + moved = viewModel.playNext() + } + + if moved { + saveHistoryForCurrentEpisode() + } + } + + private func openFullScreenPlayer() { + #if os(iOS) + showFullScreen = true + #else + guard viewModel.playUrl != nil else { return } + showFullScreen = true + appState.enterPlayerFullScreen() + requestMacWindowFullScreen(enter: true) + #endif + } + + #if os(macOS) + private func requestMacWindowFullScreen(enter: Bool) { + DispatchQueue.main.async { + guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return } + let isFullScreen = window.styleMask.contains(.fullScreen) + if enter != isFullScreen { + window.toggleFullScreen(nil) + } + } + } + + private func closeMacFullScreenOverlay() { + let window = NSApp.keyWindow ?? NSApp.mainWindow + if window?.styleMask.contains(.fullScreen) == true { + requestMacWindowFullScreen(enter: false) + return + } + showFullScreen = false + appState.exitPlayerFullScreen() + } + #endif +} + +/// 全屏播放器 +struct FullScreenPlayerView: View { + let urlString: String + var startPosition: Double = 0 + var onProgressChanged: ((Double, Double?) -> Void)? = nil + var onPlaybackEnded: (() -> Void)? = nil + var canPlayNext: Bool = false + var onPlayNext: (() -> Void)? = nil + var vlcController: VLCPlayerController? = nil + var onCloseRequested: (() -> Void)? = nil + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + PlayerView( + urlString: urlString, + startPosition: startPosition, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onToggleFullScreen: { + if let onCloseRequested { + onCloseRequested() + } else { + dismiss() + } + }, + canPlayNext: canPlayNext, + onPlayNext: onPlayNext, + vlcController: vlcController + ) + .ignoresSafeArea() + + VStack { + HStack { + Button { + if let onCloseRequested { + onCloseRequested() + } else { + dismiss() + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.white.opacity(0.8)) + } + Spacer() + } + .padding() + Spacer() + } + } + } +} diff --git a/tvbox/Views/Detail/EpisodeListView.swift b/tvbox/Views/Detail/EpisodeListView.swift new file mode 100644 index 0000000..d1c33ff --- /dev/null +++ b/tvbox/Views/Detail/EpisodeListView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// 剧集列表组件 - 对应 Android 版 SeriesAdapter +struct EpisodeListView: View { + let episodes: [VodInfo.Episode] + let selectedIndex: Int + let onSelect: (Int) -> Void + + @State private var currentGroup = 0 + private let groupSize = 50 + + private var groupCount: Int { + max(1, (episodes.count + groupSize - 1) / groupSize) + } + + private var currentEpisodes: [VodInfo.Episode] { + let start = currentGroup * groupSize + let end = min(start + groupSize, episodes.count) + guard start < episodes.count else { return [] } + return Array(episodes[start.. 1 { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0.. some View { + VStack(alignment: .leading, spacing: 6) { + CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in + image.resizable().aspectRatio(2/3, contentMode: .fill) + } placeholder: { + Rectangle().fill(Color.gray.opacity(0.3)) + .aspectRatio(2/3, contentMode: .fill) + .overlay(Image(systemName: "film").foregroundColor(.gray)) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Text(item.vodName) + .font(.caption) + .foregroundColor(.white) + .lineLimit(2) + } + } + + private func movieVideo(from item: VodCollect) -> Movie.Video { + Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) + } +} diff --git a/tvbox/Views/History/HistoryView.swift b/tvbox/Views/History/HistoryView.swift new file mode 100644 index 0000000..0718c29 --- /dev/null +++ b/tvbox/Views/History/HistoryView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import SwiftData + +/// 历史记录页 - 对应 Android 版 HistoryActivity +struct HistoryView: View { + @Query(sort: \VodRecord.updateTime, order: .reverse) + private var records: [VodRecord] + @Environment(\.modelContext) private var modelContext + + #if os(iOS) + private let columns = [ + GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) + ] + #else + private let columns = [ + GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) + ] + #endif + + var body: some View { + NavigationStack { + Group { + if records.isEmpty { + emptyState + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(records) { item in + NavigationLink(value: movieVideo(from: item)) { + recordCard(item) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + modelContext.delete(item) + try? modelContext.save() + } label: { + Label("删除记录", systemImage: "trash") + } + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + } + .background(Color(red: 0.08, green: 0.08, blue: 0.1)) + .navigationTitle("历史记录") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if !records.isEmpty { + ToolbarItem(placement: .automatic) { + Button { + Task { + CacheStore.shared.clearHistory(context: modelContext) + } + } label: { + Text("清空") + .foregroundColor(.orange) + } + } + } + } + .navigationDestination(for: Movie.Video.self) { video in + DetailView(video: video) + } + } + } + + private var emptyState: some View { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "暂无播放记录", + message: "您还没有看任何视频,赶快去首页探索吧!" + ) + .padding(40) + } + + private func recordCard(_ item: VodRecord) -> some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .bottomLeading) { + CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in + image.resizable().aspectRatio(2/3, contentMode: .fill) + } placeholder: { + Rectangle().fill(Color.gray.opacity(0.3)) + .aspectRatio(2/3, contentMode: .fill) + .overlay(Image(systemName: "film").foregroundColor(.gray)) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // 播放进度标签 + if !item.playNote.isEmpty { + Text(item.playNote) + .font(.system(size: 9)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.black.opacity(0.7)) + .cornerRadius(4) + .padding(4) + } + } + + Text(item.vodName) + .font(.caption) + .foregroundColor(.white) + .lineLimit(2) + + Text(item.updateTime.displayString) + .font(.system(size: 10)) + .foregroundColor(.gray) + } + } + + private func movieVideo(from item: VodRecord) -> Movie.Video { + Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) + } +} diff --git a/tvbox/Views/Home/HomeView.swift b/tvbox/Views/Home/HomeView.swift new file mode 100644 index 0000000..9f4d968 --- /dev/null +++ b/tvbox/Views/Home/HomeView.swift @@ -0,0 +1,349 @@ +import SwiftUI + +/// 首页 - 对应 Android 版 HomeActivity + UserFragment +struct HomeView: View { + @StateObject private var viewModel = HomeViewModel() + @EnvironmentObject var appState: AppState + @State private var categoryScrollAnchorId: String? + @State private var categoryDragTranslation: CGFloat = 0 + + // 网格布局 + #if os(iOS) + private let columns = [ + GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) + ] + #else + private let columns = [ + GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) + ] + #endif + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // 顶部栏 + headerBar + + // 分类标签栏 + if !viewModel.sorts.isEmpty { + categoryTabBar + } + + // 内容区 + contentArea + } + .background(AppTheme.primaryGradient) + } + .task { + await viewModel.loadSorts() + if let first = viewModel.sorts.first { + viewModel.selectSort(first) + } + } + } + + // MARK: - 顶部栏 + + private var headerBar: some View { + HStack(spacing: 15) { + // 应用名(可切换源) + Menu { + ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in + Button { + ApiConfig.shared.setHomeSource(source) + Task { await viewModel.refresh() } + } label: { + HStack { + Text(source.name) + if source.key == ApiConfig.shared.homeSourceBean?.key { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.orange) + Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white.opacity(0.5)) + .padding(.leading, 2) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.white.opacity(0.1)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() + + // 日期时间 + HomeClockView() + } + .padding(.horizontal, 20) + .padding(.top, 15) + .padding(.bottom, 10) + } + + // MARK: - 分类标签栏 + + private var categoryTabBar: some View { + ScrollViewReader { proxy in + HStack(spacing: 8) { + categoryMoveButton( + systemName: "chevron.left", + enabled: canMoveCategory(by: -1) + ) { + moveCategoryTabs(by: -3, proxy: proxy) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.sorts) { sort in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectSort(sort) + } + categoryScrollAnchorId = sort.id + scrollCategoryBar(to: sort.id, proxy: proxy) + } label: { + Text(sort.name) + .font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .medium)) + .foregroundColor(viewModel.selectedSort?.id == sort.id ? .orange : .white.opacity(0.8)) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + ZStack { + if viewModel.selectedSort?.id == sort.id { + RoundedRectangle(cornerRadius: 12) + .fill(Color.orange.opacity(0.15)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.orange.opacity(0.5), lineWidth: 1) + ) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.05)) + } + } + ) + } + .buttonStyle(.plain) + .id(sort.id) + } + } + .padding(.horizontal, 8) + } + .simultaneousGesture(categoryDragGesture(proxy: proxy)) + .onAppear { + syncCategoryScrollAnchorIfNeeded() + scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false) + } + .onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in + syncCategoryScrollAnchorIfNeeded() + scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false) + } + .onChange(of: viewModel.selectedSort?.id) { oldId, newId in + guard let newId else { return } + categoryScrollAnchorId = newId + scrollCategoryBar(to: newId, proxy: proxy) + } + + categoryMoveButton( + systemName: "chevron.right", + enabled: canMoveCategory(by: 1) + ) { + moveCategoryTabs(by: 3, proxy: proxy) + } + } + .padding(.horizontal, 14) + } + .padding(.vertical, 8) + } + + private func categoryMoveButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(enabled ? 0.9 : 0.35)) + .frame(width: 26, height: 26) + .background(Color.white.opacity(0.08)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + } + + private func canMoveCategory(by direction: Int) -> Bool { + guard !viewModel.sorts.isEmpty else { return false } + let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0 + if direction < 0 { + return currentIndex > 0 + } + return currentIndex < viewModel.sorts.count - 1 + } + + private func categoryIndex(for id: String?) -> Int? { + guard let id else { return nil } + return viewModel.sorts.firstIndex(where: { $0.id == id }) + } + + private func syncCategoryScrollAnchorIfNeeded() { + guard !viewModel.sorts.isEmpty else { + categoryScrollAnchorId = nil + return + } + + if let selectedId = viewModel.selectedSort?.id, + viewModel.sorts.contains(where: { $0.id == selectedId }) { + categoryScrollAnchorId = selectedId + return + } + + if let anchorId = categoryScrollAnchorId, + viewModel.sorts.contains(where: { $0.id == anchorId }) { + return + } + + categoryScrollAnchorId = viewModel.sorts.first?.id + } + + private func moveCategoryTabs(by delta: Int, proxy: ScrollViewProxy) { + guard !viewModel.sorts.isEmpty else { return } + let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0 + let newIndex = min(max(0, currentIndex + delta), viewModel.sorts.count - 1) + guard newIndex != currentIndex else { return } + + let targetId = viewModel.sorts[newIndex].id + categoryScrollAnchorId = targetId + scrollCategoryBar(to: targetId, proxy: proxy) + } + + private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) { + guard let id else { return } + + if animated { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id, anchor: .center) + } + } else { + proxy.scrollTo(id, anchor: .center) + } + } + + private func categoryDragGesture(proxy: ScrollViewProxy) -> some Gesture { + DragGesture(minimumDistance: 12) + .onChanged { value in + let delta = value.translation.width - categoryDragTranslation + if delta <= -28 { + moveCategoryTabs(by: 1, proxy: proxy) + categoryDragTranslation = value.translation.width + } else if delta >= 28 { + moveCategoryTabs(by: -1, proxy: proxy) + categoryDragTranslation = value.translation.width + } + } + .onEnded { _ in + categoryDragTranslation = 0 + } + } + + // MARK: - 内容区 + + private var contentArea: some View { + Group { + if viewModel.isLoading && viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty { + VStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + .tint(.orange) + Text("加载中...") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 12) + Spacer() + } + } else if let error = viewModel.errorMessage, viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty { + VStack(spacing: 12) { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + Text(error) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + // 如果是不支持的源类型,显示类型信息 + if let source = ApiConfig.shared.homeSourceBean, !source.isSupportedInSwift { + Text("当前源类型: \(source.typeDescription)") + .font(.caption) + .foregroundColor(.secondary) + } + + Button("重试") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + .tint(.orange) + Spacer() + } + } else { + let videos = viewModel.selectedSort?.id == "home" + ? viewModel.homeVideos + : viewModel.categoryVideos + + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(videos) { video in + NavigationLink(value: video) { + VodCardView(video: video) + } + .buttonStyle(.plain) + .onAppear { + Task { await viewModel.loadMoreIfNeeded(currentItem: video) } + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 加载更多 + if viewModel.selectedSort?.id != "home" && viewModel.hasMore { + ProgressView() + .padding() + } + } + .refreshable { + await viewModel.refresh() + } + } + } + .navigationDestination(for: Movie.Video.self) { video in + DetailView(video: video) + } + } +} + +private struct HomeClockView: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + Text(timeline.date.homeDateString) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .glassCard(cornerRadius: 12) + } + } +} diff --git a/tvbox/Views/Home/VodCardView.swift b/tvbox/Views/Home/VodCardView.swift new file mode 100644 index 0000000..1a0d3fc --- /dev/null +++ b/tvbox/Views/Home/VodCardView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// 视频卡片组件 +struct VodCardView: View { + let video: Movie.Video + @State private var isHovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // 封面图 + ZStack(alignment: .bottomLeading) { + CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in + image + .resizable() + .aspectRatio(2/3, contentMode: .fill) + } placeholder: { + placeholderImage + .overlay(ProgressView().tint(.white)) + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius)) + .shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 4) + + // 底部渐变叠加(用于保护备注文字) + if !video.note.isEmpty { + LinearGradient( + colors: [.black.opacity(0.8), .clear], + startPoint: .bottom, + endPoint: .center + ) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius)) + } + + // 备注标签 + if !video.note.isEmpty { + Text(video.note) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule().fill(Color.orange.opacity(0.9)) + ) + .padding(8) + } + } + .scaleEffect(isHovered ? 1.05 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered) + .onHover { hovering in + isHovered = hovering + } + + // 标题 + VStack(alignment: .leading, spacing: 2) { + Text(video.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + + if !video.type.isEmpty { + Text(video.type) + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.5)) + } + } + .padding(.horizontal, 4) + } + } + + private var placeholderImage: some View { + RoundedRectangle(cornerRadius: AppTheme.cardRadius) + .fill(Color.white.opacity(0.05)) + .aspectRatio(2/3, contentMode: .fill) + .overlay( + Image(systemName: "film.fill") + .font(.system(size: 30)) + .foregroundColor(.white.opacity(0.2)) + ) + } +} diff --git a/tvbox/Views/Live/LiveView.swift b/tvbox/Views/Live/LiveView.swift new file mode 100644 index 0000000..2c96f8f --- /dev/null +++ b/tvbox/Views/Live/LiveView.swift @@ -0,0 +1,541 @@ +import SwiftUI +import AVKit +#if os(macOS) +import AppKit +#endif + +/// 直播页 - 对应 Android 版 LivePlayActivity +struct LiveView: View { + @StateObject private var viewModel = LiveViewModel() + @EnvironmentObject var appState: AppState + @State private var avPlayer: AVPlayer? + @AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue + @State private var showChannelDrawer = true + @State private var isWindowFullScreen = false + private let currentChannelInfoMaxWidth: CGFloat = 600 + @State private var itemStatusObserver: NSKeyValueObservation? + @State private var playbackFailedObserver: NSObjectProtocol? + @State private var playbackStalledObserver: NSObjectProtocol? + @State private var failedSourceIndices: Set = [] + @State private var trackedChannelId: String = "" + @State private var showCurrentChannelInfo = true + @State private var channelInfoTimer: Timer? + private let channelInfoAutoHideDelay: TimeInterval = 3.0 + @State private var vlcInteractionToken = 0 + + private var selectedEngine: PlayerEngine { + PlayerEngine.fromStoredValue(playTypeRaw) + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + + if viewModel.channelGroups.isEmpty { + emptyState + } else { + // 播放器 + if selectedEngine == .vlc { + if let urlString = viewModel.currentChannel?.currentUrl, !urlString.isEmpty { + VLCLivePlayerView( + urlString: urlString, + activityToken: vlcInteractionToken, + onPlaybackFailed: { + handlePlaybackFailure(trigger: "vlc_error") + }, + onToggleFullScreen: { + toggleWindowFullScreen() + } + ) + .ignoresSafeArea() + .id("vlc-live-\(urlString)-\(viewModel.currentChannel?.id ?? "")") + } + } else if let player = avPlayer { + PlatformVideoPlayer(player: player) + .ignoresSafeArea() + } + + // 覆盖 UI + overlayUI + } + } + .navigationTitle("直播") + #if os(macOS) + .toolbar(isWindowFullScreen ? .hidden : .visible, for: .windowToolbar) + #endif + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .onAppear { + viewModel.loadChannels() + wakeUpCurrentChannelInfo() + } + .onChange(of: viewModel.currentChannel?.currentUrl) { _, newValue in + if selectedEngine == .system { + playChannel(url: newValue) + } else { + cleanupPlayer() + } + wakeUpCurrentChannelInfo() + } + .onChange(of: viewModel.currentChannel?.id) { _, _ in + resetFailureTracking(for: viewModel.currentChannel) + wakeUpCurrentChannelInfo() + } + .onChange(of: playTypeRaw) { _, _ in + if selectedEngine == .system { + playChannel(url: viewModel.currentChannel?.currentUrl) + } else { + cleanupPlayer() + } + } + .onDisappear { + cleanupPlayer() + cancelChannelInfoAutoHide() + #if os(macOS) + appState.exitPlayerFullScreen() + isWindowFullScreen = false + #endif + } + #if os(macOS) + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in + isWindowFullScreen = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in + isWindowFullScreen = false + appState.exitPlayerFullScreen() + } + .onExitCommand { + if showChannelDrawer { + withAnimation(.easeInOut(duration: 0.2)) { + showChannelDrawer = false + } + } + } + #endif + } + } + + // MARK: - 空状态 + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "tv.slash") + .font(.system(size: 48)) + .foregroundColor(.gray) + Text("暂无直播源") + .font(.headline) + .foregroundColor(.gray) + Text("请在设置中配置包含直播源的接口") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // MARK: - 覆盖 UI + + private var overlayUI: some View { + ZStack(alignment: .leading) { + if showChannelDrawer { + Color.black.opacity(0.22) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showChannelDrawer = false + } + } + } + + VStack(spacing: 0) { + HStack { + channelDrawerToggleButton + Spacer() + } + .padding(.top, 18) + .padding(.horizontal, 16) + + Spacer() + + // 底部当前频道信息 + if let channel = viewModel.currentChannel, showCurrentChannelInfo { + currentChannelInfo(channel) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + + if showChannelDrawer { + channelDrawer + .padding(.leading, 12) + .padding(.vertical, 20) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo) + .simultaneousGesture( + TapGesture().onEnded { + reportUserActivity() + } + ) + #if os(macOS) + .onContinuousHover { phase in + switch phase { + case .active(_): + reportUserActivity() + case .ended: + break + } + } + #endif + } + + private var channelDrawer: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Label("频道菜单", systemImage: "list.bullet.rectangle.portrait") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + + Spacer() + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showChannelDrawer = false + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.8)) + .frame(width: 22, height: 22) + .background(Color.white.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.white.opacity(0.08)) + + HStack(spacing: 0) { + channelGroupList + .frame(width: 150) + + Divider() + .overlay(Color.white.opacity(0.1)) + + channelList + .frame(width: 240) + } + } + .frame(width: 390) + .frame(maxHeight: .infinity, alignment: .top) + .glassCard(cornerRadius: 14) + } + + private var channelDrawerToggleButton: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showChannelDrawer.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right") + Text(showChannelDrawer ? "收起菜单" : "频道菜单") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundColor(.white.opacity(0.9)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.35)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.15), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + #if os(macOS) + .keyboardShortcut("m", modifiers: [.command]) + #endif + } + + private func currentChannelInfo(_ channel: LiveChannelItem) -> some View { + HStack(spacing: 15) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Circle().fill(Color.orange).frame(width: 8, height: 8) + Text(channel.channelName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + } + + if channel.sourceNum > 1 { + Text("正在播放:线路 \(channel.sourceIndex + 1) / \(channel.sourceNum)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + } + + Spacer() + + HStack(spacing: 12) { + // 切换线路按钮 + if channel.sourceNum > 1 { + Button { + wakeUpCurrentChannelInfo() + resetFailureTracking(for: viewModel.currentChannel) + viewModel.switchSource() + } label: { + HStack(spacing: 6) { + Image(systemName: "shuffle") + Text("切换线路") + } + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(AppTheme.accentGradient) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + #if os(macOS) + Button { + wakeUpCurrentChannelInfo() + toggleWindowFullScreen() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + Text("全屏") + } + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.white.opacity(0.15)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + #endif + } + } + .padding(20) + .glassCard(cornerRadius: AppTheme.glassRadius) + .frame(maxWidth: currentChannelInfoMaxWidth) + .frame(maxWidth: .infinity) + .padding(20) + } + + private func wakeUpCurrentChannelInfo() { + withAnimation(.easeInOut(duration: 0.2)) { + showCurrentChannelInfo = true + } + channelInfoTimer?.invalidate() + guard viewModel.currentChannel != nil else { return } + + channelInfoTimer = Timer.scheduledTimer(withTimeInterval: channelInfoAutoHideDelay, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.3)) { + showCurrentChannelInfo = false + } + } + } + + private func cancelChannelInfoAutoHide() { + channelInfoTimer?.invalidate() + channelInfoTimer = nil + } + + private func reportUserActivity() { + wakeUpCurrentChannelInfo() + vlcInteractionToken &+= 1 + } + + // MARK: - 频道分组 + + private var channelGroupList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(viewModel.channelGroups.enumerated()), id: \.offset) { index, group in + Button { + withAnimation { + viewModel.selectGroup(index) + } + } label: { + Text(group.groupName) + .font(.system(size: 14, weight: viewModel.selectedGroupIndex == index ? .bold : .medium)) + .foregroundColor(viewModel.selectedGroupIndex == index ? .orange : .white.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + viewModel.selectedGroupIndex == index + ? Color.white.opacity(0.1) + : Color.clear + ) + } + .buttonStyle(.plain) + } + } + } + } + + // MARK: - 频道列表 + + private var channelList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(viewModel.currentChannels.enumerated()), id: \.offset) { index, channel in + Button { + withAnimation { + viewModel.selectedChannelIndex = index + viewModel.selectChannel(channel) + } + } label: { + HStack { + Text(channel.channelName) + .font(.system(size: 14, weight: viewModel.currentChannel?.channelName == channel.channelName ? .bold : .medium)) + .foregroundColor(viewModel.currentChannel?.channelName == channel.channelName ? .orange : .white.opacity(0.8)) + Spacer() + if channel.sourceNum > 1 { + Text("\(channel.sourceNum)") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.3)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().stroke(Color.white.opacity(0.2), lineWidth: 0.5)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + viewModel.currentChannel?.channelName == channel.channelName + ? Color.orange.opacity(0.15) + : Color.clear + ) + } + .buttonStyle(.plain) + } + } + } + } + + // MARK: - 播放 + + private func playChannel(url: String?) { + guard let urlStr = url, let url = URL(string: urlStr) else { + handlePlaybackFailure(trigger: "invalid_url") + return + } + + cleanupPlayer() + + let playerItem = AVPlayerItem(url: url) + // 直播场景优先实时性,避免过多缓冲导致内存上涨 + playerItem.preferredForwardBufferDuration = 3 + playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false + observePlaybackFailure(for: playerItem) + + let newPlayer = AVPlayer(playerItem: playerItem) + newPlayer.play() + avPlayer = newPlayer + } + + private func cleanupPlayer() { + if let observer = playbackFailedObserver { + NotificationCenter.default.removeObserver(observer) + playbackFailedObserver = nil + } + if let observer = playbackStalledObserver { + NotificationCenter.default.removeObserver(observer) + playbackStalledObserver = nil + } + itemStatusObserver = nil + + avPlayer?.pause() + avPlayer?.replaceCurrentItem(with: nil) + avPlayer = nil + } + + private func observePlaybackFailure(for item: AVPlayerItem) { + itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in + if observedItem.status == .failed { + DispatchQueue.main.async { + handlePlaybackFailure(trigger: "status_failed") + } + } + } + + playbackFailedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { _ in + handlePlaybackFailure(trigger: "item_failed") + } + + playbackStalledObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemPlaybackStalled, + object: item, + queue: .main + ) { _ in + handlePlaybackFailure(trigger: "playback_stalled") + } + } + + private func resetFailureTracking(for channel: LiveChannelItem?) { + failedSourceIndices = [] + trackedChannelId = channel?.id ?? "" + } + + private func handlePlaybackFailure(trigger: String) { + guard let channel = viewModel.currentChannel else { return } + guard channel.sourceNum > 1 else { return } + + if trackedChannelId != channel.id { + resetFailureTracking(for: channel) + } + + let failedIndex = channel.sourceIndex + guard !failedSourceIndices.contains(failedIndex) else { return } + failedSourceIndices.insert(failedIndex) + + guard switchToNextAvailableSource(totalSources: channel.sourceNum) else { + print("直播线路全部尝试失败: channel=\(channel.channelName), trigger=\(trigger)") + return + } + } + + private func switchToNextAvailableSource(totalSources: Int) -> Bool { + guard failedSourceIndices.count < totalSources else { return false } + + for _ in 0.. AVPlayerView { + let view = AVPlayerView() + view.controlsStyle = .none // 禁用系统默认控制栏 + view.showsFullScreenToggleButton = false + view.videoGravity = .resizeAspect + view.player = player + return view + } + + func updateNSView(_ nsView: AVPlayerView, context: Context) { + if nsView.player !== player { + nsView.player = player + } + } + + static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) { + nsView.player?.pause() + nsView.player = nil + } +} +#endif + +/// 视频播放器组件 - 对应 Android 版 PlayFragment +struct PlayerView: View { + let urlString: String + var startPosition: Double = 0 + var onProgressChanged: ((Double, Double?) -> Void)? = nil + var onPlaybackEnded: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + var canPlayNext: Bool = false + var onPlayNext: (() -> Void)? = nil + var vlcController: VLCPlayerController? = nil + @AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue + + private var selectedEngine: PlayerEngine { + PlayerEngine.fromStoredValue(playTypeRaw) + } + + var body: some View { + Group { + switch selectedEngine { + case .system: + AVPlayerContentView( + urlString: urlString, + startPosition: startPosition, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onToggleFullScreen: onToggleFullScreen, + canPlayNext: canPlayNext, + onPlayNext: onPlayNext + ) + case .vlc: + VLCVodPlayerView( + urlString: urlString, + startPosition: startPosition, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onToggleFullScreen: onToggleFullScreen, + canPlayNext: canPlayNext, + onPlayNext: onPlayNext, + sharedController: vlcController + ) + } + } + .id(selectedEngine.rawValue) + } +} + +/// 基于系统 AVPlayer 的点播播放器实现 +struct AVPlayerContentView: View { + let urlString: String + var startPosition: Double = 0 + var onProgressChanged: ((Double, Double?) -> Void)? = nil + var onPlaybackEnded: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + var canPlayNext: Bool = false + var onPlayNext: (() -> Void)? = nil + @State private var player: AVPlayer? + @State private var playbackEndObserver: NSObjectProtocol? + @State private var timeObserverToken: Any? + + // UI 状态 + @State private var isPlaying = false + @State private var currentTime: Double = 0 + @State private var duration: Double = 0 + @State private var volume: Double = 1.0 + @State private var rate: Float = 1.0 + @State private var isPreparing = true + @State private var showControls = true + @State private var controlsTimer: Timer? + @State private var osdIcon: String? + @State private var osdOpacity: Double = 0 + @State private var osdTimer: Timer? + @State private var isDraggingProgress = false + @State private var draggingSeconds: Double = 0 + @State private var playerObservers: [NSKeyValueObservation] = [] + + var body: some View { + ZStack { + Group { + if let player = player { + PlatformVideoPlayer(player: player) + } else { + ZStack { + Color.black + ProgressView() + .tint(.white) + } + } + } + .onTapGesture(count: 2) { + onToggleFullScreen?() + } + .onTapGesture(count: 1) { + togglePlayPauseWithOSD() + } + + if isPreparing { + ProgressView() + .tint(.white) + } + + if let osdIcon = osdIcon { + Image(systemName: osdIcon) + .font(.system(size: 60, weight: .semibold)) + .foregroundColor(.white) + .padding(30) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .opacity(osdOpacity) + .allowsHitTesting(false) + } + } + .overlay(alignment: .bottom) { + GeometryReader { proxy in + if player != nil { + playbackControls(containerWidth: proxy.size.width) + .opacity(showControls ? 1.0 : 0.0) + .animation(.easeInOut(duration: 0.3), value: showControls) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + } + .overlay { + SystemPlayerKeyboardCaptureView( + onLeft: { seek(by: -seekStep) }, + onRight: { seek(by: seekStep) }, + onTogglePlayPause: { togglePlayPause() }, + onToggleFullScreen: { onToggleFullScreen?() }, + onVolumeDown: { wakeUpControls(); adjustVolume(by: -volumeStep) }, + onVolumeUp: { wakeUpControls(); adjustVolume(by: volumeStep) } + ) + .frame(width: 1, height: 1) + .opacity(0.01) + .allowsHitTesting(false) + } + .onContinuousHover { phase in + switch phase { + case .active(_): wakeUpControls() + case .ended: break + } + } + .onAppear { + setupPlayer() + wakeUpControls() + } + .onChange(of: urlString) { _, _ in + setupPlayer() + wakeUpControls() + } + .onDisappear { + cleanupPlayer() + controlsTimer?.invalidate() + osdTimer?.invalidate() + } + } + + private func setupPlayer() { + guard let url = URL(string: urlString) else { return } + + // 清理旧播放器 + cleanupPlayer() + + let playerItem = AVPlayerItem(url: url) + let newPlayer = AVPlayer(playerItem: playerItem) + + // 设置监听 + playerObservers = [ + newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in + let status = p.timeControlStatus + DispatchQueue.main.async { isPlaying = status == .playing } + }, + newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in + let reason = p.reasonForWaitingToPlay + DispatchQueue.main.async { isPreparing = reason != nil } + }, + newPlayer.observe(\.volume, options: [.new]) { p, _ in + let vol = Double(p.volume) + DispatchQueue.main.async { volume = vol } + }, + newPlayer.observe(\.rate, options: [.new]) { p, _ in + let r = p.rate + DispatchQueue.main.async { rate = r } + } + ] + + observePlaybackProgress(for: newPlayer) + observePlaybackEnd(for: newPlayer) + player = newPlayer + startPlayback(for: newPlayer) + } + + private func cleanupPlayer() { + if let token = timeObserverToken { + player?.removeTimeObserver(token) + timeObserverToken = nil + } + if let observer = playbackEndObserver { + NotificationCenter.default.removeObserver(observer) + playbackEndObserver = nil + } + playerObservers.forEach { $0.invalidate() } + playerObservers.removeAll() + player?.pause() + player?.replaceCurrentItem(with: nil) + player = nil + } + + private func startPlayback(for player: AVPlayer) { + let target = max(startPosition, 0) + + if target > 0 { + let seekTime = CMTime(seconds: target, preferredTimescale: 600) + player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in + reportProgress(for: player) + player.play() + } + } else { + player.play() + } + } + + private func togglePlayPause() { + guard let player = player else { return } + if player.rate == 0 { + player.play() + } else { + player.pause() + } + } + + private func togglePlayPauseWithOSD() { + togglePlayPause() + showOSD(icon: isPlaying ? "pause.fill" : "play.fill") + } + + private func wakeUpControls() { + withAnimation { showControls = true } + controlsTimer?.invalidate() + controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.5)) { + showControls = false + } + } + } + + private func showOSD(icon: String) { + osdIcon = icon + osdOpacity = 1.0 + osdTimer?.invalidate() + osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.5)) { + osdOpacity = 0.0 + } + } + } + + private func observePlaybackEnd(for player: AVPlayer) { + guard let item = player.currentItem else { return } + + playbackEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { _ in + onPlaybackEnded?() + } + } + + private func observePlaybackProgress(for player: AVPlayer) { + let interval = CMTime(seconds: 1, preferredTimescale: 2) + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in + reportProgress(for: player) + } + } + + private func reportProgress(for player: AVPlayer?) { + guard let player else { return } + let current = player.currentTime().seconds + guard current.isFinite, current >= 0 else { return } + + if !isDraggingProgress { + self.currentTime = current + self.draggingSeconds = current + } + + let rawDuration = player.currentItem?.duration.seconds + if let rawDuration, rawDuration.isFinite, rawDuration >= 0 { + self.duration = rawDuration + } + onProgressChanged?(current, duration > 0 ? duration : nil) + } + + private var seekStep: Double { + let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) + return Double(saved > 0 ? saved : 10) + } + + private var volumeStep: Double { 0.1 } + + private var progressUpperBound: Double { + max(duration, max(currentTime, 1)) + } + + private func playbackControls(containerWidth: CGFloat) -> some View { + VStack(spacing: 8) { + // 第一行:进度条和时间 + HStack(spacing: 12) { + Text(currentTime.durationString) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.9)) + .frame(width: 45, alignment: .leading) + + Slider( + value: Binding( + get: { isDraggingProgress ? draggingSeconds : currentTime }, + set: { + draggingSeconds = $0 + wakeUpControls() + } + ), + in: 0...progressUpperBound, + onEditingChanged: { editing in + isDraggingProgress = editing + wakeUpControls() + if !editing { + seek(to: draggingSeconds) + } + } + ) + .accentColor(.white) + .disabled(duration <= 0) + + Text(duration.durationString) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + .frame(width: 45, alignment: .trailing) + } + .padding(.horizontal, 4) + + // 第二行:控制按钮 + HStack(spacing: 0) { + // 左侧区:倍速 + HStack(spacing: 16) { + playbackRateMenu + } + .frame(width: 150, alignment: .leading) + + Spacer() + + // 中间区:主控 (这里集成了您原本右下角的快进快退和下一集按钮) + HStack(spacing: 24) { + Button { + wakeUpControls() + seek(by: -seekStep) + showOSD(icon: "gobackward.\(Int(seekStep))") + } label: { + Image(systemName: "gobackward.\(Int(seekStep))") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + + Button { + wakeUpControls() + togglePlayPauseWithOSD() + } label: { + ZStack { + Circle() + .fill(Color.white.opacity(0.15)) + .frame(width: 38, height: 38) + + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 18, weight: .bold)) + } + } + .buttonStyle(.plain) + + Button { + wakeUpControls() + seek(by: seekStep) + showOSD(icon: "goforward.\(Int(seekStep))") + } label: { + Image(systemName: "goforward.\(Int(seekStep))") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + + if let onPlayNext { + Button { + guard canPlayNext else { return } + wakeUpControls() + onPlayNext() + showOSD(icon: "forward.end.fill") + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + .disabled(!canPlayNext) + .opacity(canPlayNext ? 1 : 0.4) + } + } + + Spacer() + + // 右侧区:音量和全屏 + HStack(spacing: 14) { + HStack(spacing: 6) { + Button { + wakeUpControls() + let newVolume = volume > 0 ? 0.0 : 1.0 + player?.volume = Float(newVolume) + showOSD(icon: newVolume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + } label: { + Image(systemName: volumeIconName) + .font(.system(size: 14, weight: .bold)) + .frame(width: 20) + } + .buttonStyle(.plain) + + Slider( + value: Binding( + get: { volume }, + set: { + player?.volume = Float($0) + wakeUpControls() + } + ), + in: 0...1.0 + ) + .accentColor(.white.opacity(0.8)) + .frame(width: 80) + } + + if let onToggleFullScreen { + Button { + wakeUpControls() + onToggleFullScreen() + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 15, weight: .bold)) + } + .buttonStyle(.plain) + } + } + .frame(width: 150, alignment: .trailing) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 10) + .foregroundColor(.white) + .glassCard(cornerRadius: 18) + .padding(.horizontal, 20) + .padding(.bottom, 6) + .frame(width: containerWidth * 0.7) + .environment(\.colorScheme, .dark) + } + + private var playbackRateMenu: some View { + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { r in + Button { + wakeUpControls() + player?.rate = Float(r) + showOSD(icon: "speedometer") + } label: { + HStack { + Text("\(String(format: "%.1f", r))x") + if Float(r) == rate { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text("\(String(format: "%.1f", rate))x") + Image(systemName: "chevron.up") + .font(.system(size: 8, weight: .bold)) + } + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var volumeIconName: String { + if volume <= 0 { return "speaker.slash.fill" } + if volume < 0.5 { return "speaker.wave.1.fill" } + return "speaker.wave.2.fill" + } + + private func seek(to seconds: Double) { + guard let player = player else { return } + let time = CMTime(seconds: seconds, preferredTimescale: 600) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + } + + private func seek(by offset: Double) { + guard let player else { return } + + let current = player.currentTime().seconds + guard current.isFinite else { return } + let wasPlaying = player.rate != 0 || player.timeControlStatus == .waitingToPlayAtSpecifiedRate + + var target = max(current + offset, 0) + if let duration = player.currentItem?.duration.seconds, duration.isFinite { + target = min(target, duration) + } + + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in + reportProgress(for: player) + if wasPlaying { + player.play() + } + } + } + + private func adjustVolume(by delta: Double) { + guard let player else { return } + let current = Double(player.volume) + let target = min(max(current + delta, 0), 1) + player.volume = Float(target) + showOSD(icon: target <= 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + } +} + +#if os(macOS) +private struct SystemPlayerKeyboardCaptureView: NSViewRepresentable { + let onLeft: () -> Void + let onRight: () -> Void + let onTogglePlayPause: () -> Void + let onToggleFullScreen: () -> Void + let onVolumeDown: () -> Void + let onVolumeUp: () -> Void + + func makeNSView(context: Context) -> SystemPlayerKeyCaptureNSView { + let view = SystemPlayerKeyCaptureNSView(frame: .zero) + applyCallbacks(to: view) + DispatchQueue.main.async { + view.activate() + } + return view + } + + func updateNSView(_ nsView: SystemPlayerKeyCaptureNSView, context: Context) { + applyCallbacks(to: nsView) + DispatchQueue.main.async { + nsView.activate() + } + } + + private func applyCallbacks(to view: SystemPlayerKeyCaptureNSView) { + view.onLeft = onLeft + view.onRight = onRight + view.onTogglePlayPause = onTogglePlayPause + view.onToggleFullScreen = onToggleFullScreen + view.onVolumeDown = onVolumeDown + view.onVolumeUp = onVolumeUp + } +} + +private final class SystemPlayerKeyCaptureNSView: NSView { + var onLeft: (() -> Void)? + var onRight: (() -> Void)? + var onTogglePlayPause: (() -> Void)? + var onToggleFullScreen: (() -> Void)? + var onVolumeDown: (() -> Void)? + var onVolumeUp: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + activate() + } + + func activate() { + window?.makeFirstResponder(self) + } + + override func keyDown(with event: NSEvent) { + if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false { + super.keyDown(with: event) + return + } + + switch event.keyCode { + case 123: // left + onLeft?() + return + case 124: // right + onRight?() + return + case 125: // down + onVolumeDown?() + return + case 126: // up + onVolumeUp?() + return + case 49: // space + onTogglePlayPause?() + return + default: break + } + + let key = event.charactersIgnoringModifiers?.lowercased() ?? "" + switch key { + case "k": onTogglePlayPause?() + case "f": onToggleFullScreen?() + default: super.keyDown(with: event) + } + } +} +#else +private struct SystemPlayerKeyboardCaptureView: UIViewRepresentable { + let onLeft: () -> Void + let onRight: () -> Void + let onTogglePlayPause: () -> Void + let onToggleFullScreen: () -> Void + let onVolumeDown: () -> Void + let onVolumeUp: () -> Void + + func makeUIView(context: Context) -> SystemPlayerKeyCaptureUIView { + let view = SystemPlayerKeyCaptureUIView(frame: .zero) + applyCallbacks(to: view) + DispatchQueue.main.async { + view.activate() + } + return view + } + + func updateUIView(_ uiView: SystemPlayerKeyCaptureUIView, context: Context) { + applyCallbacks(to: uiView) + DispatchQueue.main.async { + uiView.activate() + } + } + + private func applyCallbacks(to view: SystemPlayerKeyCaptureUIView) { + view.onLeft = onLeft + view.onRight = onRight + view.onTogglePlayPause = onTogglePlayPause + view.onToggleFullScreen = onToggleFullScreen + view.onVolumeDown = onVolumeDown + view.onVolumeUp = onVolumeUp + } +} + +private final class SystemPlayerKeyCaptureUIView: UIView { + var onLeft: (() -> Void)? + var onRight: (() -> Void)? + var onTogglePlayPause: (() -> Void)? + var onToggleFullScreen: (() -> Void)? + var onVolumeDown: (() -> Void)? + var onVolumeUp: (() -> Void)? + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + [ + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)), + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)), + UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)), + UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)), + UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen)) + ] + } + + override func didMoveToWindow() { + super.didMoveToWindow() + activate() + } + + func activate() { + becomeFirstResponder() + } + + @objc private func handleLeft() { onLeft?() } + @objc private func handleRight() { onRight?() } + @objc private func handleVolumeDown() { onVolumeDown?() } + @objc private func handleVolumeUp() { onVolumeUp?() } + @objc private func handleTogglePlayPause() { onTogglePlayPause?() } + @objc private func handleToggleFullScreen() { onToggleFullScreen?() } +} +#endif diff --git a/tvbox/Views/Player/VLCPlayerView.swift b/tvbox/Views/Player/VLCPlayerView.swift new file mode 100644 index 0000000..c06c542 --- /dev/null +++ b/tvbox/Views/Player/VLCPlayerView.swift @@ -0,0 +1,1752 @@ +import SwiftUI +import Darwin + +#if canImport(VLCKitSPM) +import VLCKitSPM +#if os(iOS) +import UIKit +#endif + +@MainActor +final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate { + static let supportedPlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] + private static let defaultVolume = 100 + private static let maxVolume = 200 + private static let drawableSizeChangeThreshold: CGFloat = 24 + private static let drawableRebindMinimumInterval: TimeInterval = 1.2 + private static let playerInstanceSelector = NSSelectorFromString("playerInstance") + private static let libVLCStopAsync: LibVLCStopAsyncFunction? = { + // RTLD_DEFAULT 在 Swift 中不可直接用常量名,-2 等价于 C 宏 RTLD_DEFAULT。 + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) + return "libvlc_media_player_stop_async".withCString { symbolName in + guard let symbol = dlsym(defaultHandle, symbolName) else { return nil } + return unsafeBitCast(symbol, to: LibVLCStopAsyncFunction.self) + } + }() + private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void + + let mediaPlayer = VLCMediaPlayer() + @Published var isPreparing = true + @Published var isPlaying = false + @Published var currentTimeSeconds: Double = 0 + @Published var durationSeconds: Double = 0 + @Published var playbackRate: Float = 1.0 + @Published var volume: Int = defaultVolume + #if os(macOS) + private let persistentDrawableView = NSView(frame: .zero) + private weak var lastAttachedContainer: NSView? + #else + private let persistentDrawableView = UIView(frame: .zero) + private weak var lastAttachedContainer: UIView? + #endif + private var rebindWorkItems: [DispatchWorkItem] = [] + private var lastDrawableContainerIdentifier: ObjectIdentifier? + private var lastDrawableContainerSize: CGSize = .zero + private var lastDrawableRebindAt: Date = .distantPast + + var hasValidDuration: Bool { + durationSeconds > 0 + } + + private var progressTimer: Timer? + private var pendingSeekSeconds: Double? + private var isLive = false + private var isInBufferingState = false + private var pendingVodBufferingConfirmWorkItem: DispatchWorkItem? + private var bufferingBaselineSecondsVod: Double = 0 + private var decodeMode: VideoDecodeMode = .auto + private var decodeModeOverride: VideoDecodeMode? + private var hasAttemptedSoftDecodeFallback = false + private var bufferMode: VLCBufferMode = .defaultMode + private var bufferingFallbackWorkItem: DispatchWorkItem? + private var delayedPreparingWorkItem: DispatchWorkItem? + private var bufferingEventCountVod = 0 + private var lastVodBufferingCountedAt: Date = .distantPast + private var hasAttemptedVodCacheBoost = false + private var useVodCacheBoost = false + private var currentMediaURLString: String? + private var currentMediaIsLive = false + private var currentMediaDecodeMode: VideoDecodeMode = .auto + private var currentMediaBufferMode: VLCBufferMode = .defaultMode + private var onProgressChanged: ((Double, Double?) -> Void)? + private var onPlaybackEnded: (() -> Void)? + private var onPlaybackFailed: (() -> Void)? + private let progressUpdateIntervalVod: TimeInterval = 0.5 + private let progressUpdateIntervalLive: TimeInterval = 1.0 + private let bufferingFallbackThresholdVod: TimeInterval = 6.0 + private let bufferingFallbackThresholdLive: TimeInterval = 4.0 + private let bufferingConfirmDelayVod: TimeInterval = 0.35 + private let bufferingIndicatorDelayVod: TimeInterval = 1.2 + private let vodBufferingEventDebounceInterval: TimeInterval = 1.5 + private let vodBufferingProgressAdvanceThreshold: Double = 0.25 + private let vodCacheBoostTriggerCount = 2 + private let vodCacheBoostExtraNetwork = 4500 + private let vodCacheBoostExtraLive = 3500 + private let vodCacheBoostExtraFile = 5500 + private let progressPublishThreshold: Double = 0.25 + private let durationPublishThreshold: Double = 0.5 + private var lastNonZeroVolume = defaultVolume + + override init() { + super.init() + let savedObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_SPEED) + let savedRate = savedObj != nil ? Float(UserDefaults.standard.double(forKey: HawkConfig.PLAY_SPEED)) : 1.0 + playbackRate = Self.normalizedPlaybackRate(from: savedRate) + decodeMode = VideoDecodeMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) + ) + bufferMode = VLCBufferMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) + ) + let savedVolumeObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_VOLUME) + let savedVolume = savedVolumeObj != nil ? UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VOLUME) : Self.defaultVolume + volume = Self.normalizedVolume(from: savedVolume) + if volume > 0 { + lastNonZeroVolume = volume + } + #if os(macOS) + persistentDrawableView.wantsLayer = true + persistentDrawableView.layer?.backgroundColor = NSColor.black.cgColor + #else + persistentDrawableView.backgroundColor = .black + #endif + mediaPlayer.delegate = self + mediaPlayer.drawable = persistentDrawableView + } + + func play( + url: URL, + startPosition: Double, + isLive: Bool, + onProgressChanged: ((Double, Double?) -> Void)?, + onPlaybackEnded: (() -> Void)?, + onPlaybackFailed: (() -> Void)? + ) { + let targetURLString = url.absoluteString + let isNewMedia = currentMediaURLString != targetURLString || currentMediaIsLive != isLive + if isNewMedia { + resetPlaybackRecoveryState() + } + syncDecodeModeFromSettings() + syncBufferModeFromSettings() + + // 同一路径/同场景(点播或直播)时复用当前实例,避免切全屏触发重新加载 + if currentMediaURLString == targetURLString, + currentMediaIsLive == isLive, + currentMediaDecodeMode == decodeMode, + currentMediaBufferMode == bufferMode, + mediaPlayer.media != nil { + self.onProgressChanged = onProgressChanged + self.onPlaybackEnded = onPlaybackEnded + self.onPlaybackFailed = onPlaybackFailed + self.isLive = isLive + applyPlaybackRate() + applyVolume() + refreshPlaybackFlags() + emitProgress() + return + } + + stopProgressTimer() + cancelBufferingFallbackTimer() + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + isInBufferingState = false + self.onProgressChanged = onProgressChanged + self.onPlaybackEnded = onPlaybackEnded + self.onPlaybackFailed = onPlaybackFailed + self.isLive = isLive + pendingSeekSeconds = isLive ? nil : max(startPosition, 0) + setPlaybackStatus(preparing: true, playing: false) + resetProgressState() + + mediaPlayer.stop() + let media = VLCMedia(url: url) + + var cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode) + if !isLive && useVodCacheBoost { + cacheConfig.network += vodCacheBoostExtraNetwork + cacheConfig.live += vodCacheBoostExtraLive + cacheConfig.file += vodCacheBoostExtraFile + } + let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : true + let enableSkipFrames = isLive && bufferMode.enableFrameDrop + var mediaOptions: [String: Any] = [ + "network-caching": cacheConfig.network, + "live-caching": cacheConfig.live, + "file-caching": cacheConfig.file, + "drop-late-frames": enableFrameDrop ? 1 : 0, + "skip-frames": enableSkipFrames ? 1 : 0, + "http-reconnect": 1 + ] + + if let hwOption = decodeMode.vlcHardwareDecodeOption { + mediaOptions["avcodec-hw"] = hwOption + } + if isLive { + mediaOptions["avcodec-fast"] = 1 + } + if url.scheme?.lowercased() == "rtsp" { + mediaOptions["rtsp-tcp"] = 1 + } + + media.addOptions(mediaOptions) + + mediaPlayer.media = media + mediaPlayer.play() + applyPlaybackRate() + applyVolume() + startProgressTimer() + currentMediaURLString = targetURLString + currentMediaIsLive = isLive + currentMediaDecodeMode = decodeMode + currentMediaBufferMode = bufferMode + } + + func stop() { + stopProgressTimer() + resetPlaybackRecoveryState() + cancelScheduledRebinds() + stopMediaPlayer() + mediaPlayer.media = nil + onProgressChanged = nil + onPlaybackEnded = nil + onPlaybackFailed = nil + pendingSeekSeconds = nil + setPlaybackStatus(preparing: false, playing: false) + resetProgressState() + currentMediaURLString = nil + currentMediaIsLive = false + currentMediaDecodeMode = .auto + currentMediaBufferMode = .defaultMode + } + + func togglePlayback() { + if isPlaying { + mediaPlayer.pause() + } else { + mediaPlayer.play() + applyPlaybackRate() + } + } + + func setPlaybackRate(_ rate: Float) { + let normalized = Self.normalizedPlaybackRate(from: rate) + playbackRate = normalized + UserDefaults.standard.set(Double(normalized), forKey: HawkConfig.PLAY_SPEED) + applyPlaybackRate() + } + + func increasePlaybackRate() { + guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate), + index + 1 < Self.supportedPlaybackRates.count else { return } + setPlaybackRate(Self.supportedPlaybackRates[index + 1]) + } + + func decreasePlaybackRate() { + guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate), + index - 1 >= 0 else { return } + setPlaybackRate(Self.supportedPlaybackRates[index - 1]) + } + + func setVolume(_ value: Int) { + let normalized = Self.normalizedVolume(from: value) + if volume != normalized { + volume = normalized + } + if normalized > 0 { + lastNonZeroVolume = normalized + } + UserDefaults.standard.set(normalized, forKey: HawkConfig.PLAY_VOLUME) + applyVolume() + } + + func toggleMute() { + if volume == 0 { + let restored = lastNonZeroVolume > 0 ? lastNonZeroVolume : Self.defaultVolume + setVolume(restored) + } else { + setVolume(0) + } + } + + #if os(macOS) + func attachDrawable(to container: NSView) { + let containerIdentifier = ObjectIdentifier(container) + let containerSize = container.bounds.size + let containerChanged = lastDrawableContainerIdentifier != containerIdentifier + let sizeChanged = hasSignificantContainerSizeChange(to: containerSize) + let canRebindForSizeChange = canRebindDrawableForSizeChange() + lastAttachedContainer = container + lastDrawableContainerIdentifier = containerIdentifier + lastDrawableContainerSize = containerSize + let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil + + if persistentDrawableView.superview !== container { + persistentDrawableView.removeFromSuperview() + persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(persistentDrawableView) + NSLayoutConstraint.activate([ + persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor), + persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + container.layoutSubtreeIfNeeded() + } + + guard shouldRebind else { return } + + cancelScheduledRebinds() + refreshDrawableBinding() + scheduleDelayedDrawableRebind(for: container) + resumePlaybackAfterDrawableRebindIfNeeded() + } + + func detachDrawable(from container: NSView) { + if lastAttachedContainer === container { + lastAttachedContainer = nil + lastDrawableContainerIdentifier = nil + lastDrawableContainerSize = .zero + } + cancelScheduledRebinds() + if persistentDrawableView.superview === container { + persistentDrawableView.removeFromSuperview() + } + } + #else + func attachDrawable(to container: UIView) { + let containerIdentifier = ObjectIdentifier(container) + let containerSize = container.bounds.size + let containerChanged = lastDrawableContainerIdentifier != containerIdentifier + let sizeChanged = hasSignificantContainerSizeChange(to: containerSize) + let canRebindForSizeChange = canRebindDrawableForSizeChange() + lastAttachedContainer = container + lastDrawableContainerIdentifier = containerIdentifier + lastDrawableContainerSize = containerSize + let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil + + if persistentDrawableView.superview !== container { + persistentDrawableView.removeFromSuperview() + persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(persistentDrawableView) + NSLayoutConstraint.activate([ + persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor), + persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + container.layoutIfNeeded() + } + + guard shouldRebind else { return } + + cancelScheduledRebinds() + refreshDrawableBinding() + scheduleDelayedDrawableRebind(for: container) + resumePlaybackAfterDrawableRebindIfNeeded() + } + + func detachDrawable(from container: UIView) { + if lastAttachedContainer === container { + lastAttachedContainer = nil + lastDrawableContainerIdentifier = nil + lastDrawableContainerSize = .zero + } + cancelScheduledRebinds() + if persistentDrawableView.superview === container { + persistentDrawableView.removeFromSuperview() + } + } + #endif + + private func refreshDrawableBinding() { + // 强制重绑视频输出,规避 macOS 切全屏后偶发“有声音无画面” + mediaPlayer.drawable = nil + mediaPlayer.drawable = persistentDrawableView + lastDrawableRebindAt = Date() + } + + private func stopMediaPlayer() { + // 优先走 libvlc 异步 stop,避免菜单切换时主线程被同步 stop 卡住。 + if let playerPointer = playerInstancePointer(), + let stopAsync = Self.libVLCStopAsync { + stopAsync(playerPointer) + return + } + mediaPlayer.stop() + } + + private func playerInstancePointer() -> UnsafeMutableRawPointer? { + let selector = Self.playerInstanceSelector + guard mediaPlayer.responds(to: selector) else { return nil } + typealias PlayerInstanceGetter = @convention(c) (AnyObject, Selector) -> UnsafeMutableRawPointer? + let imp = mediaPlayer.method(for: selector) + let getter = unsafeBitCast(imp, to: PlayerInstanceGetter.self) + return getter(mediaPlayer, selector) + } + + private func cancelScheduledRebinds() { + rebindWorkItems.forEach { $0.cancel() } + rebindWorkItems.removeAll() + } + + private func hasSignificantContainerSizeChange(to newSize: CGSize) -> Bool { + let previousSize = lastDrawableContainerSize + guard previousSize != .zero else { return false } + let widthChanged = abs(newSize.width - previousSize.width) > Self.drawableSizeChangeThreshold + let heightChanged = abs(newSize.height - previousSize.height) > Self.drawableSizeChangeThreshold + return widthChanged || heightChanged + } + + private func canRebindDrawableForSizeChange() -> Bool { + Date().timeIntervalSince(lastDrawableRebindAt) >= Self.drawableRebindMinimumInterval + } + + private func resumePlaybackAfterDrawableRebindIfNeeded() { + guard mediaPlayer.media != nil else { return } + if !mediaPlayer.isPlaying, + mediaPlayer.state != .opening, + mediaPlayer.state != .buffering { + mediaPlayer.play() + } + applyPlaybackRate() + } + + #if os(macOS) + private func scheduleDelayedDrawableRebind(for container: NSView) { + [0.05, 0.18].forEach { delay in + let workItem = DispatchWorkItem { [weak self, weak container] in + guard let self, let container else { return } + guard self.lastAttachedContainer === container else { return } + guard self.persistentDrawableView.superview === container else { return } + self.refreshDrawableBinding() + self.resumePlaybackAfterDrawableRebindIfNeeded() + } + rebindWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + #else + private func scheduleDelayedDrawableRebind(for container: UIView) { + [0.05, 0.18].forEach { delay in + let workItem = DispatchWorkItem { [weak self, weak container] in + guard let self, let container else { return } + guard self.lastAttachedContainer === container else { return } + guard self.persistentDrawableView.superview === container else { return } + self.refreshDrawableBinding() + self.resumePlaybackAfterDrawableRebindIfNeeded() + } + rebindWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + #endif + + func seek(by offset: Double) { + guard !isLive else { return } + var target = max(currentTimeSeconds + offset, 0) + if hasValidDuration { + target = min(target, durationSeconds) + } + seek(to: target) + } + + func seek(to seconds: Double) { + guard !isLive else { return } + let maxSeconds = min(durationSeconds > 0 ? durationSeconds : Double(Int32.max) / 1000.0, Double(Int32.max) / 1000.0) + let value = max(0, min(seconds, maxSeconds)) + mediaPlayer.time = VLCTime(int: Int32(value * 1000.0)) + emitProgress() + } + + nonisolated func mediaPlayerStateChanged(_ aNotification: Notification) { + Task { @MainActor [weak self] in + self?.handlePlayerStateChanged() + } + } + + nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) { + // 使用定时器统一采样进度,避免 VLC 高频 time 回调带来主线程负载。 + } + + private func handlePlayerStateChanged() { + switch mediaPlayer.state { + case .opening, .buffering: + handleBufferingState() + case .playing: + handlePlayingState() + case .paused: + isInBufferingState = false + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + setPlaybackStatus(preparing: false, playing: false) + cancelBufferingFallbackTimer() + case .ended: + isInBufferingState = false + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + setPlaybackStatus(preparing: false, playing: false) + cancelBufferingFallbackTimer() + onPlaybackEnded?() + case .error: + isInBufferingState = false + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + setPlaybackStatus(preparing: false, playing: false) + cancelBufferingFallbackTimer() + onPlaybackFailed?() + case .stopped: + isInBufferingState = false + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + setPlaybackStatus(preparing: false, playing: false) + cancelBufferingFallbackTimer() + default: + break + } + } + + private func startProgressTimer() { + stopProgressTimer() + let interval = isLive ? progressUpdateIntervalLive : progressUpdateIntervalVod + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.emitProgress() + } + } + timer.tolerance = interval * 0.25 + progressTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func stopProgressTimer() { + progressTimer?.invalidate() + progressTimer = nil + } + + private func applyPendingSeekIfNeeded() { + guard let pendingSeekSeconds, pendingSeekSeconds > 0 else { return } + seek(to: pendingSeekSeconds) + self.pendingSeekSeconds = nil + } + + private func emitProgress() { + refreshPlaybackFlags() + guard !isLive else { return } + let current = currentSeconds() + guard current.isFinite, current >= 0 else { return } + + let roundedCurrent = (current / progressPublishThreshold).rounded() * progressPublishThreshold + let didUpdateCurrent = abs(roundedCurrent - currentTimeSeconds) >= progressPublishThreshold + if didUpdateCurrent { + currentTimeSeconds = roundedCurrent + } + + var didUpdateDuration = false + if let duration = durationSecondsFromMedia() { + if abs(duration - durationSeconds) >= durationPublishThreshold { + durationSeconds = duration + didUpdateDuration = true + } + } + + guard didUpdateCurrent || didUpdateDuration else { return } + onProgressChanged?(currentTimeSeconds, hasValidDuration ? durationSeconds : nil) + } + + private func refreshPlaybackFlags() { + // 部分直播源会长时间停留在 buffering/opening 回调,但实际已开始渲染。 + // 使用底层 isPlaying 兜底,避免“永远加载中”。 + if mediaPlayer.isPlaying { + isInBufferingState = false + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + setPlaybackStatus(preparing: false, playing: true) + cancelBufferingFallbackTimer() + return + } + + switch mediaPlayer.state { + case .opening, .buffering: + setPlaybackStatus(preparing: true, playing: false) + case .playing: + setPlaybackStatus(preparing: false, playing: true) + case .paused, .stopped, .ended, .error: + setPlaybackStatus(preparing: false, playing: false) + default: + break + } + } + + private func applyPlaybackRate() { + mediaPlayer.rate = playbackRate + } + + private func applyVolume() { + mediaPlayer.audio?.volume = Int32(volume) + } + + private func syncDecodeModeFromSettings() { + if let decodeModeOverride { + decodeMode = decodeModeOverride + return + } + decodeMode = VideoDecodeMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE) + ) + } + + private func syncBufferModeFromSettings() { + bufferMode = VLCBufferMode.fromStoredValue( + UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) + ) + } + + private static func normalizedPlaybackRate(from raw: Float) -> Float { + guard !supportedPlaybackRates.isEmpty else { return 1.0 } + return supportedPlaybackRates.min(by: { abs($0 - raw) < abs($1 - raw) }) ?? 1.0 + } + + private static func normalizedVolume(from raw: Int) -> Int { + min(max(raw, 0), maxVolume) + } + + private static func cacheConfig(isLive: Bool, bufferMode: VLCBufferMode) -> (network: Int, live: Int, file: Int) { + bufferMode.cacheConfig(isLive: isLive) + } + + private func scheduleBufferingFallbackIfNeeded() { + guard bufferingFallbackWorkItem == nil else { return } + guard !hasAttemptedSoftDecodeFallback else { return } + if !isLive && !hasAttemptedVodCacheBoost { return } + guard decodeMode != .software else { return } + guard mediaPlayer.media != nil else { return } + + let delay = isLive ? bufferingFallbackThresholdLive : bufferingFallbackThresholdVod + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + self?.attemptSoftDecodeFallbackIfNeeded() + } + } + bufferingFallbackWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func cancelBufferingFallbackTimer() { + bufferingFallbackWorkItem?.cancel() + bufferingFallbackWorkItem = nil + } + + private func attemptSoftDecodeFallbackIfNeeded() { + cancelBufferingFallbackTimer() + guard !hasAttemptedSoftDecodeFallback else { return } + guard decodeMode != .software else { return } + guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return } + guard mediaPlayer.media != nil else { return } + + hasAttemptedSoftDecodeFallback = true + decodeModeOverride = .software + let resumePosition = isLive ? 0 : max(currentSeconds(), 0) + play( + url: url, + startPosition: resumePosition, + isLive: isLive, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onPlaybackFailed: onPlaybackFailed + ) + } + + private func handleBufferingState() { + if isLive { + if !isInBufferingState { + isInBufferingState = true + } + setPlaybackStatus(preparing: true, playing: false) + scheduleBufferingFallbackIfNeeded() + return + } + + if isInBufferingState { + if isPlaying { + isPlaying = false + } + scheduleDelayedPreparingIndicatorForVod() + scheduleBufferingFallbackIfNeeded() + return + } + + scheduleVodBufferingConfirmationIfNeeded() + } + + private func handlePlayingState() { + cancelPendingVodBufferingConfirmation() + isInBufferingState = false + cancelDelayedPreparingIndicator() + setPlaybackStatus(preparing: false, playing: true) + cancelBufferingFallbackTimer() + applyPlaybackRate() + applyVolume() + applyPendingSeekIfNeeded() + } + + private func scheduleDelayedPreparingIndicatorForVod() { + guard !isLive else { return } + guard delayedPreparingWorkItem == nil else { return } + guard !isPreparing else { return } + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.isInBufferingState, !self.mediaPlayer.isPlaying else { return } + guard self.isStillStalledSinceBufferingStartedVod() else { return } + self.setPlaybackStatus(preparing: true, playing: false) + } + delayedPreparingWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + bufferingIndicatorDelayVod, execute: workItem) + } + + private func cancelDelayedPreparingIndicator() { + delayedPreparingWorkItem?.cancel() + delayedPreparingWorkItem = nil + } + + private func scheduleVodBufferingConfirmationIfNeeded() { + guard !isLive else { return } + guard pendingVodBufferingConfirmWorkItem == nil else { return } + bufferingBaselineSecondsVod = max(currentSeconds(), currentTimeSeconds) + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingVodBufferingConfirmWorkItem = nil + guard self.isBufferingLikeState(self.mediaPlayer.state) else { return } + guard !self.mediaPlayer.isPlaying else { return } + guard self.isStillStalledSinceBufferingStartedVod() else { return } + self.enterConfirmedVodBufferingState() + } + pendingVodBufferingConfirmWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + bufferingConfirmDelayVod, execute: workItem) + } + + private func cancelPendingVodBufferingConfirmation() { + pendingVodBufferingConfirmWorkItem?.cancel() + pendingVodBufferingConfirmWorkItem = nil + } + + private func enterConfirmedVodBufferingState() { + guard !isLive else { return } + guard !mediaPlayer.isPlaying else { return } + if !isInBufferingState { + isInBufferingState = true + markVodBufferingEventIfNeeded() + if shouldAttemptVodCacheBoost() { + attemptVodCacheBoostIfNeeded() + return + } + } + if isPlaying { + isPlaying = false + } + scheduleDelayedPreparingIndicatorForVod() + scheduleBufferingFallbackIfNeeded() + } + + private func markVodBufferingEventIfNeeded() { + let now = Date() + guard now.timeIntervalSince(lastVodBufferingCountedAt) >= vodBufferingEventDebounceInterval else { return } + lastVodBufferingCountedAt = now + bufferingEventCountVod += 1 + } + + private func isStillStalledSinceBufferingStartedVod() -> Bool { + let current = max(currentSeconds(), currentTimeSeconds) + return (current - bufferingBaselineSecondsVod) < vodBufferingProgressAdvanceThreshold + } + + private func isBufferingLikeState(_ state: VLCMediaPlayerState) -> Bool { + switch state { + case .opening, .buffering: + return true + default: + return false + } + } + + private func shouldAttemptVodCacheBoost() -> Bool { + !isLive && !hasAttemptedVodCacheBoost && bufferingEventCountVod >= vodCacheBoostTriggerCount + } + + private func attemptVodCacheBoostIfNeeded() { + guard !isLive else { return } + guard !hasAttemptedVodCacheBoost else { return } + guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return } + guard mediaPlayer.media != nil else { return } + hasAttemptedVodCacheBoost = true + useVodCacheBoost = true + cancelDelayedPreparingIndicator() + cancelBufferingFallbackTimer() + let resumePosition = max(currentSeconds(), 0) + play( + url: url, + startPosition: resumePosition, + isLive: false, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onPlaybackFailed: onPlaybackFailed + ) + } + + private func resetPlaybackRecoveryState() { + cancelBufferingFallbackTimer() + cancelDelayedPreparingIndicator() + cancelPendingVodBufferingConfirmation() + hasAttemptedSoftDecodeFallback = false + decodeModeOverride = nil + isInBufferingState = false + bufferingBaselineSecondsVod = 0 + bufferingEventCountVod = 0 + lastVodBufferingCountedAt = .distantPast + hasAttemptedVodCacheBoost = false + useVodCacheBoost = false + } + + private func setPlaybackStatus(preparing: Bool, playing: Bool) { + if isPreparing != preparing { + isPreparing = preparing + } + if isPlaying != playing { + isPlaying = playing + } + } + + private func resetProgressState() { + if currentTimeSeconds != 0 { + currentTimeSeconds = 0 + } + if durationSeconds != 0 { + durationSeconds = 0 + } + } + + private func currentSeconds() -> Double { + let raw = mediaPlayer.time.intValue + if raw < 0 { return 0 } + return Double(raw) / 1000.0 + } + + private func durationSecondsFromMedia() -> Double? { + let raw = mediaPlayer.media?.length.intValue ?? 0 + guard raw > 0 else { return nil } + return Double(raw) / 1000.0 + } +} + +struct VLCVodPlayerView: View { + let urlString: String + var startPosition: Double = 0 + var onProgressChanged: ((Double, Double?) -> Void)? = nil + var onPlaybackEnded: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + var canPlayNext: Bool = false + var onPlayNext: (() -> Void)? = nil + var sharedController: VLCPlayerController? = nil + @StateObject private var ownedController = VLCPlayerController() + @State private var isDraggingProgress = false + @State private var draggingSeconds: Double = 0 + + @State private var showControls = true + @State private var controlsTimer: Timer? + @State private var osdIcon: String? + @State private var osdOpacity: Double = 0 + @State private var osdTimer: Timer? + @State private var startPlaybackTask: Task? + + private var controller: VLCPlayerController { + sharedController ?? ownedController + } + + var body: some View { + ZStack { + VLCDrawableView(controller: controller) + .background(Color.black) + .onTapGesture(count: 2) { + onToggleFullScreen?() + } + .onTapGesture(count: 1) { + togglePlaybackWithOSD() + } + + if controller.isPreparing { + ProgressView() + .tint(.white) + } + + if let osdIcon = osdIcon { + Image(systemName: osdIcon) + .font(.system(size: 60, weight: .semibold)) + .foregroundColor(.white) + .padding(30) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .opacity(osdOpacity) + .allowsHitTesting(false) + } + } + .overlay(alignment: .bottom) { + GeometryReader { proxy in + playbackControls(containerWidth: proxy.size.width) + .padding(12) + .opacity(showControls ? 1.0 : 0.0) + .animation(.easeInOut(duration: 0.3), value: showControls) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + .overlay { + KeyboardShortcutCaptureView( + onLeft: { wakeUpControls(); controller.seek(by: -seekStep); showOSD(icon: "gobackward.\(Int(seekStep))") }, + onRight: { wakeUpControls(); controller.seek(by: seekStep); showOSD(icon: "goforward.\(Int(seekStep))") }, + onTogglePlayPause: { wakeUpControls(); togglePlaybackWithOSD() }, + onToggleFullScreen: { wakeUpControls(); onToggleFullScreen?() }, + onDecreaseSpeed: { wakeUpControls(); controller.decreasePlaybackRate(); showOSD(icon: "tortoise.fill") }, + onIncreaseSpeed: { wakeUpControls(); controller.increasePlaybackRate(); showOSD(icon: "hare.fill") }, + onVolumeDown: { + wakeUpControls() + controller.setVolume(controller.volume - volumeStep) + showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + }, + onVolumeUp: { + wakeUpControls() + controller.setVolume(controller.volume + volumeStep) + showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + } + ) + .frame(width: 1, height: 1) + .opacity(0.01) + .allowsHitTesting(false) + } + .onContinuousHover { phase in + switch phase { + case .active(_): wakeUpControls() + case .ended: break + } + } + .onAppear { + startPlayback() + wakeUpControls() + } + .onChange(of: urlString) { _, _ in + startPlayback() + wakeUpControls() + } + .onChange(of: controller.currentTimeSeconds) { _, newValue in + if !isDraggingProgress { + draggingSeconds = newValue + } + } + .onDisappear { + startPlaybackTask?.cancel() + startPlaybackTask = nil + if sharedController == nil { + controller.stop() + } + controlsTimer?.invalidate() + osdTimer?.invalidate() + } + } + + private func wakeUpControls() { + withAnimation { showControls = true } + controlsTimer?.invalidate() + controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.5)) { + showControls = false + } + } + } + + private func showOSD(icon: String) { + osdIcon = icon + osdOpacity = 1.0 + osdTimer?.invalidate() + osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.5)) { + osdOpacity = 0.0 + } + } + } + + private func togglePlaybackWithOSD() { + controller.togglePlayback() + showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill") + } + + private func startPlayback() { + guard let url = URL(string: urlString) else { return } + let targetStartPosition = max(startPosition, 0) + draggingSeconds = targetStartPosition + startPlaybackTask?.cancel() + startPlaybackTask = Task { @MainActor in + // 先让出一个主线程周期,避免点击瞬间布局与播放器初始化竞争。 + await Task.yield() + guard !Task.isCancelled else { return } + controller.play( + url: url, + startPosition: targetStartPosition, + isLive: false, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onPlaybackFailed: nil + ) + } + } + + private var seekStep: Double { + let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) + return Double(saved > 0 ? saved : 10) + } + + private var volumeStep: Int { 10 } + + private var progressUpperBound: Double { + max(controller.durationSeconds, max(controller.currentTimeSeconds, 1)) + } + + private var currentDisplaySeconds: Double { + isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds + } + + private var totalDisplayText: String { + controller.hasValidDuration ? controller.durationSeconds.durationString : "--:--" + } + + private func playbackControls(containerWidth: CGFloat) -> some View { + VStack(spacing: 8) { + // 第一行:进度条和时间 + HStack(spacing: 12) { + Text(currentDisplaySeconds.durationString) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.9)) + .frame(width: 45, alignment: .leading) + + Slider( + value: Binding( + get: { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds }, + set: { + draggingSeconds = $0 + wakeUpControls() + } + ), + in: 0...progressUpperBound, + onEditingChanged: { editing in + isDraggingProgress = editing + wakeUpControls() + if !editing { + controller.seek(to: draggingSeconds) + } + } + ) + .accentColor(.white) + .disabled(!controller.hasValidDuration) + + Text(totalDisplayText) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + .frame(width: 45, alignment: .trailing) + } + .padding(.horizontal, 4) + + // 第二行:控制按钮 + HStack(spacing: 0) { + // 左侧区:倍速 + HStack(spacing: 16) { + playbackRateMenu + } + .frame(width: 150, alignment: .leading) + + Spacer() + + // 中间区:主控 + HStack(spacing: 24) { + Button { + wakeUpControls() + controller.seek(by: -seekStep) + showOSD(icon: "gobackward.\(Int(seekStep))") + } label: { + Image(systemName: "gobackward.\(Int(seekStep))") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + + Button { + wakeUpControls() + togglePlaybackWithOSD() + } label: { + ZStack { + Circle() + .fill(Color.white.opacity(0.15)) + .frame(width: 38, height: 38) + + Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 18, weight: .bold)) + } + } + .buttonStyle(.plain) + + Button { + wakeUpControls() + controller.seek(by: seekStep) + showOSD(icon: "goforward.\(Int(seekStep))") + } label: { + Image(systemName: "goforward.\(Int(seekStep))") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + + if let onPlayNext { + Button { + guard canPlayNext else { return } + wakeUpControls() + onPlayNext() + showOSD(icon: "forward.end.fill") + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 18, weight: .medium)) + } + .buttonStyle(.plain) + .disabled(!canPlayNext) + .opacity(canPlayNext ? 1 : 0.4) + } + } + + Spacer() + + // 右侧区:音量和全屏 + HStack(spacing: 14) { + HStack(spacing: 6) { + Button { + wakeUpControls() + controller.toggleMute() + showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + } label: { + Image(systemName: volumeIconName) + .font(.system(size: 14, weight: .bold)) + .frame(width: 20) + } + .buttonStyle(.plain) + + Slider( + value: Binding( + get: { Double(controller.volume) }, + set: { + controller.setVolume(Int($0.rounded())) + wakeUpControls() + } + ), + in: 0...200, + step: 1 + ) + .accentColor(.white.opacity(0.8)) + .frame(width: 80) + } + + if let onToggleFullScreen { + Button { + wakeUpControls() + onToggleFullScreen() + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 15, weight: .bold)) + } + .buttonStyle(.plain) + } + } + .frame(width: 150, alignment: .trailing) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 10) + .foregroundColor(.white) + .glassCard(cornerRadius: 18) + .padding(.horizontal, 20) + .padding(.bottom, 6) + .frame(width: containerWidth * 0.7) + .environment(\.colorScheme, .dark) + } + + private var playbackRateMenu: some View { + Menu { + ForEach(VLCPlayerController.supportedPlaybackRates, id: \.self) { rate in + Button { + wakeUpControls() + controller.setPlaybackRate(rate) + showOSD(icon: "speedometer") + } label: { + HStack { + Text(playbackRateLabel(rate)) + if rate == controller.playbackRate { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(playbackRateLabel(controller.playbackRate)) + Image(systemName: "chevron.up") + .font(.system(size: 8, weight: .bold)) + } + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func playbackRateLabel(_ rate: Float) -> String { + if rate.rounded() == rate { + return "\(Int(rate))x" + } + if (rate * 10).rounded() == rate * 10 { + return "\(String(format: "%.1f", rate))x" + } + return "\(String(format: "%.2f", rate))x" + } + + private var volumeIconName: String { + switch controller.volume { + case ...0: + return "speaker.slash.fill" + case 1...66: + return "speaker.wave.1.fill" + default: + return "speaker.wave.2.fill" + } + } +} + +struct VLCLivePlayerView: View { + let urlString: String + var activityToken: Int = 0 + var onPlaybackFailed: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + @StateObject private var controller = VLCPlayerController() + private var volumeStep: Int { 10 } + + @State private var osdIcon: String? + @State private var osdOpacity: Double = 0 + @State private var osdTimer: Timer? + + var body: some View { + ZStack { + VLCDrawableView(controller: controller) + .background(Color.black) + .onTapGesture(count: 2) { + onToggleFullScreen?() + } + .onTapGesture(count: 1) { + togglePlaybackWithOSD() + } + + if controller.isPreparing { + ProgressView() + .tint(.white) + } + + if let osdIcon = osdIcon { + Image(systemName: osdIcon) + .font(.system(size: 60, weight: .semibold)) + .foregroundColor(.white) + .padding(30) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .opacity(osdOpacity) + .allowsHitTesting(false) + } + } + .overlay { + KeyboardShortcutCaptureView( + onLeft: { }, + onRight: { }, + onTogglePlayPause: { togglePlaybackWithOSD() }, + onToggleFullScreen: { onToggleFullScreen?() }, + onDecreaseSpeed: { }, + onIncreaseSpeed: { }, + onVolumeDown: { + controller.setVolume(controller.volume - volumeStep) + showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + }, + onVolumeUp: { + controller.setVolume(controller.volume + volumeStep) + showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + } + ) + .frame(width: 1, height: 1) + .opacity(0.01) + .allowsHitTesting(false) + } + .onAppear { + startPlayback() + } + .onChange(of: urlString) { _, _ in + startPlayback() + } + .onDisappear { + controller.stop() + osdTimer?.invalidate() + } + } + + private func showOSD(icon: String) { + osdIcon = icon + osdOpacity = 1.0 + osdTimer?.invalidate() + osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + withAnimation(.easeOut(duration: 0.5)) { + osdOpacity = 0.0 + } + } + } + + private func togglePlaybackWithOSD() { + controller.togglePlayback() + showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill") + } + + private func startPlayback() { + guard let url = URL(string: urlString) else { + onPlaybackFailed?() + return + } + controller.play( + url: url, + startPosition: 0, + isLive: true, + onProgressChanged: nil, + onPlaybackEnded: nil, + onPlaybackFailed: onPlaybackFailed + ) + } +} + +private struct VLCDrawableView: View { + let controller: VLCPlayerController + + var body: some View { + #if os(macOS) + VLCMacDrawableView(controller: controller) + #else + VLCIOSDrawableView(controller: controller) + #endif + } +} + +private struct KeyboardShortcutCaptureView: View { + let onLeft: () -> Void + let onRight: () -> Void + let onTogglePlayPause: () -> Void + let onToggleFullScreen: () -> Void + let onDecreaseSpeed: () -> Void + let onIncreaseSpeed: () -> Void + let onVolumeDown: () -> Void + let onVolumeUp: () -> Void + + var body: some View { + #if os(macOS) + MacKeyboardCaptureView( + onLeft: onLeft, + onRight: onRight, + onTogglePlayPause: onTogglePlayPause, + onToggleFullScreen: onToggleFullScreen, + onDecreaseSpeed: onDecreaseSpeed, + onIncreaseSpeed: onIncreaseSpeed, + onVolumeDown: onVolumeDown, + onVolumeUp: onVolumeUp + ) + #else + IOSKeyboardCaptureView( + onLeft: onLeft, + onRight: onRight, + onTogglePlayPause: onTogglePlayPause, + onToggleFullScreen: onToggleFullScreen, + onDecreaseSpeed: onDecreaseSpeed, + onIncreaseSpeed: onIncreaseSpeed, + onVolumeDown: onVolumeDown, + onVolumeUp: onVolumeUp + ) + #endif + } +} + +#if os(macOS) +private struct VLCMacDrawableView: NSViewRepresentable { + let controller: VLCPlayerController + + final class Coordinator { + let controller: VLCPlayerController + + init(controller: VLCPlayerController) { + self.controller = controller + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller) + } + + func makeNSView(context: Context) -> VLCOutputNSView { + let view = VLCOutputNSView(frame: .zero) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.black.cgColor + view.onLifecycle = { container in + context.coordinator.controller.attachDrawable(to: container) + } + view.requestLifecycleUpdate() + return view + } + + func updateNSView(_ nsView: VLCOutputNSView, context: Context) { + nsView.onLifecycle = { container in + context.coordinator.controller.attachDrawable(to: container) + } + nsView.requestLifecycleUpdate() + } + + static func dismantleNSView(_ nsView: VLCOutputNSView, coordinator: Coordinator) { + nsView.onLifecycle = nil + coordinator.controller.detachDrawable(from: nsView) + } +} + +private final class VLCOutputNSView: NSView { + var onLifecycle: ((NSView) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + requestLifecycleUpdate() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + requestLifecycleUpdate() + } + + override func layout() { + super.layout() + requestLifecycleUpdate() + } + + func requestLifecycleUpdate() { + onLifecycle?(self) + } +} + +private struct MacKeyboardCaptureView: NSViewRepresentable { + let onLeft: () -> Void + let onRight: () -> Void + let onTogglePlayPause: () -> Void + let onToggleFullScreen: () -> Void + let onDecreaseSpeed: () -> Void + let onIncreaseSpeed: () -> Void + let onVolumeDown: () -> Void + let onVolumeUp: () -> Void + + func makeNSView(context: Context) -> MacKeyCaptureNSView { + let view = MacKeyCaptureNSView(frame: .zero) + applyCallbacks(to: view) + DispatchQueue.main.async { + view.activate() + } + return view + } + + func updateNSView(_ nsView: MacKeyCaptureNSView, context: Context) { + applyCallbacks(to: nsView) + DispatchQueue.main.async { + nsView.activate() + } + } + + private func applyCallbacks(to view: MacKeyCaptureNSView) { + view.onLeft = onLeft + view.onRight = onRight + view.onTogglePlayPause = onTogglePlayPause + view.onToggleFullScreen = onToggleFullScreen + view.onDecreaseSpeed = onDecreaseSpeed + view.onIncreaseSpeed = onIncreaseSpeed + view.onVolumeDown = onVolumeDown + view.onVolumeUp = onVolumeUp + } +} + +private final class MacKeyCaptureNSView: NSView { + var onLeft: (() -> Void)? + var onRight: (() -> Void)? + var onTogglePlayPause: (() -> Void)? + var onToggleFullScreen: (() -> Void)? + var onDecreaseSpeed: (() -> Void)? + var onIncreaseSpeed: (() -> Void)? + var onVolumeDown: (() -> Void)? + var onVolumeUp: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + activate() + } + + func activate() { + window?.makeFirstResponder(self) + } + + override func keyDown(with event: NSEvent) { + if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false { + super.keyDown(with: event) + return + } + + switch event.keyCode { + case 123: // left + onLeft?() + return + case 124: // right + onRight?() + return + case 125: // down + onVolumeDown?() + return + case 126: // up + onVolumeUp?() + return + case 49: // space + onTogglePlayPause?() + return + default: + break + } + + let key = event.charactersIgnoringModifiers?.lowercased() ?? "" + switch key { + case "k": + onTogglePlayPause?() + case "f": + onToggleFullScreen?() + case "[": + onDecreaseSpeed?() + case "]": + onIncreaseSpeed?() + default: + super.keyDown(with: event) + } + } +} +#else +private struct VLCIOSDrawableView: UIViewRepresentable { + let controller: VLCPlayerController + + final class Coordinator { + let controller: VLCPlayerController + + init(controller: VLCPlayerController) { + self.controller = controller + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller) + } + + func makeUIView(context: Context) -> VLCOutputUIView { + let view = VLCOutputUIView(frame: .zero) + view.backgroundColor = .black + view.onLifecycle = { container in + context.coordinator.controller.attachDrawable(to: container) + } + view.requestLifecycleUpdate() + return view + } + + func updateUIView(_ uiView: VLCOutputUIView, context: Context) { + uiView.onLifecycle = { container in + context.coordinator.controller.attachDrawable(to: container) + } + uiView.requestLifecycleUpdate() + } + + static func dismantleUIView(_ uiView: VLCOutputUIView, coordinator: Coordinator) { + uiView.onLifecycle = nil + coordinator.controller.detachDrawable(from: uiView) + } +} + +private final class VLCOutputUIView: UIView { + var onLifecycle: ((UIView) -> Void)? + + override func didMoveToWindow() { + super.didMoveToWindow() + requestLifecycleUpdate() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + requestLifecycleUpdate() + } + + override func layoutSubviews() { + super.layoutSubviews() + requestLifecycleUpdate() + } + + func requestLifecycleUpdate() { + onLifecycle?(self) + } +} + +private struct IOSKeyboardCaptureView: UIViewRepresentable { + let onLeft: () -> Void + let onRight: () -> Void + let onTogglePlayPause: () -> Void + let onToggleFullScreen: () -> Void + let onDecreaseSpeed: () -> Void + let onIncreaseSpeed: () -> Void + let onVolumeDown: () -> Void + let onVolumeUp: () -> Void + + func makeUIView(context: Context) -> IOSKeyCaptureView { + let view = IOSKeyCaptureView(frame: .zero) + applyCallbacks(to: view) + DispatchQueue.main.async { + view.activate() + } + return view + } + + func updateUIView(_ uiView: IOSKeyCaptureView, context: Context) { + applyCallbacks(to: uiView) + DispatchQueue.main.async { + uiView.activate() + } + } + + private func applyCallbacks(to view: IOSKeyCaptureView) { + view.onLeft = onLeft + view.onRight = onRight + view.onTogglePlayPause = onTogglePlayPause + view.onToggleFullScreen = onToggleFullScreen + view.onDecreaseSpeed = onDecreaseSpeed + view.onIncreaseSpeed = onIncreaseSpeed + view.onVolumeDown = onVolumeDown + view.onVolumeUp = onVolumeUp + } +} + +private final class IOSKeyCaptureView: UIView { + var onLeft: (() -> Void)? + var onRight: (() -> Void)? + var onTogglePlayPause: (() -> Void)? + var onToggleFullScreen: (() -> Void)? + var onDecreaseSpeed: (() -> Void)? + var onIncreaseSpeed: (() -> Void)? + var onVolumeDown: (() -> Void)? + var onVolumeUp: (() -> Void)? + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + [ + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)), + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)), + UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)), + UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)), + UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen)), + UIKeyCommand(input: "[", modifierFlags: [], action: #selector(handleDecreaseSpeed)), + UIKeyCommand(input: "]", modifierFlags: [], action: #selector(handleIncreaseSpeed)) + ] + } + + override func didMoveToWindow() { + super.didMoveToWindow() + activate() + } + + func activate() { + becomeFirstResponder() + } + + @objc private func handleLeft() { + onLeft?() + } + + @objc private func handleRight() { + onRight?() + } + + @objc private func handleTogglePlayPause() { + onTogglePlayPause?() + } + + @objc private func handleToggleFullScreen() { + onToggleFullScreen?() + } + + @objc private func handleDecreaseSpeed() { + onDecreaseSpeed?() + } + + @objc private func handleIncreaseSpeed() { + onIncreaseSpeed?() + } + + @objc private func handleVolumeDown() { + onVolumeDown?() + } + + @objc private func handleVolumeUp() { + onVolumeUp?() + } +} +#endif + +#else + +final class VLCPlayerController: ObservableObject { + func stop() {} +} + +struct VLCVodPlayerView: View { + let urlString: String + var startPosition: Double = 0 + var onProgressChanged: ((Double, Double?) -> Void)? = nil + var onPlaybackEnded: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + var canPlayNext: Bool = false + var onPlayNext: (() -> Void)? = nil + + var body: some View { + AVPlayerContentView( + urlString: urlString, + startPosition: startPosition, + onProgressChanged: onProgressChanged, + onPlaybackEnded: onPlaybackEnded, + onToggleFullScreen: onToggleFullScreen, + canPlayNext: canPlayNext, + onPlayNext: onPlayNext + ) + } +} + +struct VLCLivePlayerView: View { + let urlString: String + var onPlaybackFailed: (() -> Void)? = nil + var onToggleFullScreen: (() -> Void)? = nil + + var body: some View { + Color.black + } +} + +#endif diff --git a/tvbox/Views/Search/SearchView.swift b/tvbox/Views/Search/SearchView.swift new file mode 100644 index 0000000..1ccc2b3 --- /dev/null +++ b/tvbox/Views/Search/SearchView.swift @@ -0,0 +1,223 @@ +import SwiftUI + +/// 搜索页 - 对应 Android 版 SearchActivity +struct SearchView: View { + @StateObject private var viewModel = SearchViewModel() + + #if os(iOS) + private let columns = [ + GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) + ] + #else + private let columns = [ + GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) + ] + #endif + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // 搜索栏 + searchBar + + // 内容 + if viewModel.isSearching { + Spacer() + ProgressView("搜索中...") + .tint(.orange) + Spacer() + } else if !viewModel.results.isEmpty { + searchResults + } else if viewModel.keyword.isEmpty { + searchHistorySection + } else if let error = viewModel.errorMessage { + Spacer() + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.largeTitle) + .foregroundColor(.gray) + Text(error) + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + } + .background(Color(red: 0.08, green: 0.08, blue: 0.1)) + .navigationTitle("搜索") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + } + + // MARK: - 搜索栏 + + private var searchBar: some View { + HStack(spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white.opacity(0.8)) + + TextField("搜索影片...", text: $viewModel.keyword) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .foregroundColor(.white) + .submitLabel(.search) + .onSubmit { + Task { await viewModel.search() } + } + #if os(iOS) + .autocapitalization(.none) + #endif + + if !viewModel.keyword.isEmpty { + Button { + withAnimation { + viewModel.keyword = "" + viewModel.results = [] + } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.white.opacity(0.4)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white.opacity(0.05)) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(LinearGradient(colors: [.orange.opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 1) + ) + + Button { + Task { await viewModel.search() } + } label: { + Text("搜索") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(LinearGradient(colors: [.orange, .red], startPoint: .leading, endPoint: .trailing)) + .cornerRadius(14) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 10) + } + + // MARK: - 搜索结果 + + private var searchResults: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(viewModel.results) { video in + NavigationLink(value: video) { + VodCardView(video: video) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + .navigationDestination(for: Movie.Video.self) { video in + DetailView(video: video) + } + } + + // MARK: - 搜索历史 + + private var searchHistorySection: some View { + VStack(alignment: .leading, spacing: 12) { + if !viewModel.searchHistory.isEmpty { + HStack { + Text("搜索历史") + .font(.headline) + .foregroundColor(.white) + Spacer() + Button { + viewModel.clearHistory() + } label: { + HStack(spacing: 4) { + Image(systemName: "trash") + Text("清空") + } + .font(.caption) + .foregroundColor(.gray) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + + FlowLayout(spacing: 8) { + ForEach(viewModel.searchHistory, id: \.self) { keyword in + Button { + viewModel.keyword = keyword + Task { await viewModel.search() } + } label: { + Text(keyword) + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.white.opacity(0.1)) + .cornerRadius(16) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + } + + Spacer() + } + } +} + +/// 流式布局 +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = arrangement(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews) + for (index, position) in result.positions.enumerated() { + subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified) + } + } + + private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) { + let maxWidth = proposal.width ?? .infinity + var positions: [CGPoint] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxX: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > maxWidth && currentX > 0 { + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + positions.append(CGPoint(x: currentX, y: currentY)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + spacing + maxX = max(maxX, currentX) + } + + return (CGSize(width: maxX, height: currentY + lineHeight), positions) + } +} diff --git a/tvbox/Views/Settings/SettingsView.swift b/tvbox/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..a8acf8e --- /dev/null +++ b/tvbox/Views/Settings/SettingsView.swift @@ -0,0 +1,478 @@ +import SwiftUI + +/// 设置页 - 对应 Android 版 SettingActivity + ModelSettingFragment +struct SettingsView: View { + @StateObject private var viewModel = SettingsViewModel() + @StateObject private var apiConfig = ApiConfig.shared + @EnvironmentObject var appState: AppState + @State private var showApiInput = false + @State private var showAbout = false + @State private var sourceSearchText = "" + @State private var showingPicker: PickerType = .none + + enum PickerType { + case none + case player + case decode + case vlcBuffer + case playTimeStep + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + // API 配置 + SectionCard(title: "数据源") { + SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) { + showApiInput = true + } + Divider().background(Color.white.opacity(0.1)) + if !apiConfig.sourceBeanList.isEmpty { + NavigationLink { + sourcePickerView + } label: { + SettingsRow(icon: "server.rack", title: "主页数据源", value: apiConfig.homeSourceBean?.name ?? "", action: nil) + } + } + } + + // 播放设置 + SectionCard(title: "播放设置") { + SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) { + if viewModel.playerEngineOptions.count > 1 { + showingPicker = .player + } + } + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "cpu", title: "视频解码", value: viewModel.decodeMode.title) { + showingPicker = .decode + } + if PlayerEngine.isVLCAvailable { + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "externaldrive.badge.wifi", title: "VLC缓冲", value: viewModel.vlcBufferMode.title) { + showingPicker = .vlcBuffer + } + } + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "forward", title: "快进步长", value: "\(viewModel.playTimeStep)秒") { + showingPicker = .playTimeStep + } + } + + // 功能 + SectionCard(title: "功能") { + NavigationLink { + HistoryView() + } label: { + SettingsRow(icon: "clock", title: "播放历史", value: "", action: nil) + } + Divider().background(Color.white.opacity(0.1)) + NavigationLink { + FavoritesView() + } label: { + SettingsRow(icon: "heart", title: "我的收藏", value: "", action: nil) + } + } + + // 缓存 + SectionCard(title: "缓存") { + SettingsRow(icon: "trash", title: "清除缓存", value: viewModel.cacheSizeString) { + viewModel.clearCache() + } + } + + // 关于 + SectionCard(title: "关于") { + SettingsRow(icon: "info.circle", title: "版本", value: "1.0.0", action: nil) + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "globe", title: "站点数量", value: "\(apiConfig.sourceBeanList.count)", action: nil) + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "wand.and.stars", title: "解析数量", value: "\(apiConfig.parseBeanList.count)", action: nil) + Divider().background(Color.white.opacity(0.1)) + SettingsRow(icon: "tv", title: "直播分组", value: "\(apiConfig.liveChannelGroupList.count)", action: nil) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + .background(AppTheme.primaryGradient.ignoresSafeArea()) + .navigationTitle("设置") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + #endif + .sheet(isPresented: $showApiInput) { + apiInputSheet + } + } + .overlay(pickerOverlay) + } + + // MARK: - 选择器 Overlay + + @ViewBuilder + private var pickerOverlay: some View { + switch showingPicker { + case .player: + SelectionModal( + title: "选择播放器", + icon: "play.rectangle.fill", + items: viewModel.playerEngineOptions, + selectedItem: viewModel.playerEngine, + itemTitle: { $0.title }, + onSelect: { engine in + viewModel.setPlayerEngine(engine) + showingPicker = .none + }, + onCancel: { showingPicker = .none } + ) + case .decode: + SelectionModal( + title: "视频解码模式", + icon: "cpu.fill", + items: viewModel.decodeModeOptions, + selectedItem: viewModel.decodeMode, + itemTitle: { $0.title }, + onSelect: { mode in + viewModel.setDecodeMode(mode) + showingPicker = .none + }, + onCancel: { showingPicker = .none } + ) + case .vlcBuffer: + SelectionModal( + title: "VLC 缓冲策略", + icon: "externaldrive.fill", + items: viewModel.vlcBufferModeOptions, + selectedItem: viewModel.vlcBufferMode, + itemTitle: { $0.title }, + onSelect: { mode in + viewModel.setVLCBufferMode(mode) + showingPicker = .none + }, + onCancel: { showingPicker = .none } + ) + case .playTimeStep: + SelectionModal( + title: "快进步长", + icon: "forward.fill", + items: viewModel.playTimeStepOptions, + selectedItem: viewModel.playTimeStep, + itemTitle: { "\($0) 秒" }, + onSelect: { step in + viewModel.setPlayTimeStep(step) + showingPicker = .none + }, + onCancel: { showingPicker = .none } + ) + case .none: + EmptyView() + } + } + + // MARK: - API 输入弹窗 + + private var apiInputSheet: some View { + NavigationStack { + VStack(spacing: 16) { + HStack { + Image(systemName: "link") + .foregroundColor(.secondary) + TextField("请输入接口地址", text: $viewModel.apiUrl) + .textFieldStyle(.plain) + #if os(iOS) + .autocapitalization(.none) + .keyboardType(.URL) + #endif + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(10) + + // 粘贴按钮 + HStack { + Button { + #if os(iOS) + if let text = UIPasteboard.general.string { + viewModel.apiUrl = text + } + #else + if let text = NSPasteboard.general.string(forType: .string) { + viewModel.apiUrl = text + } + #endif + } label: { + Label("粘贴", systemImage: "doc.on.clipboard") + .font(.subheadline) + } + + Spacer() + } + + // 历史记录 + if !viewModel.apiHistory.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("历史记录") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(viewModel.apiHistory, id: \.self) { url in + HStack { + Button { + viewModel.apiUrl = url + } label: { + HStack { + Image(systemName: "clock") + .font(.caption) + Text(url) + .font(.caption) + .lineLimit(1) + } + .foregroundColor(.secondary) + } + + Spacer() + + Button { + viewModel.removeApiHistory(url) + } label: { + Image(systemName: "xmark.circle") + .font(.caption) + .foregroundColor(.gray) + } + } + } + } + } + + if let error = viewModel.configError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + Spacer() + } + .padding() + .navigationTitle("配置接口") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { showApiInput = false } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await viewModel.loadConfig() + if viewModel.configSuccess { + await appState.loadConfig(url: viewModel.apiUrl) + showApiInput = false + } + } + } label: { + if viewModel.isLoadingConfig { + ProgressView() + } else { + Text("确认") + } + } + .disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty) + } + } + } + #if os(iOS) + .presentationDetents([.medium, .large]) + #endif + } + + // MARK: - 源选择 + + private var filteredSources: [SourceBean] { + let sources = apiConfig.sourceBeanList + if sourceSearchText.isEmpty { + return sources + } else { + return sources.filter { $0.name.localizedCaseInsensitiveContains(sourceSearchText) || $0.api.localizedCaseInsensitiveContains(sourceSearchText) } + } + } + + private var sourcePickerView: some View { + VStack(spacing: 0) { + // 搜索栏 + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("搜索数据源", text: $sourceSearchText) + .textFieldStyle(.plain) + if !sourceSearchText.isEmpty { + Button(action: { sourceSearchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(12) + .glassCard(cornerRadius: 12) + .padding(.horizontal, 20) + .padding(.vertical, 12) + + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredSources) { source in + Button { + apiConfig.setHomeSource(source) + appState.currentSourceKey = source.key + } label: { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(source.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(source.isSupportedInSwift ? .white : .white.opacity(0.5)) + + // 类型标签 + Text(source.typeDescription) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(source.isSupportedInSwift ? .orange : .gray) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule().fill( + source.isSupportedInSwift ? Color.orange.opacity(0.2) : Color.gray.opacity(0.2) + ) + ) + + if !source.isSupportedInSwift { + Text("暂不支持") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.red.opacity(0.8)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.red.opacity(0.15))) + } + } + + Text(source.api) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + } + + Spacer() + + HStack(spacing: 12) { + if source.isSearchable { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.green.opacity(0.8)) + } + + if source.key == apiConfig.homeSourceBean?.key { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.orange) + } else { + Circle() + .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) + .frame(width: 20, height: 20) + } + } + } + .padding(16) + .glassCard(cornerRadius: 16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + source.key == apiConfig.homeSourceBean?.key ? Color.orange.opacity(0.5) : Color.clear, + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + .background(AppTheme.primaryGradient.ignoresSafeArea()) + .navigationTitle("选择数据源") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } +} + +// MARK: - 辅助组件 + +struct SectionCard: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(.white.opacity(0.6)) + .padding(.leading, 8) + + VStack(spacing: 0) { + content() + } + .glassCard(cornerRadius: 16) + } + } +} + +struct SettingsRow: View { + let icon: String + let title: String + let value: String + let action: (() -> Void)? + + var body: some View { + Group { + if let action = action { + Button(action: action) { + rowContent + } + .buttonStyle(.plain) + } else { + rowContent + } + } + } + + private var rowContent: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(.orange) + .frame(width: 24) + + Text(title) + .font(.body) + .foregroundColor(.white.opacity(0.9)) + + Spacer() + + Text(value) + .font(.subheadline) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white.opacity(0.3)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } +} diff --git a/tvbox/tvboxApp.swift b/tvbox/tvboxApp.swift new file mode 100644 index 0000000..ccbea4b --- /dev/null +++ b/tvbox/tvboxApp.swift @@ -0,0 +1,70 @@ +import SwiftUI +import SwiftData + +@main +struct tvboxApp: App { + @StateObject private var appState = AppState() + + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + VodCollect.self, + VodRecord.self, + CacheItem.self + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + .modelContainer(sharedModelContainer) + #if os(macOS) + .defaultSize(width: 1200, height: 800) + #endif + } +} + +@MainActor +class AppState: ObservableObject { + @Published var apiConfig = ApiConfig.shared + @Published var isConfigLoaded = false + @Published var currentSourceKey: String = "" + #if os(macOS) + @Published var splitViewVisibility: NavigationSplitViewVisibility = .all + private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility? + #endif + + func loadConfig(url: String) async { + do { + try await ApiConfig.shared.loadConfig(from: url) + await MainActor.run { + self.isConfigLoaded = true + self.currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" + } + } catch { + print("Failed to load config: \(error)") + } + } + + #if os(macOS) + func enterPlayerFullScreen() { + if splitViewVisibilityBeforePlayerFullScreen == nil { + splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility + } + splitViewVisibility = .detailOnly + } + + func exitPlayerFullScreen() { + guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return } + splitViewVisibility = previous + splitViewVisibilityBeforePlayerFullScreen = nil + } + #endif +}