first commit
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
# TVBox-Swift
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
**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. 本应用旨在作为一个本地多媒体客户端工具框架进行技术探讨,任何以此框架衍生的商业化产品与违法行为均与原作者无关。如果您下载、安装、编译或使用了本项目,即代表您理解并完全接受本免责声明之所有条款。
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = "<group>"; };
|
||||
0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; };
|
||||
06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
|
||||
0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||
131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
|
||||
21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = "<group>"; };
|
||||
2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = "<group>"; };
|
||||
617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = "<group>"; };
|
||||
6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = "<group>"; };
|
||||
6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
||||
6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
|
||||
90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = "<group>"; };
|
||||
B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = "<group>"; };
|
||||
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
|
||||
B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListView.swift; sourceTree = "<group>"; };
|
||||
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = "<group>"; };
|
||||
BFD32454522C32D876921A30 /* CacheStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheStore.swift; sourceTree = "<group>"; };
|
||||
CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveModels.swift; sourceTree = "<group>"; };
|
||||
D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = "<group>"; };
|
||||
D5DFDD8C597739AD4B443C03 /* SourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceService.swift; sourceTree = "<group>"; };
|
||||
D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiConfig.swift; sourceTree = "<group>"; };
|
||||
D791457269BE952C496ECE55 /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = "<group>"; };
|
||||
DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
|
||||
DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
22CB65DE627DB94747387944 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */,
|
||||
D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */,
|
||||
D5DFDD8C597739AD4B443C03 /* SourceService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2990EE415D2440DE11757F52 /* Player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */,
|
||||
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */,
|
||||
);
|
||||
path = Player;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2B5161C9D7D5ECC250A7FFC0 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8AAD980DF9D6ABDD040AC805 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
395C32C174B961E32AF81E4F /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10969CAA236B75C35062BCE /* SettingsView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
619FDAACD7BFF9CA03E84145 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8B5798117EA20DF680EFD3CA /* tvbox */,
|
||||
AF26E3B8CC541C336E1ECDDB /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6B3F725811AE256C79413905 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
837A55F21477081BDA8D1C05 /* Persistence */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BFD32454522C32D876921A30 /* CacheStore.swift */,
|
||||
);
|
||||
path = Persistence;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8798AB58636EE4E2C7410D3E /* Detail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
131429F267F6A418C282831A /* DetailView.swift */,
|
||||
B9D5D580A3FABBCD5EE1F9A0 /* EpisodeListView.swift */,
|
||||
);
|
||||
path = Detail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8AEC948634E120213D3731BD /* Favorites */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
06CD8B07440DBB1EF2807795 /* FavoritesView.swift */,
|
||||
);
|
||||
path = Favorites;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
AF26E3B8CC541C336E1ECDDB /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */,
|
||||
E5234A75D14EAE9141633FCA /* tvbox.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D3F1B37CB4D7643D7F7639CE /* Live */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */,
|
||||
);
|
||||
path = Live;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1FD870365F6D4FD67730DBD /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */,
|
||||
2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */,
|
||||
21306B53D5CA8BB88593A55D /* LiveViewModel.swift */,
|
||||
0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */,
|
||||
0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD7DFF54835B377CB9BF5A82 /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
90F26F870D1B587C1292A189 /* HomeView.swift */,
|
||||
F905D1244D5DE13DBCEE170F /* VodCardView.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FF7A64851C810E1636763FE2 /* History */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DE52456C613016065CF59F9C /* HistoryView.swift */,
|
||||
);
|
||||
path = History;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 746 B |
|
After Width: | Height: | Size: 746 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 1008 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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]?
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)) ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "" // 值
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(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<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(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<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(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<VodRecord> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodRecord>(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)
|
||||
}
|
||||
}
|
||||
@@ -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..<chars.count {
|
||||
let c = chars[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if c == "\\" && inString {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if c == "\"" {
|
||||
inString.toggle()
|
||||
continue
|
||||
}
|
||||
if !inString && c == "/" && i + 1 < chars.count && chars[i + 1] == "/" {
|
||||
// 找到行内注释,截断
|
||||
return String(chars[0..<i]).trimmingCharacters(in: .whitespaces).hasSuffix(",")
|
||||
? String(String(chars[0..<i]).trimmingCharacters(in: .whitespaces).dropLast())
|
||||
: String(chars[0..<i])
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/// 解析配置数据
|
||||
private func parseConfig(_ config: AppConfigData, apiUrl: String) {
|
||||
// 解析站点列表
|
||||
var sources: [SourceBean] = []
|
||||
if let sites = config.sites {
|
||||
for site in sites {
|
||||
let bean = SourceBean(
|
||||
key: site.key ?? UUID().uuidString,
|
||||
name: site.name ?? "未命名",
|
||||
api: site.api ?? "",
|
||||
searchable: site.searchable?.value ?? 1,
|
||||
filterable: site.filterable?.value ?? 1,
|
||||
playerType: site.playerType?.value ?? 0,
|
||||
type: site.type?.value ?? 1,
|
||||
ext: site.ext?.stringValue
|
||||
)
|
||||
sources.append(bean)
|
||||
}
|
||||
}
|
||||
self.sourceBeanList = sources
|
||||
|
||||
// 设置默认主页源:优先选择 Swift 支持的源
|
||||
if let saved = UserDefaults.standard.string(forKey: HawkConfig.HOME_API),
|
||||
let found = sources.first(where: { $0.key == saved }) {
|
||||
self.homeSourceBean = found
|
||||
} else {
|
||||
// 优先选择支持的源(type 0/1/4),跳过 type=3 (JAR)
|
||||
self.homeSourceBean = sources.first(where: { $0.isSupportedInSwift }) ?? sources.first
|
||||
}
|
||||
|
||||
// 解析解析器列表
|
||||
if let parses = config.parses {
|
||||
self.parseBeanList = parses.map { p in
|
||||
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 DoH 列表
|
||||
if let dohs = config.doh {
|
||||
self.dohList = dohs.compactMap { d in
|
||||
guard let name = d.name, let url = d.url else { return nil }
|
||||
return (name: name, url: url)
|
||||
}
|
||||
}
|
||||
|
||||
// 壁纸
|
||||
self.wallpaper = config.wallpaper ?? ""
|
||||
|
||||
// 解析直播源
|
||||
if let lives = config.lives {
|
||||
parseLives(lives, apiUrl: apiUrl)
|
||||
}
|
||||
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
/// 解析直播列表
|
||||
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) {
|
||||
Task {
|
||||
var mergedGroups: [String: LiveChannelGroup] = [:]
|
||||
|
||||
for live in lives {
|
||||
// 如果有 url,从远程加载
|
||||
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
|
||||
do {
|
||||
let content = try await network.getString(from: resolvedUrl)
|
||||
let groups = parseLiveContent(content)
|
||||
mergeLiveGroups(groups, into: &mergedGroups)
|
||||
} catch {
|
||||
print("加载直播源失败: \(resolvedUrl), error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有内嵌频道
|
||||
if let channels = live.channels {
|
||||
let inlineGroups = parseInlineLiveChannels(channels)
|
||||
mergeLiveGroups(inlineGroups, into: &mergedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
self.liveChannelGroupList = sortedGroups(from: mergedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 m3u / txt 格式的直播内容
|
||||
private func parseLiveContent(_ content: String) -> [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[..<endQuote])
|
||||
}
|
||||
}
|
||||
} else if Self.isLiveStreamUrl(trimmed) {
|
||||
if !currentName.isEmpty {
|
||||
appendChannel(
|
||||
named: currentName,
|
||||
urls: [trimmed],
|
||||
logo: "",
|
||||
to: currentGroup,
|
||||
groups: &groups
|
||||
)
|
||||
currentName = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TXT 格式: 分组名,#genre# 或 频道名,url
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
|
||||
if trimmed.hasSuffix(",#genre#") || trimmed.hasSuffix(",#genre#") {
|
||||
currentGroupName = trimmed
|
||||
.replacingOccurrences(of: ",#genre#", with: "")
|
||||
.replacingOccurrences(of: ",#genre#", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
continue
|
||||
}
|
||||
|
||||
let parts = trimmed.components(separatedBy: ",")
|
||||
if parts.count >= 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<String> = []
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "<ty id=\"(\\d+)\"[^>]*>([^<]+)</ty>"
|
||||
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 = "<video>.*?<id>(\\d+)</id>.*?<name><!\\[CDATA\\[(.+?)\\]\\]></name>.*?<pic>(.*?)</pic>.*?<note><!\\[CDATA\\[(.*?)\\]\\]></note>.*?</video>"
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View, Placeholder: View>: 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<NSURL, PlatformImage>()
|
||||
|
||||
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<Item: Identifiable & Equatable>: 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 }
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..<end])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// 分组选择
|
||||
if groupCount > 1 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<groupCount, id: \.self) { group in
|
||||
let start = group * groupSize + 1
|
||||
let end = min((group + 1) * groupSize, episodes.count)
|
||||
Button {
|
||||
withAnimation {
|
||||
currentGroup = group
|
||||
}
|
||||
} label: {
|
||||
Text("\(start)-\(end)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(currentGroup == group ? .white : .white.opacity(0.5))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
ZStack {
|
||||
if currentGroup == group {
|
||||
Capsule().fill(Color.white.opacity(0.15))
|
||||
} else {
|
||||
Capsule().fill(Color.white.opacity(0.05))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集网格
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: [
|
||||
GridItem(.fixed(44)),
|
||||
GridItem(.fixed(44))
|
||||
], spacing: 10) {
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
onSelect(actualIndex)
|
||||
} label: {
|
||||
Text(episode.name)
|
||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||
.frame(minWidth: 70)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
if actualIndex == selectedIndex {
|
||||
AppTheme.accentGradient
|
||||
} else {
|
||||
Color.white.opacity(0.05)
|
||||
}
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(height: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 收藏页 - 对应 Android 版 CollectActivity
|
||||
struct FavoritesView: View {
|
||||
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
||||
private var favorites: [VodCollect]
|
||||
@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 favorites.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
favoriteCard(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("取消收藏", systemImage: "heart.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
||||
.navigationTitle("收藏")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "heart.text.square",
|
||||
title: "暂无收藏",
|
||||
message: "遇到喜欢的影片别忘了点下收藏按钮哦!"
|
||||
)
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
private func favoriteCard(_ item: VodCollect) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Int> = []
|
||||
@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..<totalSources {
|
||||
viewModel.switchSource()
|
||||
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
||||
if !failedSourceIndices.contains(nextIndex) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func toggleWindowFullScreen() {
|
||||
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||||
let enteringFullScreen = !window.styleMask.contains(.fullScreen)
|
||||
if enteringFullScreen {
|
||||
isWindowFullScreen = true
|
||||
appState.enterPlayerFullScreen()
|
||||
}
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
#else
|
||||
private func toggleWindowFullScreen() {}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// 跨平台播放器:macOS 使用 AVPlayerView,避免 SwiftUI.VideoPlayer 在 macOS 的崩溃问题
|
||||
struct PlatformVideoPlayer: View {
|
||||
let player: AVPlayer
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
MacOSPlayerView(player: player)
|
||||
#else
|
||||
VideoPlayer(player: player)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private struct MacOSPlayerView: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> 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
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>: 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||