first commit

This commit is contained in:
JinJiangHuang
2026-03-01 14:04:44 +08:00
commit 61e97da938
62 changed files with 9947 additions and 0 deletions
+11
View File
@@ -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
+21
View File
@@ -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.
+146
View File
@@ -0,0 +1,146 @@
# TVBox-Swift
![Platform iOS | macOS](https://img.shields.io/badge/Platform-iOS%20%7C%20macOS-blue.svg)
![Swift Version](https://img.shields.io/badge/Swift-5.9-orange.svg)
![Xcode Version](https://img.shields.io/badge/Xcode-15.0+-blue.svg)
**TVBox-Swift** 是一套基于原生 Swift & SwiftUI 构建的跨平台多媒体应用核心工程。它同时支持 macOS 和 iOS 设备,致力于在双端提供无缝、现代且极其流畅的视听体验。
## ✨ 核心特性
- 🎯 **真正的原生跨平台 (Native Cross-platform)**
- 基于最新的 SwiftUI 和 SwiftData,一套代码覆盖 macOS (14.0+) 与 iOS (17.0+)。
- 高度针对多端设备的特定交互形式进行原生级适配与优化。
- 🎨 **前沿的 UI 视觉 (Modern UI & Glassmorphism)**
- 全新的毛玻璃 (Glassmorphism) 视觉设计体系。
- 精美流畅的重构动画与微交互,从列表到播放页全系更新。
- 原生级体验的暗黑模式 (Dark Mode) 全方位支持。
- 🧩 **全方位的媒体功能栈 (Media Stack)**
- **点播中心 (VOD Center):** 整合了智能资源检索、卡片化视觉呈现与多级详情页。
- *生态支持:* 原生支持解析主流的 CMS JSON/XML 数据源接口 (`type=0`, `type=1`)。
- *暂不支持:* 由于 iOS/macOS 平台原生限制与环境安全,内核未集成基于 Java 引擎或 Rhino 逻辑层,**不支持** `type=3` (Spider / Jar) 等依赖动态脚本执行的复杂爬虫源。
- **直播支持 (Live):** 稳定的流媒体播放基础构件支持。
- **内容枢纽 (Content Hub):** 便捷的搜索、智能历史记录流、一键收藏管理。
- ⚙️ **健壮的业务底层 (Robust Foundation)**
- **持久化:** 基于苹果全新 `SwiftData` 技术的现代化缓存与本地数据存储模块。
- **数据层:** 兼容性极强的网络接口解析引擎,支持非标准的 JSON/XML 接口容错处理与数据清洗。
## 🛠 技术栈
* **语言**: Swift 5.9
* **UI 框架**: SwiftUI
* **数据持久化**: SwiftData
* **工程化管理**: XcodeGen (>= 2.35)
---
## 🔍 与安卓参考版差异
以下差异基于当前仓库代码:
1. **源类型支持范围不同**
- Swift 版支持 `type=0/1/4`XML/JSON/Remote)。
- Swift 版暂不支持 `type=3 (JAR/Spider)` 动态脚本源。
2. **配置兼容能力不同**
- 安卓版支持加密配置解密、`clan://`、相对路径修复、独立直播配置加载等。
- Swift 版当前为常规 URL 拉取 + 注释容错解析。
3. **解析链路能力不同**
- 安卓版支持嗅探/解析切换、规则引擎与相关过滤策略。
- Swift 版当前仅解析并展示解析器配置数量,未接入完整播放解析链路。
4. **播放器能力不同**
- 安卓版支持系统/IJK/EXO/外部播放器等多模式。
- Swift 版当前支持系统 `AVPlayer``VLCKit`iOS/macOS)双引擎切换,IJK/EXO/外部播放器仍未接入。
5. **字幕体系不同**
- 安卓版支持字幕搜索、本地字幕、样式/延迟调节、内嵌轨道等。
- Swift 版当前尚未接入完整字幕功能。
6. **直播高级能力不同**
- 安卓版支持 EPG、回看/时移、分组密码、直播设置分组等。
- Swift 版当前已支持基础直播分组与线路切换,EPG 仍为简化实现。
7. **筛选与检索形态不同**
- 安卓版具备分类筛选、快速搜索、复选搜索等界面与交互。
- Swift 版当前为基础搜索与分类浏览。
8. **远程控制与推送不同**
- 安卓版具备本地控制服务与推送/遥控相关能力。
- Swift 版当前未提供该能力。
9. **设置项覆盖度不同**
- 安卓版设置项更细(DoH、渲染/缩放、历史条数、M3U8 净化、备份恢复等)。
- Swift 版当前保留核心常用设置。
10. **收藏入口闭环不同**
- 安卓版详情页支持直接加入/取消收藏。
- Swift 版目前提供收藏页与收藏数据模型,但详情页收藏入口待完善。
---
## 🚀 快速开始
本项目的主 Xcode 工程文件并未直接包含在仓库代码中,以保证干净的源码树并降低拉取冲突风险。您需要通过 `XcodeGen` 生成配置文件。
### 1. 环境准备
确保您的设备已安装以下必备组件:
* **macOS** (最新版本)
* **Xcode 15.0** 或以上版本
* **Homebrew** (用于安装命令行工具)
安装工程生成器 `XcodeGen`:
```bash
brew install xcodegen
```
### 2. 生成 Xcode 工程
在终端进入本项目的根目录,执行以下命令以生成 `tvbox.xcodeproj` 文件:
```bash
xcodegen generate
```
### 3. 构建与运行
打开刚刚使用 XcodeGen 生成的 `tvbox.xcodeproj`
- 选择合适的 Scheme (`tvbox` 对应 iOS, `tvbox-macOS` 对应 macOS)。
- 直接在设备或模拟器真机上 `Command + R` 进行编译与运行调试。
---
## 📦 macOS 独立打包
项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。
```bash
chmod +x package_mac.sh
./package_mac.sh
```
> **成功输出提示:** `✅ 打包完成!生成文件: TVBox-macOS.dmg`
---
## 📂 简明目录结构
```text
├── tvbox
│ ├── Models/ # 数据模型定义
│ ├── ViewModels/ # 业务逻辑与状态管理 (MVVM)
│ ├── Views/ # 所有的 SwiftUI 原生视图组件
│ ├── Services/ # 网络层、配置解析层等外部服务交互
│ ├── Persistence/ # 数据库 (SwiftData) 配置与读写管理
│ ├── Utils/ # 通用的帮助类与扩展函数
│ └── Assets.xcassets # 资源文件集、图标与预设全局色彩 (AccentColor)
├── project.yml # XcodeGen 的工程模板描述文件
├── package_mac.sh # macOS DMG 打包脚本
└── process_icon.py # 图片转换脚本套件
```
---
## 📜 开源协议
本项目采用 [MIT License](./LICENSE) 开源发布。
---
## ⚠️ 开源免责声明 (Disclaimer)
**本项目仅供编程技术交流、学习 Swift 与 SwiftUI 原生应用开发原理之目的。**
1. 开发者不对本项目代码产生的任何直接或间接后果负责。
2. 本项目**不提供、不内置、不分发**任何形式的具体影视资源、播放源配置接口(如接口 URL、JSON 规则等)。
3. 使用本项目所导致的所有合法性与知识产权纠纷(包括但不限于使用者自行导入第三方规则/接口而引发的版权问题),由使用者自行承担全部法律责任。
4. 本应用旨在作为一个本地多媒体客户端工具框架进行技术探讨,任何以此框架衍生的商业化产品与违法行为均与原作者无关。如果您下载、安装、编译或使用了本项目,即代表您理解并完全接受本免责声明之所有条款。
BIN
View File
Binary file not shown.
Executable
+35
View File
@@ -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"
+83
View File
@@ -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"
+789
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+11
View File
@@ -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>
+216
View File
@@ -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]?
}
}
+72
View File
@@ -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
}
+82
View File
@@ -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)) ?? ""
}
}
}
+36
View File
@@ -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 = "" //
}
}
}
+133
View File
@@ -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)
}
}
}
+52
View File
@@ -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://")
}
}
+85
View File
@@ -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]
}
}
+192
View File
@@ -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 = "" // JSONVodPlaybackState
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)
}
}
+427
View File
@@ -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)
// JSONAndroid 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)"
}
}
}
+78
View File
@@ -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)"
}
}
}
+417
View File
@@ -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)"
}
}
}
+597
View File
@@ -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 }
}
+459
View File
@@ -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
}
}
+125
View File
@@ -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)
}
}
}
+91
View File
@@ -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
}
}
+82
View File
@@ -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)
}
}
+134
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
}
+279
View File
@@ -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)
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
+588
View File
@@ -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()
}
}
}
}
+98
View File
@@ -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)
}
}
}
+89
View File
@@ -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)
}
}
+121
View File
@@ -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)
}
}
+349
View File
@@ -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)
}
}
}
+80
View File
@@ -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))
)
}
}
+541
View File
@@ -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
}
+739
View File
@@ -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
+1752
View File
@@ -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
+223
View File
@@ -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)
}
}
+478
View File
@@ -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())
}
}
+70
View File
@@ -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
}