36 changed files with 1665 additions and 283 deletions
+1 -1
View File
@@ -7,5 +7,5 @@ DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
/build
TVBox.dmg
TVBox-*.dmg
*.py
+16
View File
@@ -0,0 +1,16 @@
<?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>method</key>
<string>development</string> <!-- 可选: ad-hoc, enterprise, app-store, development -->
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string></string> <!-- 此处填写您的 Apple Team ID -->
<key>thinning</key>
<string>&lt;none&gt;</string>
</dict>
</plist>
+17
View File
@@ -99,6 +99,23 @@ xcodegen generate
---
### iOS 版本打包
1. 确保已安装通用依赖:
- Xcode 15+
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (可选,用于生成工程)
2. 运行打包脚本:
```bash
chmod +x package_ios.sh
./package_ios.sh
```
3. **注意**
- 脚本默认生成 `.xcarchive` 文件。
- 如需自动生成 `.ipa`,请在 `ExportOptions.plist` 中填写您的 `teamID`。
- 您也可以在 Xcode 中打开 `tvbox.xcodeproj`,选择 `tvbox` scheme,然后点击 `Product > Archive` 手动打包。
## 📦 macOS 独立打包
项目已内置自动化打包脚本 `package_mac.sh`。当您需要分发 macOS 版本时,直接运行即可自动清理缓存、构建并生成免安装的 `.dmg` 拖拽式安装包。
BIN
View File
Binary file not shown.
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
set -e
# 设置工作目录为脚本所在目录
cd "$(dirname "$0")"
echo "清理构建目录..."
rm -rf build
rm -rf tvbox.xcarchive
rm -f TVBox.ipa
SCHEME="tvbox"
CONFIGURATION="Release"
ARCHIVE_PATH="build/tvbox.xcarchive"
EXPORT_PATH="build/exported"
EXPORT_OPTIONS="ExportOptions.plist"
echo "开始构建 iOS Archive..."
xcodebuild archive \
-project tvbox.xcodeproj \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH"
if [ -f "$EXPORT_OPTIONS" ]; then
echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..."
mkdir -p "$EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-exportPath "$EXPORT_PATH" \
-allowProvisioningUpdates
# 查找生成的 .ipa 文件并移动到根目录
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -n 1)
if [ -n "$IPA_FILE" ]; then
cp "$IPA_FILE" ./TVBox.ipa
echo "✅ 打包完成!生成文件: TVBox.ipa"
else
echo "⚠️ 未能在导出目录中找到 .ipa 文件。"
fi
else
echo "️ 未找到 $EXPORT_OPTIONS,仅生成 Archive。"
echo "✅ 构建完成!Archive 路径: $ARCHIVE_PATH"
echo "您可以打开 Xcode 使用 Distribute App 手动导出 IPA。"
fi
+28
View File
@@ -214,3 +214,31 @@ struct AppConfigData: Codable {
var script: [String]?
}
}
extension AppConfigData {
///
/// AppConfigData JSON
///
var hasUsableContent: Bool {
let hasSites = !(sites?.isEmpty ?? true)
let hasLives = !(lives?.isEmpty ?? true)
let hasParses = !(parses?.isEmpty ?? true)
return hasSites || hasLives || hasParses
}
}
/// tvboxmulti.json
struct MultiRepoConfigData: Codable {
var urls: [Entry]?
struct Entry: Codable {
var name: String?
var url: String?
}
var candidateUrls: [String] {
(urls ?? [])
.compactMap { $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
+22 -2
View File
@@ -4,10 +4,15 @@ import Foundation
///
struct LiveChannelGroup: Codable, Identifiable, Hashable {
/// 便 SwiftUI diff
var id: String { groupName }
///
var groupName: String = ""
///
var groupIndex: Int = 0
///
var channels: [LiveChannelItem] = []
///
var isPassword: Bool = false
init(groupName: String = "", groupIndex: Int = 0) {
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
///
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) {
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
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
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
var endTime: String = ""
var index: Int = 0
/// `HH:mm`
var isLive: Bool {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
/// EPG
struct LiveEpgDate: Codable, Identifiable, Hashable {
var id: String { datePresent }
var datePresent: String = "" //
var date: String = "" //
/// UI
var datePresent: String = ""
///
var date: String = ""
///
var index: Int = 0
/// UI
var isSelected: Bool = false
}
+27 -7
View File
@@ -2,28 +2,47 @@ 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 {
/// ID Int String
var id: String
///
var name: String = ""
///
var pic: String = ""
var note: String = "" // "20"
/// 20
var note: String = ""
///
var year: String = ""
///
var area: String = ""
var type: 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 = "" //
///
var des: String = ""
/// key
var sourceKey: String = ""
/// ID
var tid: String = ""
///
var last: String = ""
///
var dt: String = ""
init(id: String = UUID().uuidString, name: String = "", pic: String = "",
note: String = "", sourceKey: String = "") {
@@ -51,6 +70,7 @@ struct Movie: Codable {
case sourceKey
}
/// `vod_id` / `type_id` Int String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// String Int id
+14 -2
View File
@@ -2,13 +2,18 @@ import Foundation
/// - Android MovieSort.java
struct MovieSort: Codable {
///
var sortList: [SortData] = []
///
struct SortData: Codable, Identifiable, Hashable {
/// type_id
var id: String
///
var name: String = ""
/// /
var flag: String = ""
///
var filters: [SortFilter] = []
init(id: String = "", name: String = "", flag: String = "") {
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
self.flag = flag
}
///
///
static func home() -> SortData {
SortData(id: "home", name: "推荐", flag: "1")
}
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
///
struct SortFilter: Codable, Hashable {
/// `year``area`
var key: String = ""
/// UI
var name: String = ""
///
var values: [SortFilterValue] = []
struct SortFilterValue: Codable, Hashable {
var n: String = "" //
var v: String = "" //
///
var n: String = ""
///
var v: String = ""
}
}
}
+20 -2
View File
@@ -2,11 +2,14 @@ import Foundation
///
enum PlayerEngine: Int, CaseIterable, Identifiable {
/// AVPlayer
case system = 0
/// VLC VLCKitSPM
case vlc = 10
var id: Int { rawValue }
/// UI
var title: String {
switch self {
case .system:
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
}
}
/// VLC
static var isVLCAvailable: Bool {
#if canImport(VLCKitSPM)
return true
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
#endif
}
///
/// VLC
static var availableEngines: [PlayerEngine] {
var engines: [PlayerEngine] = [.system]
if isVLCAvailable {
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
return engines
}
///
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
guard let engine = PlayerEngine(rawValue: rawValue) else {
return .system
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
///
enum VideoDecodeMode: Int, CaseIterable, Identifiable {
///
case auto = 0
///
case hardware = 1
///
case software = 2
var id: Int { rawValue }
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
}
/// VLC
/// `avcodec-hw`
var vlcHardwareDecodeOption: String? {
switch self {
case .auto:
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
/// VLC
enum VLCBufferMode: Int, CaseIterable, Identifiable {
///
case lowLatency = 0
///
case balanced = 1
///
case smooth = 2
var id: Int { rawValue }
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
self == .lowLatency
}
/// /
/// - Parameters:
/// - isLive:
/// - Returns: network/live/file
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
switch self {
case .lowLatency:
@@ -122,12 +140,12 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
if isLive {
return (network: 2600, live: 2600, file: 3200)
}
return (network: 3800, live: 3200, file: 4400)
return (network: 6000, live: 5000, file: 6400)
case .smooth:
if isLive {
return (network: 4200, live: 4200, file: 5200)
}
return (network: 5600, live: 4800, file: 6400)
return (network: 8000, live: 6800, file: 8400)
}
}
}
+18 -5
View File
@@ -2,25 +2,37 @@ import Foundation
/// - Android SourceBean.java
struct SourceBean: Codable, Identifiable, Hashable {
/// key
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
/// 0 1
let searchable: Int
/// 0 1
let filterable: Int
/// 0 1 remote quick
let quickSearch: Int
/// Swift
let playerType: Int
/// 0 XML1 JSON3 JAR4 Remote
let type: Int
/// remote
let ext: String?
init(key: String = "", name: String = "", api: String = "",
searchable: Int = 1, filterable: Int = 1,
searchable: Int = 1, filterable: Int = 1, quickSearch: Int = 0,
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.quickSearch = quickSearch
self.playerType = playerType
self.type = type
self.ext = ext
@@ -28,6 +40,7 @@ struct SourceBean: Codable, Identifiable, Hashable {
var isSearchable: Bool { searchable == 1 }
var isFilterable: Bool { filterable == 1 }
var isQuickSearchEnabled: Bool { quickSearch == 1 }
/// Swift type=3 JAR/Spider Java
var isSupportedInSwift: Bool {
+20 -3
View File
@@ -2,16 +2,27 @@ import Foundation
/// - Android VodInfo.java
struct VodInfo: Codable, Identifiable {
/// ID
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 = ""
/// key
var sourceKey: String = ""
/// 线
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
/// key: flag, value:
var playUrlMap: [String: [Episode]] = [:]
var playFlag: String = "" // 线
var playIndex: Int = 0 //
/// 线
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) {
@@ -48,7 +63,7 @@ struct VodInfo: Codable, Identifiable {
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 }
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
return info
}
/// 线
var currentEpisodes: [Episode] {
playUrlMap[playFlag] ?? []
}
/// 线 +
var currentEpisode: Episode? {
let eps = currentEpisodes
guard playIndex >= 0, playIndex < eps.count else { return nil }
+24 -2
View File
@@ -5,18 +5,26 @@ import SwiftData
///
struct VodPlaybackState: Codable {
/// 线
var flag: String
///
var episodeIndex: Int
///
var progressSeconds: Double
}
///
@Model
final class VodCollect {
/// ID sourceKey
var vodId: String = ""
///
var vodName: String = ""
///
var vodPic: String = ""
/// key
var sourceKey: String = ""
/// /
var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
@@ -31,12 +39,19 @@ final class VodCollect {
///
@Model
final class VodRecord {
/// ID
var vodId: String = ""
///
var vodName: String = ""
///
var vodPic: String = ""
/// key
var sourceKey: String = ""
var playNote: String = "" // "5 03:45"
var dataJson: String = "" // JSONVodPlaybackState
/// 5 03:45
var playNote: String = ""
/// JSON`VodPlaybackState`
var dataJson: String = ""
///
var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
@@ -52,8 +67,11 @@ final class VodRecord {
///
@Model
final class CacheItem {
///
@Attribute(.unique) var key: String = ""
///
var value: String = ""
///
var updateTime: Date = Date()
init(key: String, value: String) {
@@ -95,6 +113,7 @@ actor CacheStore {
@MainActor
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
// (vodId, sourceKey)
let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey
}
@@ -151,6 +170,7 @@ actor CacheStore {
try? context.save()
}
/// JSON `nil`
@MainActor
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
@@ -171,6 +191,7 @@ actor CacheStore {
@MainActor
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
// (vodId, sourceKey)
let predicate = #Predicate<VodRecord> { item in
item.vodId == vodId && item.sourceKey == sourceKey
}
@@ -185,6 +206,7 @@ actor CacheStore {
return String(data: data, encoding: .utf8)
}
/// JSON
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)
+426 -12
View File
@@ -5,6 +5,15 @@ import Foundation
@MainActor
class ApiConfig: ObservableObject {
static let shared = ApiConfig()
private static let maxConfigResolveDepth = 6
private static let maxRedirectCandidates = 20
struct MultiRepoOption: Identifiable, Equatable {
let name: String
let url: String
var id: String { url.lowercased() }
}
@Published var sourceBeanList: [SourceBean] = []
@Published var homeSourceBean: SourceBean?
@@ -13,6 +22,7 @@ class ApiConfig: ObservableObject {
@Published var dohList: [(name: String, url: String)] = []
@Published var isLoaded: Bool = false
@Published var configUrl: String = ""
@Published var liveConfigUrl: String = ""
@Published var wallpaper: String = ""
private let network = NetworkManager.shared
@@ -21,9 +31,81 @@ class ApiConfig: ObservableObject {
///
func loadConfig(from apiUrl: String) async throws {
self.configUrl = apiUrl
try await loadConfigs(vodApiUrl: apiUrl, liveApiUrl: apiUrl)
}
let jsonStr = try await network.getString(from: apiUrl)
///
func loadConfigs(vodApiUrl: String, liveApiUrl: String) async throws {
let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedVod.isEmpty else {
throw ConfigError.parseError("点播接口地址不能为空")
}
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
self.configUrl = trimmedVod
self.liveConfigUrl = resolvedLive
if trimmedVod == resolvedLive {
let configResult = try await fetchConfig(from: trimmedVod)
parseConfig(
configResult.config,
apiUrl: configResult.loadedFrom,
includeSources: true,
includeLive: true
)
} else {
let vodConfig = try await fetchConfig(from: trimmedVod)
parseConfig(
vodConfig.config,
apiUrl: vodConfig.loadedFrom,
includeSources: true,
includeLive: false
)
let liveConfig = try await fetchConfig(from: resolvedLive)
parseConfig(
liveConfig.config,
apiUrl: liveConfig.loadedFrom,
includeSources: false,
includeLive: true
)
}
self.isLoaded = true
}
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
try await fetchConfig(
from: apiUrl,
visitedUrls: Set<String>(),
depth: 0
)
}
private func fetchConfig(
from apiUrl: String,
visitedUrls: Set<String>,
depth: Int
) async throws -> (config: AppConfigData, loadedFrom: String) {
guard depth <= Self.maxConfigResolveDepth else {
throw ConfigError.parseError("配置跳转层级过深(超过 \(Self.maxConfigResolveDepth) 层)")
}
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
guard !normalizedUrl.isEmpty else {
throw ConfigError.parseError("配置地址为空")
}
let visitKey = normalizedUrl.lowercased()
guard !visitedUrls.contains(visitKey) else {
throw ConfigError.parseError("检测到循环引用的配置地址: \(normalizedUrl)")
}
var nextVisited = visitedUrls
nextVisited.insert(visitKey)
let jsonStr = try await network.getString(from: normalizedUrl)
// JSONAndroid Gson Swift
let cleanedJson = Self.stripJsonComments(jsonStr)
@@ -32,8 +114,283 @@ class ApiConfig: ObservableObject {
throw ConfigError.parseError("无法解析配置数据")
}
let config = try JSONDecoder().decode(AppConfigData.self, from: data)
parseConfig(config, apiUrl: apiUrl)
let decoder = JSONDecoder()
let decodedConfig = try? decoder.decode(AppConfigData.self, from: data)
if let config = decodedConfig, config.hasUsableContent {
return (config, normalizedUrl)
}
if let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) {
let candidateUrls = Self.uniqueUrlsInOrder(
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
)
var lastError: Error?
for candidateUrl in candidateUrls {
do {
return try await fetchConfig(
from: candidateUrl,
visitedUrls: nextVisited,
depth: depth + 1
)
} catch {
lastError = error
}
}
if let lastError {
throw ConfigError.parseError("多仓库配置中没有可用地址,最后错误: \(lastError.localizedDescription)")
}
throw ConfigError.parseError("多仓库配置中没有可用地址")
}
let redirectCandidates = Self.extractConfigRedirectCandidates(from: cleanedJson)
.filter { $0.lowercased() != visitKey && !nextVisited.contains($0.lowercased()) }
if !redirectCandidates.isEmpty {
var lastError: Error?
for candidate in redirectCandidates {
do {
return try await fetchConfig(
from: candidate,
visitedUrls: nextVisited,
depth: depth + 1
)
} catch {
lastError = error
}
}
if let lastError {
throw ConfigError.parseError("页面跳转配置解析失败,最后错误: \(lastError.localizedDescription)")
}
}
if decodedConfig != nil {
throw ConfigError.parseError("配置缺少可用站点(sites / lives / parses")
}
throw ConfigError.parseError("配置格式不受支持")
}
private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
for url in urls {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let key = trimmed.lowercased()
guard !seen.contains(key) else { continue }
seen.insert(key)
result.append(trimmed)
}
return result
}
/// /
/// - URL
/// - data-clipboard-text
/// - JSON url
private static func extractConfigRedirectCandidates(from content: String) -> [String] {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
var rawCandidates: [String] = []
if (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) && !trimmed.contains("\n") {
rawCandidates.append(trimmed)
}
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #"data-clipboard-text\s*=\s*["']([^"']+)["']"#,
in: content
))
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #""url"\s*:\s*"([^"]+)""#,
in: content
))
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #"(https?://[^\s"'<>\\]+)"#,
in: content
))
let normalized = rawCandidates
.map(sanitizeExtractedUrl)
.map(normalizeConfigUrl)
.filter { !$0.isEmpty }
.filter(isLikelyConfigPointerUrl)
.filter { !isLikelyBinaryAssetUrl($0) }
return Array(uniqueUrlsInOrder(normalized).prefix(maxRedirectCandidates))
}
private static func matchCaptureGroup(pattern: String, in content: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
let range = NSRange(content.startIndex..<content.endIndex, in: content)
let matches = regex.matches(in: content, options: [], range: range)
return matches.compactMap { match in
guard match.numberOfRanges >= 2,
let subRange = Range(match.range(at: 1), in: content) else {
return nil
}
return String(content[subRange])
}
}
private static func sanitizeExtractedUrl(_ value: String) -> String {
var result = value.trimmingCharacters(in: .whitespacesAndNewlines)
result = result.replacingOccurrences(of: "&amp;", with: "&")
result = result.replacingOccurrences(of: "\\/", with: "/")
while let last = result.last, [".", ",", ";", ")", "]", "}", "\"", "'"].contains(last) {
result.removeLast()
}
while let first = result.first, ["\"", "'", "(", "[", "{"].contains(first) {
result.removeFirst()
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isLikelyBinaryAssetUrl(_ urlString: String) -> Bool {
guard let components = URLComponents(string: urlString) else { return false }
let path = components.path.lowercased()
return path.hasSuffix(".png")
|| path.hasSuffix(".jpg")
|| path.hasSuffix(".jpeg")
|| path.hasSuffix(".webp")
|| path.hasSuffix(".gif")
|| path.hasSuffix(".svg")
|| path.hasSuffix(".ico")
|| path.hasSuffix(".css")
|| path.hasSuffix(".woff")
|| path.hasSuffix(".woff2")
|| path.hasSuffix(".ttf")
}
private static func isLikelyConfigPointerUrl(_ urlString: String) -> Bool {
guard let components = URLComponents(string: urlString) else { return false }
let host = (components.host ?? "").lowercased()
let path = components.path.lowercased()
let query = (components.percentEncodedQuery ?? "").lowercased()
if host.contains("raw.githubusercontent.com") || host.contains("githubusercontent.com") {
return true
}
if path.contains(".json")
|| path.hasSuffix("/tv")
|| path.hasSuffix("/tv/")
|| path.hasSuffix("/m")
|| path.hasSuffix("/m/")
|| path.contains("tvbox")
|| path.contains("box")
|| query.contains("json")
|| query.contains("config")
|| query.contains("url=") {
return true
}
return false
}
/// URL
/// 1) https:/xxx
/// 2) github.com/.../blob/... raw.githubusercontent.com/...
/// 3) gh-proxy + github/blob
static func normalizeConfigUrl(_ rawUrl: String) -> String {
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
let fixedScheme = fixMalformedSchemeIfNeeded(trimmed)
if let normalizedProxy = normalizeGhProxyWrappedUrl(fixedScheme) {
return normalizedProxy
}
if let githubRaw = convertGitHubBlobUrlToRaw(fixedScheme) {
return githubRaw
}
return fixedScheme
}
private static func fixMalformedSchemeIfNeeded(_ urlString: String) -> String {
let fixedHttps = urlString.replacingOccurrences(
of: #"^https:/(?!/)"#,
with: "https://",
options: .regularExpression
)
return fixedHttps.replacingOccurrences(
of: #"^http:/(?!/)"#,
with: "http://",
options: .regularExpression
)
}
private static func normalizeGhProxyWrappedUrl(_ urlString: String) -> String? {
guard let components = URLComponents(string: urlString),
let host = components.host?.lowercased(),
host.contains("gh-proxy") else {
return nil
}
let path = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !path.isEmpty else { return nil }
let decodedPath = path.removingPercentEncoding ?? path
let fixedEmbedded = fixMalformedSchemeIfNeeded(decodedPath)
guard fixedEmbedded.hasPrefix("http://") || fixedEmbedded.hasPrefix("https://") else {
return nil
}
let normalizedEmbedded = convertGitHubBlobUrlToRaw(fixedEmbedded) ?? fixedEmbedded
let scheme = components.scheme ?? "https"
let portSuffix = components.port.map { ":\($0)" } ?? ""
var rebuilt = "\(scheme)://\(host)\(portSuffix)/\(normalizedEmbedded)"
if let query = components.percentEncodedQuery, !query.isEmpty {
rebuilt += "?\(query)"
}
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
rebuilt += "#\(fragment)"
}
return rebuilt
}
private static func convertGitHubBlobUrlToRaw(_ urlString: String) -> String? {
guard let components = URLComponents(string: urlString),
let host = components.host?.lowercased(),
host == "github.com" || host == "www.github.com" else {
return nil
}
let parts = components.path.split(separator: "/", omittingEmptySubsequences: true)
guard parts.count >= 5, parts[2] == "blob" else {
return nil
}
let owner = String(parts[0])
let repo = String(parts[1])
let branch = String(parts[3])
let filePath = parts.dropFirst(4).joined(separator: "/")
guard !filePath.isEmpty else {
return nil
}
var rawUrl = "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(filePath)"
if let query = components.percentEncodedQuery, !query.isEmpty {
rawUrl += "?\(query)"
}
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
rawUrl += "#\(fragment)"
}
return rawUrl
}
/// JSON // TVBox
@@ -96,8 +453,62 @@ class ApiConfig: ObservableObject {
return line
}
///
/// nil
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
let jsonStr = try await network.getString(from: normalizedUrl)
let cleanedJson = Self.stripJsonComments(jsonStr)
guard let data = cleanedJson.data(using: .utf8) else {
throw ConfigError.parseError("无法解析配置数据")
}
let decoder = JSONDecoder()
if let config = try? decoder.decode(AppConfigData.self, from: data), config.hasUsableContent {
return nil
}
guard let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) else {
return nil
}
let normalizedCandidates = Self.uniqueUrlsInOrder(
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
)
var options: [MultiRepoOption] = []
for candidate in normalizedCandidates {
let matchedEntry = multiRepo.urls?.first(where: {
Self.normalizeConfigUrl($0.url ?? "") == candidate
})
let displayName = matchedEntry?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = URL(string: candidate)?.host ?? candidate
let resolvedName: String
if let displayName, !displayName.isEmpty {
resolvedName = displayName
} else {
resolvedName = fallbackName
}
options.append(
MultiRepoOption(
name: resolvedName,
url: candidate
)
)
}
return options
}
///
private func parseConfig(_ config: AppConfigData, apiUrl: String) {
private func parseConfig(
_ config: AppConfigData,
apiUrl: String,
includeSources: Bool,
includeLive: Bool
) {
if includeSources {
//
var sources: [SourceBean] = []
if let sites = config.sites {
@@ -108,6 +519,7 @@ class ApiConfig: ObservableObject {
api: site.api ?? "",
searchable: site.searchable?.value ?? 1,
filterable: site.filterable?.value ?? 1,
quickSearch: site.quickSearch?.value ?? 0,
playerType: site.playerType?.value ?? 0,
type: site.type?.value ?? 1,
ext: site.ext?.stringValue
@@ -143,13 +555,15 @@ class ApiConfig: ObservableObject {
//
self.wallpaper = config.wallpaper ?? ""
//
if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
}
self.isLoaded = true
if includeLive {
if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
} else {
liveChannelGroupList = []
}
}
}
///
@@ -353,13 +767,13 @@ class ApiConfig: ObservableObject {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
if let url = URL(string: trimmed), url.scheme != nil {
return trimmed
return Self.normalizeConfigUrl(trimmed)
}
guard let baseUrl = URL(string: baseConfigUrl),
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
return trimmed
}
return resolved.absoluteString
return Self.normalizeConfigUrl(resolved.absoluteString)
}
private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
+79 -2
View File
@@ -2,11 +2,42 @@ import Foundation
/// - Android OkGo
class NetworkManager {
///
static let shared = NetworkManager()
///
private static let fallbackCharsetNames: [String] = [
"utf-8",
"gb18030",
"gbk",
"gb2312",
"utf-16",
"utf-16le",
"utf-16be",
"utf-32",
"windows-1252",
"iso-8859-1"
]
///
private static let fallbackStringEncodings: [String.Encoding] = [
.utf8,
.utf16,
.utf16LittleEndian,
.utf16BigEndian,
.utf32,
.utf32LittleEndian,
.utf32BigEndian,
.windowsCP1252,
.isoLatin1
]
///
private let session: URLSession
/// JSON
private let decoder = JSONDecoder()
///
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
@@ -35,8 +66,9 @@ class NetworkManager {
throw NetworkError.httpError(httpResponse.statusCode)
}
guard let str = String(data: data, encoding: .utf8) else {
throw NetworkError.decodingError("UTF-8 解码失败")
// +
guard let str = Self.decodeString(data: data, response: httpResponse) else {
throw NetworkError.decodingError("文本解码失败")
}
return str
@@ -59,14 +91,59 @@ class NetworkManager {
let (data, _) = try await session.data(from: url)
return data
}
///
/// 1)
/// 2)
/// 3) UTF-8
private static func decodeString(data: Data, response: HTTPURLResponse) -> String? {
// 使 gbk / gb2312 / gb18030
if let charset = response.textEncodingName,
let declaredEncoding = encoding(fromIANACharset: charset),
let value = String(data: data, encoding: declaredEncoding) {
return value
}
for charset in fallbackCharsetNames {
if let encoding = encoding(fromIANACharset: charset),
let value = String(data: data, encoding: encoding) {
return value
}
}
for encoding in fallbackStringEncodings {
if let value = String(data: data, encoding: encoding) {
return value
}
}
//
if !data.isEmpty {
return String(decoding: data, as: UTF8.self)
}
return nil
}
/// IANA `String.Encoding`
private static func encoding(fromIANACharset charset: String) -> String.Encoding? {
let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString)
guard cfEncoding != kCFStringEncodingInvalidId else {
return nil
}
let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding)
return String.Encoding(rawValue: nsEncoding)
}
}
///
enum NetworkError: LocalizedError {
case invalidURL(String)
case invalidResponse
case httpError(Int)
case decodingError(String)
/// UI/
var errorDescription: String? {
switch self {
case .invalidURL(let url): return "无效的URL: \(url)"
+42 -3
View File
@@ -325,9 +325,10 @@ class SourceService {
url = "\(api)?wd=\(encodedKeyword)"
} else if sourceBean.type == 4 {
// Type 4:
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
url = api.contains("?")
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false"
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false"
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
// extend
if let ext = sourceBean.ext, !ext.isEmpty {
@@ -345,7 +346,8 @@ class SourceService {
}
let jsonStr = try await network.getString(from: url)
return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
let videos = try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
return filterSearchResults(videos, keyword: keyword)
}
///
@@ -374,6 +376,43 @@ class SourceService {
}
}
/// /
private func filterSearchResults(_ videos: [Movie.Video], keyword: String) -> [Movie.Video] {
let tokens = keyword
.split(whereSeparator: \.isWhitespace)
.map { normalizeSearchText(String($0)) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return videos }
return videos.filter { video in
let searchableText = normalizeSearchText([
video.name,
video.note,
video.actor,
video.director,
video.type,
video.area,
video.year
].joined(separator: " "))
guard !searchableText.isEmpty else { return false }
return tokens.allSatisfy { searchableText.contains($0) }
}
}
private func normalizeSearchText(_ text: String) -> String {
let folded = text.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: .current
)
let scalars = folded.unicodeScalars.filter { scalar in
!CharacterSet.whitespacesAndNewlines.contains(scalar) &&
!CharacterSet.punctuationCharacters.contains(scalar) &&
!CharacterSet.symbols.contains(scalar)
}
return String(String.UnicodeScalarView(scalars)).lowercased()
}
// MARK: - Extend
/// extend Android getFixUrl
+2
View File
@@ -11,6 +11,8 @@ struct HawkConfig {
static let HOME_API = "home_api"
static let HOME_REC = "home_rec"
static let PLAY_TYPE = "play_type"
static let PLAY_TYPE_VOD = "play_type_vod"
static let PLAY_TYPE_LIVE = "play_type_live"
static let DOH_URL = "doh_url"
static let SEARCH_VIEW = "search_view"
static let LIVE_API_URL = "live_api_url"
+33
View File
@@ -2,9 +2,13 @@ import Foundation
import SwiftUI
struct PlaybackQualityOption: Identifiable, Hashable {
///
static let autoIdentifier = "auto"
/// 使 auto id
let id: String
/// UI 1080p / 720p /
let name: String
///
let url: String
var isAuto: Bool {
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
/// 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] = []
/// id
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
/// UI
private var realtimeProgressSeconds: Double = 0
///
private let sourceService = SourceService.shared
private let network = NetworkManager.shared
///
private var qualityBaseEpisodeURL: String = ""
/// key URL
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
///
private var qualityResolveTask: Task<Void, Never>?
///
private var qualityResolveToken = UUID()
///
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
realtimeProgressSeconds = 0
if let episode = vodInfo?.currentEpisode {
// URL
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
playUrl = selectedPlayableURL(fallback: episode.url)
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
selectedQualityId = option.id
guard isPlaying else { return }
// 使使
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
guard !targetURL.isEmpty, playUrl != targetURL else { return }
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
}
private func selectedPlayableURL(fallback: String) -> String {
// URL使退
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
if let selected, !selected.isEmpty {
return selected
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
return fallback
}
///
private func resetQualityState() {
qualityResolveTask?.cancel()
qualityResolveTask = nil
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
return
}
//
qualityResolveTask?.cancel()
qualityResolveTask = nil
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
qualityOptions = [autoOption]
if let cached = qualityOptionCache[trimmedEpisodeURL] {
//
qualityOptions = cached
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
selectedQualityId = PlaybackQualityOption.autoIdentifier
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
}
}
/// HLS
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)
}
/// HLS
private struct HLSVariant {
let url: String
let name: String?
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
let bandwidth: Int?
}
/// URL HLS
private static func looksLikeHLSURL(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased()
if lowercased.contains(".m3u8") { return true }
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
return ext == "m3u8" || ext == "m3u"
}
/// HLS
/// 2
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
return merged
}
/// `EXT-X-STREAM-INF`
private static func parseAttributeMap(_ raw: String) -> [String: String] {
var result: [String: String] = [:]
let pairs = splitAttributes(raw)
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
return result
}
///
private static func splitAttributes(_ raw: String) -> [String] {
var parts: [String] = []
var buffer = ""
+14 -1
View File
@@ -4,15 +4,24 @@ 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
///
@@ -24,7 +33,7 @@ class HomeViewModel: ObservableObject {
do {
let result = try await sourceService.getSort(sourceBean: source)
// ""
// UI Android
var allSorts = [MovieSort.SortData.home()]
allSorts.append(contentsOf: result.sorts)
@@ -43,6 +52,7 @@ class HomeViewModel: ObservableObject {
///
func selectSort(_ sort: MovieSort.SortData) {
//
selectedSort = sort
errorMessage = nil
categoryVideos = []
@@ -62,6 +72,7 @@ class HomeViewModel: ObservableObject {
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
@@ -78,6 +89,7 @@ class HomeViewModel: ObservableObject {
} else {
categoryVideos.append(contentsOf: videos)
}
//
currentPage = page
hasMore = !videos.isEmpty
} catch {
@@ -105,6 +117,7 @@ class HomeViewModel: ObservableObject {
///
func refresh() async {
//
currentPage = 1
hasMore = true
categoryVideos = []
+11 -1
View File
@@ -4,12 +4,19 @@ 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] = []
/// 便 EPG
@Published var isLoading = false
/// TV
@Published var showChannelList = false
///
@@ -67,6 +74,7 @@ class LiveViewModel: ObservableObject {
/// 线
func switchSource() {
// `currentChannel` mutating @Published
currentChannel?.nextSource()
}
@@ -78,13 +86,15 @@ class LiveViewModel: ObservableObject {
/// EPG
private func loadEPG(for channel: LiveChannelItem) {
// EPG -
// / ID EPG
//
epgList = []
}
}
// 访
extension Collection {
/// `nil`
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
+23 -1
View File
@@ -4,14 +4,23 @@ 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
///
private var latestSearchRequestId: UUID = UUID()
///
init() {
loadSearchHistory()
}
@@ -20,15 +29,19 @@ class SearchViewModel: ObservableObject {
func search() async {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let requestId = UUID()
latestSearchRequestId = requestId
isSearching = true
errorMessage = nil
results = []
//
//
addToHistory(trimmed)
//
let videos = await sourceService.searchAll(keyword: trimmed)
guard requestId == latestSearchRequestId else { return }
self.results = videos
if videos.isEmpty {
@@ -42,13 +55,18 @@ class SearchViewModel: ObservableObject {
func searchInSource(_ source: SourceBean) async {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let requestId = UUID()
latestSearchRequestId = requestId
isSearching = true
errorMessage = nil
do {
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
guard requestId == latestSearchRequestId else { return }
self.results = videos
} catch {
guard requestId == latestSearchRequestId else { return }
errorMessage = error.localizedDescription
}
@@ -57,10 +75,12 @@ class SearchViewModel: ObservableObject {
// MARK: -
///
private func loadSearchHistory() {
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
}
/// 20
private func addToHistory(_ keyword: String) {
searchHistory.removeAll { $0 == keyword }
searchHistory.insert(keyword, at: 0)
@@ -70,11 +90,13 @@ class SearchViewModel: ObservableObject {
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)
+185 -18
View File
@@ -4,56 +4,145 @@ import SwiftUI
/// ViewModel
@MainActor
class SettingsViewModel: ObservableObject {
@Published var apiUrl: String = ""
///
struct PendingMultiRepoSelection: Identifiable {
///
enum Target {
case vod
case live
var title: String {
switch self {
case .vod: return "点播"
case .live: return "直播"
}
}
}
let id = UUID()
///
let target: Target
/// live
let sourceUrl: String
///
let options: [ApiConfig.MultiRepoOption]
}
///
@Published var vodApiUrl: String = ""
///
@Published var liveApiUrl: String = ""
///
@Published var isLoadingConfig = false
///
@Published var configError: String?
/// UI /
@Published var configSuccess = false
/// nil
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
/// API
@Published var apiHistory: [String] = []
@Published var playerEngine: PlayerEngine = .system
///
@Published var vodPlayerEngine: PlayerEngine = .system
///
@Published var livePlayerEngine: PlayerEngine = .system
///
@Published var decodeMode: VideoDecodeMode = .auto
/// VLC
@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
/// VLC
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
///
/// 1)
/// 2)
/// 3) /
init() {
apiUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? ""
let defaults = UserDefaults.standard
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
vodApiUrl = savedVod
if let savedLive = defaults.string(forKey: HawkConfig.LIVE_API_URL) {
liveApiUrl = savedLive
} else {
liveApiUrl = savedVod
}
loadApiHistory()
playerEngine = PlayerEngine.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TYPE)
let hasLegacyPlayer = defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil
let legacyPlayerRaw = defaults.integer(forKey: HawkConfig.PLAY_TYPE)
let defaultVodRaw = PlayerEngine.system.rawValue
let defaultLiveRaw = PlayerEngine.isVLCAvailable
? PlayerEngine.vlc.rawValue
: PlayerEngine.system.rawValue
if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) == nil {
defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultVodRaw, forKey: HawkConfig.PLAY_TYPE_VOD)
}
if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) == nil {
defaults.set(hasLegacyPlayer ? legacyPlayerRaw : defaultLiveRaw, forKey: HawkConfig.PLAY_TYPE_LIVE)
}
vodPlayerEngine = PlayerEngine.fromStoredValue(
defaults.integer(forKey: HawkConfig.PLAY_TYPE_VOD)
)
livePlayerEngine = PlayerEngine.fromStoredValue(
defaults.integer(forKey: HawkConfig.PLAY_TYPE_LIVE)
)
decodeMode = VideoDecodeMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
defaults.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
)
vlcBufferMode = VLCBufferMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
defaults.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
)
let savedStep = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
let savedStep = defaults.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 = "请输入配置地址"
let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedVod.isEmpty else {
configError = "请输入点播接口地址"
return
}
isLoadingConfig = true
configError = nil
configSuccess = false
pendingMultiRepoSelection = nil
do {
try await ApiConfig.shared.loadConfig(from: trimmed)
UserDefaults.standard.set(trimmed, forKey: HawkConfig.API_URL)
addToApiHistory(trimmed)
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
//
if let pending = try await detectPendingMultiRepoSelection(
vodUrl: trimmedVod,
liveUrl: resolvedLive
) {
pendingMultiRepoSelection = pending
isLoadingConfig = false
return
}
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
// live
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
vodApiUrl = trimmedVod
liveApiUrl = trimmedLive
addToApiHistory(trimmedVod)
addToApiHistory(resolvedLive)
configSuccess = true
} catch {
configError = error.localizedDescription
@@ -62,12 +151,80 @@ class SettingsViewModel: ObservableObject {
isLoadingConfig = false
}
///
func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async {
guard let pending = pendingMultiRepoSelection else { return }
let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl)
switch pending.target {
case .vod:
let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl)
// live vod
let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& normalizedLive == normalizedSource
vodApiUrl = option.url
if shouldSyncLive {
liveApiUrl = option.url
}
case .live:
liveApiUrl = option.url
}
pendingMultiRepoSelection = nil
await loadConfig()
}
///
func cancelPendingMultiRepoSelection() {
pendingMultiRepoSelection = nil
isLoadingConfig = false
}
///
/// - Returns: `nil`
private func detectPendingMultiRepoSelection(
vodUrl: String,
liveUrl: String
) async throws -> PendingMultiRepoSelection? {
if let vodOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: vodUrl) {
guard !vodOptions.isEmpty else {
throw ConfigError.parseError("点播多仓库配置中没有可用地址")
}
return PendingMultiRepoSelection(
target: .vod,
sourceUrl: vodUrl,
options: vodOptions
)
}
let normalizedVod = ApiConfig.normalizeConfigUrl(vodUrl)
let normalizedLive = ApiConfig.normalizeConfigUrl(liveUrl)
guard normalizedLive != normalizedVod else {
return nil
}
if let liveOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: liveUrl) {
guard !liveOptions.isEmpty else {
throw ConfigError.parseError("直播多仓库配置中没有可用地址")
}
return PendingMultiRepoSelection(
target: .live,
sourceUrl: liveUrl,
options: liveOptions
)
}
return nil
}
// MARK: - API
/// API
private func loadApiHistory() {
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
}
/// 10
private func addToApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url }
apiHistory.insert(url, at: 0)
@@ -77,6 +234,7 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(apiHistory, forKey: "api_history")
}
/// API
func removeApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url }
UserDefaults.standard.set(apiHistory, forKey: "api_history")
@@ -97,11 +255,18 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(step, forKey: HawkConfig.PLAY_TIME_STEP)
}
///
func setPlayerEngine(_ engine: PlayerEngine) {
///
func setVodPlayerEngine(_ engine: PlayerEngine) {
guard playerEngineOptions.contains(engine) else { return }
playerEngine = engine
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE)
vodPlayerEngine = engine
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_VOD)
}
///
func setLivePlayerEngine(_ engine: PlayerEngine) {
guard playerEngineOptions.contains(engine) else { return }
livePlayerEngine = engine
UserDefaults.standard.set(engine.rawValue, forKey: HawkConfig.PLAY_TYPE_LIVE)
}
///
@@ -118,12 +283,14 @@ class SettingsViewModel: ObservableObject {
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 {
+7
View File
@@ -1,8 +1,13 @@
import SwiftUI
///
///
struct EmptyStateView: View {
/// SF Symbol
let icon: String
///
let title: String
///
var message: String? = nil
var body: some View {
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
}
.padding(.bottom, 8)
//
Text(title)
.font(.title3.bold())
.foregroundColor(.white.opacity(0.9))
.tracking(1)
//
if let message = message {
Text(message)
.font(.callout)
+98 -14
View File
@@ -2,10 +2,22 @@ import SwiftUI
/// - Android HomeActivity TabView
struct ContentView: View {
/// 使
private enum ApiInputTarget {
case vod
case live
}
///
@EnvironmentObject var appState: AppState
/// ViewModel
@StateObject private var settingsVM = SettingsViewModel()
///
@State private var selectedTab = 0
/// `appState.isConfigLoaded`
@State private var showSetup = false
///
@State private var setupInputTarget: ApiInputTarget = .vod
var body: some View {
Group {
@@ -15,20 +27,50 @@ struct ContentView: View {
setupView
}
}
.overlay(multiRepoSelectionOverlay)
.preferredColorScheme(.dark)
.onAppear {
//
let savedUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? ""
if !savedUrl.isEmpty {
let defaults = UserDefaults.standard
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
if !savedVodUrl.isEmpty {
//
Task {
await appState.loadConfig(url: savedUrl)
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
}
}
}
}
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
//
if let pending = settingsVM.pendingMultiRepoSelection {
SelectionModal(
title: "选择\(pending.target.title)仓库",
icon: "list.bullet.rectangle.portrait.fill",
items: pending.options,
selectedItem: nil,
itemTitle: { $0.name },
onSelect: { option in
Task {
await settingsVM.selectPendingMultiRepoOption(option)
if settingsVM.configSuccess {
appState.applyLoadedConfigState()
}
}
},
onCancel: {
settingsVM.cancelPendingMultiRepoSelection()
}
)
}
}
// MARK: -
/// iOS 使 TabViewmacOS 使 NavigationSplitView
private var mainTabView: some View {
#if os(iOS)
TabView(selection: $selectedTab) {
@@ -97,6 +139,7 @@ struct ContentView: View {
// MARK: -
///
private var setupView: some View {
ZStack {
//
@@ -169,24 +212,48 @@ struct ContentView: View {
HStack {
Image(systemName: "link")
.foregroundColor(.orange)
TextField("请输入接口地址 (URL)", text: $settingsVM.apiUrl)
TextField("请输入点播接口地址 (URL)", text: $settingsVM.vodApiUrl)
.textFieldStyle(.plain)
.foregroundColor(.white)
.onTapGesture {
setupInputTarget = .vod
}
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
Button {
if let text = readPasteboardText() {
settingsVM.vodApiUrl = text
}
} label: {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.orange)
}
.buttonStyle(.plain)
}
.padding()
.glassCard(cornerRadius: 15)
HStack {
Image(systemName: "tv")
.foregroundColor(.orange)
TextField("请输入直播接口地址 (URL,可留空跟随点播)", text: $settingsVM.liveApiUrl)
.textFieldStyle(.plain)
.foregroundColor(.white)
.onTapGesture {
setupInputTarget = .live
}
#if os(iOS)
if let text = UIPasteboard.general.string {
settingsVM.apiUrl = text
}
#else
if let text = NSPasteboard.general.string(forType: .string) {
settingsVM.apiUrl = text
}
.autocapitalization(.none)
.keyboardType(.URL)
#endif
Button {
if let text = readPasteboardText() {
settingsVM.liveApiUrl = text
}
} label: {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.orange)
@@ -202,7 +269,7 @@ struct ContentView: View {
Task {
await settingsVM.loadConfig()
if settingsVM.configSuccess {
await appState.loadConfig(url: settingsVM.apiUrl)
appState.applyLoadedConfigState()
}
}
} label: {
@@ -222,7 +289,10 @@ struct ContentView: View {
.clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
}
.disabled(settingsVM.isLoadingConfig || settingsVM.apiUrl.isEmpty)
.disabled(
settingsVM.isLoadingConfig
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
//
if !settingsVM.apiHistory.isEmpty {
@@ -234,7 +304,12 @@ struct ContentView: View {
ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in
Button {
settingsVM.apiUrl = url
switch setupInputTarget {
case .vod:
settingsVM.vodApiUrl = url
case .live:
settingsVM.liveApiUrl = url
}
} label: {
HStack {
Image(systemName: "clock.arrow.2.circlepath")
@@ -276,4 +351,13 @@ struct ContentView: View {
}
}
}
private func readPasteboardText() -> String? {
#if os(iOS)
UIPasteboard.general.string
#else
// macOS NSPasteboard
NSPasteboard.general.string(forType: .string)
#endif
}
}
+2 -1
View File
@@ -1 +1,2 @@
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
// `tvbox/Utils/Extensions.swift`
//
+36 -10
View File
@@ -8,10 +8,14 @@ import AppKit
struct DetailView: View {
let video: Movie.Video
@StateObject private var viewModel = DetailViewModel()
@StateObject private var sharedSystemController = SystemPlayerSessionController()
@StateObject private var sharedVLCController = VLCPlayerController()
@EnvironmentObject var appState: AppState
@Environment(\.modelContext) private var modelContext
@State private var showFullScreen = false
#if os(macOS)
@State private var pendingMacWindowFullScreen = false
#endif
@State private var lastPersistedProgress: Double = 0
@State private var isCollected = false
@@ -19,7 +23,7 @@ struct DetailView: View {
ScrollView {
VStack(spacing: 0) {
//
if viewModel.isPlaying, let url = viewModel.playUrl, !showFullScreen {
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl {
PlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
@@ -30,6 +34,7 @@ struct DetailView: View {
},
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
systemController: sharedSystemController,
vlcController: sharedVLCController
)
.id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)")
@@ -77,7 +82,7 @@ struct DetailView: View {
.background(AppTheme.primaryGradient)
.navigationTitle(video.name)
#if os(macOS)
.toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar)
.toolbar((showFullScreen || pendingMacWindowFullScreen) ? .hidden : .visible, for: .windowToolbar)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
@@ -91,8 +96,10 @@ struct DetailView: View {
viewModel.commitPlaybackProgressSnapshot()
persistHistoryIfNeeded(force: true)
showFullScreen = false
sharedSystemController.stop()
sharedVLCController.stop()
#if os(macOS)
pendingMacWindowFullScreen = false
appState.exitPlayerFullScreen()
#endif
}
@@ -106,6 +113,7 @@ struct DetailView: View {
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
systemController: sharedSystemController,
vlcController: sharedVLCController,
onCloseRequested: closeMacFullScreenOverlay
)
@@ -114,7 +122,13 @@ struct DetailView: View {
.zIndex(2)
}
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in
guard pendingMacWindowFullScreen else { return }
pendingMacWindowFullScreen = false
showFullScreen = true
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
pendingMacWindowFullScreen = false
if showFullScreen {
showFullScreen = false
}
@@ -131,6 +145,7 @@ struct DetailView: View {
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
systemController: sharedSystemController,
vlcController: sharedVLCController
)
}
@@ -502,24 +517,33 @@ struct DetailView: View {
showFullScreen = true
#else
guard viewModel.playUrl != nil else { return }
showFullScreen = true
appState.enterPlayerFullScreen()
requestMacWindowFullScreen(enter: true)
if let window = NSApp.keyWindow ?? NSApp.mainWindow,
window.styleMask.contains(.fullScreen) {
showFullScreen = true
return
}
pendingMacWindowFullScreen = requestMacWindowFullScreen(enter: true)
if !pendingMacWindowFullScreen {
showFullScreen = true
}
#endif
}
#if os(macOS)
private func requestMacWindowFullScreen(enter: Bool) {
DispatchQueue.main.async {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
@discardableResult
private func requestMacWindowFullScreen(enter: Bool) -> Bool {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return false }
let isFullScreen = window.styleMask.contains(.fullScreen)
if enter != isFullScreen {
guard enter != isFullScreen else { return false }
window.toggleFullScreen(nil)
}
}
return true
}
private func closeMacFullScreenOverlay() {
pendingMacWindowFullScreen = false
let window = NSApp.keyWindow ?? NSApp.mainWindow
if window?.styleMask.contains(.fullScreen) == true {
requestMacWindowFullScreen(enter: false)
@@ -539,6 +563,7 @@ struct FullScreenPlayerView: View {
var onPlaybackEnded: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var systemController: SystemPlayerSessionController? = nil
var vlcController: VLCPlayerController? = nil
var onCloseRequested: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@@ -561,6 +586,7 @@ struct FullScreenPlayerView: View {
},
canPlayNext: canPlayNext,
onPlayNext: onPlayNext,
systemController: systemController,
vlcController: vlcController
)
.ignoresSafeArea()
+15 -9
View File
@@ -2,17 +2,26 @@ import SwiftUI
/// - Android SeriesAdapter
struct EpisodeListView: View {
/// 线
let episodes: [VodInfo.Episode]
///
let selectedIndex: Int
///
let onSelect: (Int) -> Void
/// 50
@State private var currentGroup = 0
///
private let groupSize = 50
///
private let gridColumns = [GridItem(.adaptive(minimum: 78), spacing: 10)]
/// 1 0
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)
@@ -56,12 +65,9 @@ struct EpisodeListView: View {
}
}
//
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [
GridItem(.fixed(44)),
GridItem(.fixed(44))
], spacing: 10) {
//
LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 10) {
// 使 + 便
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
let actualIndex = currentGroup * groupSize + index
Button {
@@ -70,7 +76,9 @@ struct EpisodeListView: View {
Text(episode.name)
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
.frame(minWidth: 70)
.lineLimit(1)
.minimumScaleFactor(0.85)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
ZStack {
@@ -92,7 +100,5 @@ struct EpisodeListView: View {
}
.padding(.horizontal, 20)
}
.frame(height: 100)
}
}
}
+10
View File
@@ -3,15 +3,19 @@ import SwiftData
/// - Android CollectActivity
struct FavoritesView: View {
/// /
@Query(sort: \VodCollect.updateTime, order: .reverse)
private var favorites: [VodCollect]
/// SwiftData
@Environment(\.modelContext) private var modelContext
#if os(iOS)
/// iOS
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
/// macOS
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
@@ -25,6 +29,7 @@ struct FavoritesView: View {
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
//
ForEach(favorites) { item in
NavigationLink(value: movieVideo(from: item)) {
favoriteCard(item)
@@ -50,12 +55,14 @@ struct FavoritesView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
// Movie.Video /
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
///
private var emptyState: some View {
EmptyStateView(
icon: "heart.text.square",
@@ -65,6 +72,8 @@ struct FavoritesView: View {
.padding(40)
}
///
///
private func favoriteCard(_ item: VodCollect) -> some View {
VStack(alignment: .leading, spacing: 6) {
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
@@ -83,6 +92,7 @@ struct FavoritesView: View {
}
}
///
private func movieVideo(from item: VodCollect) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
}
+11
View File
@@ -3,15 +3,19 @@ import SwiftData
/// - Android HistoryActivity
struct HistoryView: View {
///
@Query(sort: \VodRecord.updateTime, order: .reverse)
private var records: [VodRecord]
/// SwiftData
@Environment(\.modelContext) private var modelContext
#if os(iOS)
/// iOS
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
/// macOS
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
@@ -25,6 +29,7 @@ struct HistoryView: View {
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
//
ForEach(records) { item in
NavigationLink(value: movieVideo(from: item)) {
recordCard(item)
@@ -53,6 +58,7 @@ struct HistoryView: View {
.toolbar {
if !records.isEmpty {
ToolbarItem(placement: .automatic) {
// 使
Button {
Task {
CacheStore.shared.clearHistory(context: modelContext)
@@ -64,12 +70,14 @@ struct HistoryView: View {
}
}
}
// //
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
///
private var emptyState: some View {
EmptyStateView(
icon: "clock.arrow.circlepath",
@@ -79,6 +87,8 @@ struct HistoryView: View {
.padding(40)
}
///
/// 便
private func recordCard(_ item: VodRecord) -> some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) {
@@ -115,6 +125,7 @@ struct HistoryView: View {
}
}
///
private func movieVideo(from item: VodRecord) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
}
+4
View File
@@ -2,7 +2,9 @@ import SwiftUI
///
struct VodCardView: View {
///
let video: Movie.Video
/// macOS
@State private var isHovered = false
var body: some View {
@@ -44,6 +46,7 @@ struct VodCardView: View {
.padding(8)
}
}
//
.scaleEffect(isHovered ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in
@@ -67,6 +70,7 @@ struct VodCardView: View {
}
}
///
private var placeholderImage: some View {
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
.fill(Color.white.opacity(0.05))
+42 -3
View File
@@ -6,25 +6,56 @@ import AppKit
/// - Android LivePlayActivity
struct LiveView: View {
///
@StateObject private var viewModel = LiveViewModel()
/// macOS
@EnvironmentObject var appState: AppState
/// 使
@State private var avPlayer: AVPlayer?
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
///
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
///
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
///
@State private var showChannelDrawer = true
///
@State private var isWindowFullScreen = false
///
private let currentChannelInfoMaxWidth: CGFloat = 600
/// AVPlayer
@State private var itemStatusObserver: NSKeyValueObservation?
///
@State private var playbackFailedObserver: NSObjectProtocol?
///
@State private var playbackStalledObserver: NSObjectProtocol?
/// 线线
@State private var failedSourceIndices: Set<Int> = []
/// ID
@State private var trackedChannelId: String = ""
///
@State private var showCurrentChannelInfo = true
///
@State private var channelInfoTimer: Timer?
///
private let channelInfoAutoHideDelay: TimeInterval = 3.0
/// VLC
@State private var vlcInteractionToken = 0
///
/// 退使
private var selectedEngine: PlayerEngine {
PlayerEngine.fromStoredValue(playTypeRaw)
let defaults = UserDefaults.standard
let rawValue: Int
if defaults.object(forKey: HawkConfig.PLAY_TYPE_LIVE) != nil {
rawValue = livePlayTypeRaw
} else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil {
rawValue = legacyPlayTypeRaw
} else {
rawValue = PlayerEngine.isVLCAvailable
? PlayerEngine.vlc.rawValue
: PlayerEngine.system.rawValue
}
return PlayerEngine.fromStoredValue(rawValue)
}
var body: some View {
@@ -68,6 +99,7 @@ struct LiveView: View {
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
//
viewModel.loadChannels()
wakeUpCurrentChannelInfo()
}
@@ -80,10 +112,11 @@ struct LiveView: View {
wakeUpCurrentChannelInfo()
}
.onChange(of: viewModel.currentChannel?.id) { _, _ in
// 线
resetFailureTracking(for: viewModel.currentChannel)
wakeUpCurrentChannelInfo()
}
.onChange(of: playTypeRaw) { _, _ in
.onChange(of: selectedEngine) { _, _ in
if selectedEngine == .system {
playChannel(url: viewModel.currentChannel?.currentUrl)
} else {
@@ -352,6 +385,7 @@ struct LiveView: View {
}
private func reportUserActivity() {
// VLC
wakeUpCurrentChannelInfo()
vlcInteractionToken &+= 1
}
@@ -447,6 +481,7 @@ struct LiveView: View {
}
private func cleanupPlayer() {
//
if let observer = playbackFailedObserver {
NotificationCenter.default.removeObserver(observer)
playbackFailedObserver = nil
@@ -463,6 +498,7 @@ struct LiveView: View {
}
private func observePlaybackFailure(for item: AVPlayerItem) {
// KVO item
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed {
DispatchQueue.main.async {
@@ -471,6 +507,7 @@ struct LiveView: View {
}
}
//
playbackFailedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
@@ -479,6 +516,7 @@ struct LiveView: View {
handlePlaybackFailure(trigger: "item_failed")
}
//
playbackStalledObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: item,
@@ -514,6 +552,7 @@ struct LiveView: View {
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
guard failedSourceIndices.count < totalSources else { return false }
// `totalSources` 线
for _ in 0..<totalSources {
viewModel.switchSource()
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
+109 -20
View File
@@ -40,12 +40,34 @@ private struct MacOSPlayerView: NSViewRepresentable {
}
static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) {
nsView.player?.pause()
nsView.player = nil
}
}
#endif
/// AVPlayer
@MainActor
final class SystemPlayerSessionController: ObservableObject {
fileprivate var player: AVPlayer?
fileprivate var mediaURLString: String?
func setPlayer(_ newPlayer: AVPlayer, urlString: String) {
if player !== newPlayer {
player?.pause()
player?.replaceCurrentItem(with: nil)
}
player = newPlayer
mediaURLString = urlString
}
func stop() {
player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil
mediaURLString = nil
}
}
/// - Android PlayFragment
struct PlayerView: View {
let urlString: String
@@ -55,11 +77,22 @@ struct PlayerView: View {
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var systemController: SystemPlayerSessionController? = nil
var vlcController: VLCPlayerController? = nil
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
@AppStorage(HawkConfig.PLAY_TYPE_VOD) private var vodPlayTypeRaw = -1
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
private var selectedEngine: PlayerEngine {
PlayerEngine.fromStoredValue(playTypeRaw)
let defaults = UserDefaults.standard
let rawValue: Int
if defaults.object(forKey: HawkConfig.PLAY_TYPE_VOD) != nil {
rawValue = vodPlayTypeRaw
} else if defaults.object(forKey: HawkConfig.PLAY_TYPE) != nil {
rawValue = legacyPlayTypeRaw
} else {
rawValue = PlayerEngine.system.rawValue
}
return PlayerEngine.fromStoredValue(rawValue)
}
var body: some View {
@@ -73,7 +106,8 @@ struct PlayerView: View {
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: onToggleFullScreen,
canPlayNext: canPlayNext,
onPlayNext: onPlayNext
onPlayNext: onPlayNext,
sharedController: systemController
)
case .vlc:
VLCVodPlayerView(
@@ -89,6 +123,22 @@ struct PlayerView: View {
}
}
.id(selectedEngine.rawValue)
.onAppear {
if selectedEngine != .system {
systemController?.stop()
}
if selectedEngine != .vlc {
vlcController?.stop()
}
}
.onChange(of: selectedEngine) { _, newValue in
if newValue != .system {
systemController?.stop()
}
if newValue != .vlc {
vlcController?.stop()
}
}
}
}
@@ -101,6 +151,7 @@ struct AVPlayerContentView: View {
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var sharedController: SystemPlayerSessionController? = nil
@State private var player: AVPlayer?
@State private var playbackEndObserver: NSObjectProtocol?
@State private var timeObserverToken: Any?
@@ -195,7 +246,7 @@ struct AVPlayerContentView: View {
wakeUpControls()
}
.onDisappear {
cleanupPlayer()
cleanupPlayer(keepSharedPlayer: sharedController != nil)
controlsTimer?.invalidate()
osdTimer?.invalidate()
}
@@ -203,40 +254,60 @@ struct AVPlayerContentView: View {
private func setupPlayer() {
guard let url = URL(string: urlString) else { return }
let targetURLString = url.absoluteString
if let sharedController,
sharedController.mediaURLString == targetURLString,
let sharedPlayer = sharedController.player {
cleanupPlayer(keepSharedPlayer: true)
player = sharedPlayer
bindPlayerObservers(for: sharedPlayer)
reportProgress(for: sharedPlayer)
return
}
//
cleanupPlayer()
let playerItem = AVPlayerItem(url: url)
let newPlayer = AVPlayer(playerItem: playerItem)
if let sharedController {
sharedController.setPlayer(newPlayer, urlString: targetURLString)
}
//
player = newPlayer
bindPlayerObservers(for: newPlayer)
startPlayback(for: newPlayer)
}
private func bindPlayerObservers(for player: AVPlayer) {
playerObservers = [
newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
let status = p.timeControlStatus
DispatchQueue.main.async { isPlaying = status == .playing }
},
newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in
player.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in
let reason = p.reasonForWaitingToPlay
DispatchQueue.main.async { isPreparing = reason != nil }
},
newPlayer.observe(\.volume, options: [.new]) { p, _ in
player.observe(\.volume, options: [.new]) { p, _ in
let vol = Double(p.volume)
DispatchQueue.main.async { volume = vol }
},
newPlayer.observe(\.rate, options: [.new]) { p, _ in
player.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)
observePlaybackProgress(for: player)
observePlaybackEnd(for: player)
isPlaying = player.timeControlStatus == .playing
isPreparing = player.reasonForWaitingToPlay != nil
volume = Double(player.volume)
rate = player.rate
}
private func cleanupPlayer() {
private func detachPlayerObservers() {
if let token = timeObserverToken {
player?.removeTimeObserver(token)
timeObserverToken = nil
@@ -247,8 +318,24 @@ struct AVPlayerContentView: View {
}
playerObservers.forEach { $0.invalidate() }
playerObservers.removeAll()
player?.pause()
player?.replaceCurrentItem(with: nil)
}
private func cleanupPlayer(keepSharedPlayer: Bool = false) {
let currentPlayer = player
detachPlayerObservers()
guard let currentPlayer else { return }
if keepSharedPlayer, sharedController?.player === currentPlayer {
player = nil
return
}
currentPlayer.pause()
currentPlayer.replaceCurrentItem(with: nil)
if sharedController?.player === currentPlayer {
sharedController?.player = nil
sharedController?.mediaURLString = nil
}
player = nil
}
@@ -355,7 +442,8 @@ struct AVPlayerContentView: View {
Text(currentTime.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.frame(width: 45, alignment: .leading)
.lineLimit(1)
.frame(width: 62, alignment: .leading)
Slider(
value: Binding(
@@ -380,7 +468,8 @@ struct AVPlayerContentView: View {
Text(duration.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.frame(width: 45, alignment: .trailing)
.lineLimit(1)
.frame(width: 62, alignment: .trailing)
}
.padding(.horizontal, 4)
+27 -67
View File
@@ -14,6 +14,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private static let maxVolume = 200
private static let drawableSizeChangeThreshold: CGFloat = 24
private static let drawableRebindMinimumInterval: TimeInterval = 1.2
// libvlc /
private static let stablePlaybackOptions: [String] = [
"--no-drop-late-frames",
"--no-skip-frames",
"--clock-synchro=0",
"--clock-jitter=0"
]
private static let playerInstanceSelector = NSSelectorFromString("playerInstance")
private static let libVLCStopAsync: LibVLCStopAsyncFunction? = {
// RTLD_DEFAULT Swift -2 C RTLD_DEFAULT
@@ -25,7 +32,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}()
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
let mediaPlayer = VLCMediaPlayer()
let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
@Published var isPreparing = true
@Published var isPlaying = false
@Published var currentTimeSeconds: Double = 0
@@ -60,10 +67,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
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
@@ -73,16 +76,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
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
@@ -163,22 +160,19 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
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 cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode)
let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : false
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 !isLive {
mediaOptions["avcodec-hurry-up"] = 0
mediaOptions["clock-synchro"] = 0
}
if let hwOption = decodeMode.vlcHardwareDecodeOption {
mediaOptions["avcodec-hw"] = hwOption
@@ -191,6 +185,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
media.addOptions(mediaOptions)
// 使 no- 0/1 libvlc
media.addOption(enableFrameDrop ? "drop-late-frames" : "no-drop-late-frames")
media.addOption(enableSkipFrames ? "skip-frames" : "no-skip-frames")
mediaPlayer.media = media
mediaPlayer.play()
@@ -580,11 +577,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
private func applyPlaybackRate() {
guard mediaPlayer.rate != playbackRate else { return }
mediaPlayer.rate = playbackRate
}
private func applyVolume() {
mediaPlayer.audio?.volume = Int32(volume)
let target = Int32(volume)
guard mediaPlayer.audio?.volume != target else { return }
mediaPlayer.audio?.volume = target
}
private func syncDecodeModeFromSettings() {
@@ -617,13 +617,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
private func scheduleBufferingFallbackIfNeeded() {
guard isLive else { return }
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 delay = bufferingFallbackThresholdLive
let workItem = DispatchWorkItem { [weak self] in
Task { @MainActor [weak self] in
self?.attemptSoftDecodeFallbackIfNeeded()
@@ -673,7 +673,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
isPlaying = false
}
scheduleDelayedPreparingIndicatorForVod()
scheduleBufferingFallbackIfNeeded()
return
}
@@ -736,24 +735,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
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 {
@@ -770,30 +756,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
}
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()
@@ -802,10 +764,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
decodeModeOverride = nil
isInBufferingState = false
bufferingBaselineSecondsVod = 0
bufferingEventCountVod = 0
lastVodBufferingCountedAt = .distantPast
hasAttemptedVodCacheBoost = false
useVodCacheBoost = false
}
private func setPlaybackStatus(preparing: Bool, playing: Bool) {
@@ -1024,7 +982,8 @@ struct VLCVodPlayerView: View {
Text(currentDisplaySeconds.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.frame(width: 45, alignment: .leading)
.lineLimit(1)
.frame(width: 62, alignment: .leading)
Slider(
value: Binding(
@@ -1049,7 +1008,8 @@ struct VLCVodPlayerView: View {
Text(totalDisplayText)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.frame(width: 45, alignment: .trailing)
.lineLimit(1)
.frame(width: 62, alignment: .trailing)
}
.padding(.horizontal, 4)
+11
View File
@@ -2,13 +2,16 @@ import SwiftUI
/// - Android SearchActivity
struct SearchView: View {
///
@StateObject private var viewModel = SearchViewModel()
#if os(iOS)
/// iOS
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
/// macOS
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
@@ -29,6 +32,7 @@ struct SearchView: View {
} else if !viewModel.results.isEmpty {
searchResults
} else if viewModel.keyword.isEmpty {
//
searchHistorySection
} else if let error = viewModel.errorMessage {
Spacer()
@@ -53,6 +57,7 @@ struct SearchView: View {
// MARK: -
///
private var searchBar: some View {
HStack(spacing: 12) {
HStack(spacing: 10) {
@@ -114,6 +119,7 @@ struct SearchView: View {
// MARK: -
///
private var searchResults: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
@@ -134,6 +140,7 @@ struct SearchView: View {
// MARK: -
///
private var searchHistorySection: some View {
VStack(alignment: .leading, spacing: 12) {
if !viewModel.searchHistory.isEmpty {
@@ -183,13 +190,16 @@ struct SearchView: View {
///
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() {
@@ -197,6 +207,7 @@ struct FlowLayout: Layout {
}
}
///
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
+115 -21
View File
@@ -2,17 +2,38 @@ import SwiftUI
/// - Android SettingActivity + ModelSettingFragment
struct SettingsView: View {
enum ApiInputType {
case vod
case live
var title: String {
switch self {
case .vod: return "点播接口地址"
case .live: return "直播接口地址"
}
}
var placeholder: String {
switch self {
case .vod: return "请输入点播接口地址"
case .live: return "请输入直播接口地址(可留空跟随点播)"
}
}
}
@StateObject private var viewModel = SettingsViewModel()
@StateObject private var apiConfig = ApiConfig.shared
@EnvironmentObject var appState: AppState
@State private var showApiInput = false
@State private var editingApiType: ApiInputType = .vod
@State private var showAbout = false
@State private var sourceSearchText = ""
@State private var showingPicker: PickerType = .none
enum PickerType {
case none
case player
case vodPlayer
case livePlayer
case decode
case vlcBuffer
case playTimeStep
@@ -24,7 +45,21 @@ struct SettingsView: View {
VStack(spacing: 24) {
// API
SectionCard(title: "数据源") {
SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) {
SettingsRow(
icon: "film",
title: "点播接口地址",
value: viewModel.vodApiUrl.isEmpty ? "未配置" : viewModel.vodApiUrl
) {
editingApiType = .vod
showApiInput = true
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(
icon: "tv",
title: "直播接口地址",
value: viewModel.liveApiUrl.isEmpty ? "跟随点播接口" : viewModel.liveApiUrl
) {
editingApiType = .live
showApiInput = true
}
Divider().background(Color.white.opacity(0.1))
@@ -39,9 +74,15 @@ struct SettingsView: View {
//
SectionCard(title: "播放设置") {
SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) {
SettingsRow(icon: "play.rectangle", title: "点播播放器", value: viewModel.vodPlayerEngine.title) {
if viewModel.playerEngineOptions.count > 1 {
showingPicker = .player
showingPicker = .vodPlayer
}
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "dot.radiowaves.left.and.right", title: "直播播放器", value: viewModel.livePlayerEngine.title) {
if viewModel.playerEngineOptions.count > 1 {
showingPicker = .livePlayer
}
}
Divider().background(Color.white.opacity(0.1))
@@ -115,15 +156,28 @@ struct SettingsView: View {
@ViewBuilder
private var pickerOverlay: some View {
switch showingPicker {
case .player:
case .vodPlayer:
SelectionModal(
title: "选择播放器",
title: "选择点播播放器",
icon: "play.rectangle.fill",
items: viewModel.playerEngineOptions,
selectedItem: viewModel.playerEngine,
selectedItem: viewModel.vodPlayerEngine,
itemTitle: { $0.title },
onSelect: { engine in
viewModel.setPlayerEngine(engine)
viewModel.setVodPlayerEngine(engine)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .livePlayer:
SelectionModal(
title: "选择直播播放器",
icon: "dot.radiowaves.left.and.right",
items: viewModel.playerEngineOptions,
selectedItem: viewModel.livePlayerEngine,
itemTitle: { $0.title },
onSelect: { engine in
viewModel.setLivePlayerEngine(engine)
showingPicker = .none
},
onCancel: { showingPicker = .none }
@@ -180,7 +234,7 @@ struct SettingsView: View {
HStack {
Image(systemName: "link")
.foregroundColor(.secondary)
TextField("请输入接口地址", text: $viewModel.apiUrl)
TextField(editingApiType.placeholder, text: currentApiBinding)
.textFieldStyle(.plain)
#if os(iOS)
.autocapitalization(.none)
@@ -194,15 +248,9 @@ struct SettingsView: View {
//
HStack {
Button {
#if os(iOS)
if let text = UIPasteboard.general.string {
viewModel.apiUrl = text
if let text = readPasteboardText() {
currentApiBinding.wrappedValue = text
}
#else
if let text = NSPasteboard.general.string(forType: .string) {
viewModel.apiUrl = text
}
#endif
} label: {
Label("粘贴", systemImage: "doc.on.clipboard")
.font(.subheadline)
@@ -221,7 +269,7 @@ struct SettingsView: View {
ForEach(viewModel.apiHistory, id: \.self) { url in
HStack {
Button {
viewModel.apiUrl = url
currentApiBinding.wrappedValue = url
} label: {
HStack {
Image(systemName: "clock")
@@ -256,7 +304,7 @@ struct SettingsView: View {
Spacer()
}
.padding()
.navigationTitle("配置接口")
.navigationTitle(editingApiType.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -269,7 +317,7 @@ struct SettingsView: View {
Task {
await viewModel.loadConfig()
if viewModel.configSuccess {
await appState.loadConfig(url: viewModel.apiUrl)
appState.applyLoadedConfigState()
showApiInput = false
}
}
@@ -280,15 +328,61 @@ struct SettingsView: View {
Text("确认")
}
}
.disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty)
.disabled(
viewModel.isLoadingConfig
|| viewModel.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
}
.overlay(multiRepoSelectionOverlay)
#if os(iOS)
.presentationDetents([.medium, .large])
#endif
}
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
if let pending = viewModel.pendingMultiRepoSelection {
SelectionModal(
title: "选择\(pending.target.title)仓库",
icon: "list.bullet.rectangle.portrait.fill",
items: pending.options,
selectedItem: nil,
itemTitle: { $0.name },
onSelect: { option in
Task {
await viewModel.selectPendingMultiRepoOption(option)
if viewModel.configSuccess {
appState.applyLoadedConfigState()
showApiInput = false
}
}
},
onCancel: {
viewModel.cancelPendingMultiRepoSelection()
}
)
}
}
private var currentApiBinding: Binding<String> {
switch editingApiType {
case .vod:
return $viewModel.vodApiUrl
case .live:
return $viewModel.liveApiUrl
}
}
private func readPasteboardText() -> String? {
#if os(iOS)
UIPasteboard.general.string
#else
NSPasteboard.general.string(forType: .string)
#endif
}
// MARK: -
private var filteredSources: [SourceBean] {
+38 -5
View File
@@ -1,10 +1,15 @@
import SwiftUI
import SwiftData
///
/// SwiftData `AppState`
@main
struct tvboxApp: App {
///
@StateObject private var appState = AppState()
/// SwiftData
/// Schema//使
var sharedModelContainer: ModelContainer = {
let schema = Schema([
VodCollect.self,
@@ -19,6 +24,7 @@ struct tvboxApp: App {
}
}()
///
var body: some Scene {
WindowGroup {
ContentView()
@@ -31,29 +37,55 @@ struct tvboxApp: App {
}
}
///
///
@MainActor
class AppState: ObservableObject {
/// ViewModel 使
@Published var apiConfig = ApiConfig.shared
/// `ContentView`
@Published var isConfigLoaded = false
/// key
@Published var currentSourceKey: String = ""
#if os(macOS)
/// 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 ?? ""
await loadConfig(vodUrl: url, liveUrl: nil)
}
///
/// - Parameters:
/// - vodUrl:
/// - liveUrl: 退
func loadConfig(vodUrl: String, liveUrl: String?) async {
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedVod.isEmpty else { return }
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
do {
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
applyLoadedConfigState()
} catch {
print("Failed to load config: \(error)")
}
}
///
///
func applyLoadedConfigState() {
isConfigLoaded = true
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
}
#if os(macOS)
///
func enterPlayerFullScreen() {
if splitViewVisibilityBeforePlayerFullScreen == nil {
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
@@ -61,6 +93,7 @@ class AppState: ObservableObject {
splitViewVisibility = .detailOnly
}
/// 退
func exitPlayerFullScreen() {
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
splitViewVisibility = previous