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
@@ -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
}