增加注解

This commit is contained in:
JinJiangHuang
2026-03-01 22:23:38 +08:00
parent 99a8cc799b
commit ee8c71dcd4
23 changed files with 374 additions and 24 deletions
+22 -2
View File
@@ -4,10 +4,15 @@ import Foundation
/// ///
struct LiveChannelGroup: Codable, Identifiable, Hashable { struct LiveChannelGroup: Codable, Identifiable, Hashable {
/// 便 SwiftUI diff
var id: String { groupName } var id: String { groupName }
///
var groupName: String = "" var groupName: String = ""
///
var groupIndex: Int = 0 var groupIndex: Int = 0
///
var channels: [LiveChannelItem] = [] var channels: [LiveChannelItem] = []
///
var isPassword: Bool = false var isPassword: Bool = false
init(groupName: String = "", groupIndex: Int = 0) { init(groupName: String = "", groupIndex: Int = 0) {
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
/// ///
struct LiveChannelItem: Codable, Identifiable, Hashable { struct LiveChannelItem: Codable, Identifiable, Hashable {
/// +
var id: String { "\(channelName)_\(channelIndex)" } var id: String { "\(channelName)_\(channelIndex)" }
///
var channelName: String = "" var channelName: String = ""
///
var channelIndex: Int = 0 var channelIndex: Int = 0
/// 线
var channelUrls: [String] = [] var channelUrls: [String] = []
/// 线
var sourceIndex: Int = 0 var sourceIndex: Int = 0
/// 线
var sourceNum: Int { channelUrls.count } var sourceNum: Int { channelUrls.count }
///
var logo: String = "" var logo: String = ""
init(channelName: String = "", channelIndex: Int = 0) { init(channelName: String = "", channelIndex: Int = 0) {
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
self.channelIndex = channelIndex self.channelIndex = channelIndex
} }
/// 线
/// 线
var currentUrl: String? { var currentUrl: String? {
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first } guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
return channelUrls[sourceIndex] return channelUrls[sourceIndex]
} }
/// 线
mutating func nextSource() { mutating func nextSource() {
if channelUrls.count > 0 { if channelUrls.count > 0 {
sourceIndex = (sourceIndex + 1) % channelUrls.count sourceIndex = (sourceIndex + 1) % channelUrls.count
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
var endTime: String = "" var endTime: String = ""
var index: Int = 0 var index: Int = 0
/// `HH:mm`
var isLive: Bool { var isLive: Bool {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "HH:mm" formatter.dateFormat = "HH:mm"
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
/// EPG /// EPG
struct LiveEpgDate: Codable, Identifiable, Hashable { struct LiveEpgDate: Codable, Identifiable, Hashable {
var id: String { datePresent } var id: String { datePresent }
var datePresent: String = "" // /// UI
var date: String = "" // var datePresent: String = ""
///
var date: String = ""
///
var index: Int = 0 var index: Int = 0
/// UI
var isSelected: Bool = false var isSelected: Bool = false
} }
+27 -7
View File
@@ -2,28 +2,47 @@ import Foundation
/// / - Android Movie.java /// / - Android Movie.java
struct Movie: Codable { struct Movie: Codable {
///
var videoList: [Video] = [] var videoList: [Video] = []
///
var pagecount: Int = 0 var pagecount: Int = 0
///
var page: Int = 0 var page: Int = 0
///
var total: Int = 0 var total: Int = 0
///
var limit: Int = 0 var limit: Int = 0
/// ///
struct Video: Codable, Identifiable, Hashable { struct Video: Codable, Identifiable, Hashable {
/// ID Int String
var id: String var id: String
///
var name: String = "" var name: String = ""
///
var pic: String = "" var pic: String = ""
var note: String = "" // "20" /// 20
var note: String = ""
///
var year: String = "" var year: String = ""
///
var area: String = "" var area: String = ""
var type: String = "" // / /// /
var type: String = ""
///
var director: String = "" var director: String = ""
///
var actor: String = "" var actor: String = ""
var des: String = "" // ///
var sourceKey: String = "" // key var des: String = ""
var tid: String = "" // ID /// key
var last: String = "" // var sourceKey: String = ""
var dt: String = "" // /// ID
var tid: String = ""
///
var last: String = ""
///
var dt: String = ""
init(id: String = UUID().uuidString, name: String = "", pic: String = "", init(id: String = UUID().uuidString, name: String = "", pic: String = "",
note: String = "", sourceKey: String = "") { note: String = "", sourceKey: String = "") {
@@ -51,6 +70,7 @@ struct Movie: Codable {
case sourceKey case sourceKey
} }
/// `vod_id` / `type_id` Int String
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
// String Int id // String Int id
+14 -2
View File
@@ -2,13 +2,18 @@ import Foundation
/// - Android MovieSort.java /// - Android MovieSort.java
struct MovieSort: Codable { struct MovieSort: Codable {
///
var sortList: [SortData] = [] var sortList: [SortData] = []
/// ///
struct SortData: Codable, Identifiable, Hashable { struct SortData: Codable, Identifiable, Hashable {
/// type_id
var id: String var id: String
///
var name: String = "" var name: String = ""
/// /
var flag: String = "" var flag: String = ""
///
var filters: [SortFilter] = [] var filters: [SortFilter] = []
init(id: String = "", name: String = "", flag: String = "") { init(id: String = "", name: String = "", flag: String = "") {
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
self.flag = flag self.flag = flag
} }
///
///
static func home() -> SortData { static func home() -> SortData {
SortData(id: "home", name: "推荐", flag: "1") SortData(id: "home", name: "推荐", flag: "1")
} }
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
/// ///
struct SortFilter: Codable, Hashable { struct SortFilter: Codable, Hashable {
/// `year``area`
var key: String = "" var key: String = ""
/// UI
var name: String = "" var name: String = ""
///
var values: [SortFilterValue] = [] var values: [SortFilterValue] = []
struct SortFilterValue: Codable, Hashable { struct SortFilterValue: Codable, Hashable {
var n: String = "" // ///
var v: String = "" // var n: String = ""
///
var v: String = ""
} }
} }
} }
+18
View File
@@ -2,11 +2,14 @@ import Foundation
/// ///
enum PlayerEngine: Int, CaseIterable, Identifiable { enum PlayerEngine: Int, CaseIterable, Identifiable {
/// AVPlayer
case system = 0 case system = 0
/// VLC VLCKitSPM
case vlc = 10 case vlc = 10
var id: Int { rawValue } var id: Int { rawValue }
/// UI
var title: String { var title: String {
switch self { switch self {
case .system: case .system:
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
} }
} }
/// VLC
static var isVLCAvailable: Bool { static var isVLCAvailable: Bool {
#if canImport(VLCKitSPM) #if canImport(VLCKitSPM)
return true return true
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
#endif #endif
} }
///
/// VLC
static var availableEngines: [PlayerEngine] { static var availableEngines: [PlayerEngine] {
var engines: [PlayerEngine] = [.system] var engines: [PlayerEngine] = [.system]
if isVLCAvailable { if isVLCAvailable {
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
return engines return engines
} }
///
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine { static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
guard let engine = PlayerEngine(rawValue: rawValue) else { guard let engine = PlayerEngine(rawValue: rawValue) else {
return .system return .system
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
/// ///
enum VideoDecodeMode: Int, CaseIterable, Identifiable { enum VideoDecodeMode: Int, CaseIterable, Identifiable {
///
case auto = 0 case auto = 0
///
case hardware = 1 case hardware = 1
///
case software = 2 case software = 2
var id: Int { rawValue } var id: Int { rawValue }
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
} }
/// VLC /// VLC
/// `avcodec-hw`
var vlcHardwareDecodeOption: String? { var vlcHardwareDecodeOption: String? {
switch self { switch self {
case .auto: case .auto:
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
/// VLC /// VLC
enum VLCBufferMode: Int, CaseIterable, Identifiable { enum VLCBufferMode: Int, CaseIterable, Identifiable {
///
case lowLatency = 0 case lowLatency = 0
///
case balanced = 1 case balanced = 1
///
case smooth = 2 case smooth = 2
var id: Int { rawValue } var id: Int { rawValue }
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
self == .lowLatency self == .lowLatency
} }
/// /
/// - Parameters:
/// - isLive:
/// - Returns: network/live/file
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) { func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
switch self { switch self {
case .lowLatency: case .lowLatency:
+13 -4
View File
@@ -2,15 +2,24 @@ import Foundation
/// - Android SourceBean.java /// - Android SourceBean.java
struct SourceBean: Codable, Identifiable, Hashable { struct SourceBean: Codable, Identifiable, Hashable {
/// key
var id: String { key } var id: String { key }
///
let key: String let key: String
///
let name: String let name: String
///
let api: String let api: String
let searchable: Int // 0: 1: /// 0 1
let filterable: Int // 0: 1: let searchable: Int
let playerType: Int // 0: 1:IJK 2:EXO /// 0 1
let type: Int // 0:xml 1:json 3:jar 4:remote let filterable: Int
/// Swift
let playerType: Int
/// 0 XML1 JSON3 JAR4 Remote
let type: Int
/// remote
let ext: String? let ext: String?
init(key: String = "", name: String = "", api: String = "", init(key: String = "", name: String = "", api: String = "",
+20 -3
View File
@@ -2,16 +2,27 @@ import Foundation
/// - Android VodInfo.java /// - Android VodInfo.java
struct VodInfo: Codable, Identifiable { struct VodInfo: Codable, Identifiable {
/// ID
var id: String var id: String
///
var name: String = "" var name: String = ""
///
var pic: String = "" var pic: String = ""
///
var note: String = "" var note: String = ""
///
var year: String = "" var year: String = ""
///
var area: String = "" var area: String = ""
///
var typeName: String = "" var typeName: String = ""
///
var director: String = "" var director: String = ""
///
var actor: String = "" var actor: String = ""
///
var des: String = "" var des: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
/// 线 /// 线
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
/// key: flag, value: /// key: flag, value:
var playUrlMap: [String: [Episode]] = [:] var playUrlMap: [String: [Episode]] = [:]
var playFlag: String = "" // 线 /// 线
var playIndex: Int = 0 // var playFlag: String = ""
///
var playIndex: Int = 0
/// ///
struct Episode: Codable, Identifiable, Hashable { struct Episode: Codable, Identifiable, Hashable {
var id: String { name } var id: String { name }
///
let name: String let name: String
///
let url: String let url: String
init(name: String, url: String) { init(name: String, url: String) {
@@ -48,7 +63,7 @@ struct VodInfo: Codable, Identifiable {
info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
info.sourceKey = video.sourceKey info.sourceKey = video.sourceKey
// //
// playFrom : "线1$$$线2$$$线3" // playFrom : "线1$$$线2$$$线3"
// playUrl : "1$url1#2$url2$$$1$url3#2$url4" // playUrl : "1$url1#2$url2$$$1$url3#2$url4"
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty } let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
return info return info
} }
/// 线
var currentEpisodes: [Episode] { var currentEpisodes: [Episode] {
playUrlMap[playFlag] ?? [] playUrlMap[playFlag] ?? []
} }
/// 线 +
var currentEpisode: Episode? { var currentEpisode: Episode? {
let eps = currentEpisodes let eps = currentEpisodes
guard playIndex >= 0, playIndex < eps.count else { return nil } guard playIndex >= 0, playIndex < eps.count else { return nil }
+24 -2
View File
@@ -5,18 +5,26 @@ import SwiftData
/// ///
struct VodPlaybackState: Codable { struct VodPlaybackState: Codable {
/// 线
var flag: String var flag: String
///
var episodeIndex: Int var episodeIndex: Int
///
var progressSeconds: Double var progressSeconds: Double
} }
/// ///
@Model @Model
final class VodCollect { final class VodCollect {
/// ID sourceKey
var vodId: String = "" var vodId: String = ""
///
var vodName: String = "" var vodName: String = ""
///
var vodPic: String = "" var vodPic: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
/// /
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
@@ -31,12 +39,19 @@ final class VodCollect {
/// ///
@Model @Model
final class VodRecord { final class VodRecord {
/// ID
var vodId: String = "" var vodId: String = ""
///
var vodName: String = "" var vodName: String = ""
///
var vodPic: String = "" var vodPic: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
var playNote: String = "" // "5 03:45" /// 5 03:45
var dataJson: String = "" // JSONVodPlaybackState var playNote: String = ""
/// JSON`VodPlaybackState`
var dataJson: String = ""
///
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
@@ -52,8 +67,11 @@ final class VodRecord {
/// ///
@Model @Model
final class CacheItem { final class CacheItem {
///
@Attribute(.unique) var key: String = "" @Attribute(.unique) var key: String = ""
///
var value: String = "" var value: String = ""
///
var updateTime: Date = Date() var updateTime: Date = Date()
init(key: String, value: String) { init(key: String, value: String) {
@@ -95,6 +113,7 @@ actor CacheStore {
@MainActor @MainActor
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
// (vodId, sourceKey)
let predicate = #Predicate<VodCollect> { item in let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.vodId == vodId && item.sourceKey == sourceKey
} }
@@ -151,6 +170,7 @@ actor CacheStore {
try? context.save() try? context.save()
} }
/// JSON `nil`
@MainActor @MainActor
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else { guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else {
@@ -171,6 +191,7 @@ actor CacheStore {
@MainActor @MainActor
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
// (vodId, sourceKey)
let predicate = #Predicate<VodRecord> { item in let predicate = #Predicate<VodRecord> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.vodId == vodId && item.sourceKey == sourceKey
} }
@@ -185,6 +206,7 @@ actor CacheStore {
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
/// JSON
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? { private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
guard let data = json.data(using: .utf8) else { return nil } guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(VodPlaybackState.self, from: data) return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
+14
View File
@@ -2,8 +2,10 @@ import Foundation
/// - Android OkGo /// - Android OkGo
class NetworkManager { class NetworkManager {
///
static let shared = NetworkManager() static let shared = NetworkManager()
///
private static let fallbackCharsetNames: [String] = [ private static let fallbackCharsetNames: [String] = [
"utf-8", "utf-8",
"gb18030", "gb18030",
@@ -17,6 +19,7 @@ class NetworkManager {
"iso-8859-1" "iso-8859-1"
] ]
///
private static let fallbackStringEncodings: [String.Encoding] = [ private static let fallbackStringEncodings: [String.Encoding] = [
.utf8, .utf8,
.utf16, .utf16,
@@ -29,9 +32,12 @@ class NetworkManager {
.isoLatin1 .isoLatin1
] ]
///
private let session: URLSession private let session: URLSession
/// JSON
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
///
private init() { private init() {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 15
@@ -60,6 +66,7 @@ class NetworkManager {
throw NetworkError.httpError(httpResponse.statusCode) throw NetworkError.httpError(httpResponse.statusCode)
} }
// +
guard let str = Self.decodeString(data: data, response: httpResponse) else { guard let str = Self.decodeString(data: data, response: httpResponse) else {
throw NetworkError.decodingError("文本解码失败") throw NetworkError.decodingError("文本解码失败")
} }
@@ -85,6 +92,10 @@ class NetworkManager {
return data return data
} }
///
/// 1)
/// 2)
/// 3) UTF-8
private static func decodeString(data: Data, response: HTTPURLResponse) -> String? { private static func decodeString(data: Data, response: HTTPURLResponse) -> String? {
// 使 gbk / gb2312 / gb18030 // 使 gbk / gb2312 / gb18030
if let charset = response.textEncodingName, if let charset = response.textEncodingName,
@@ -114,6 +125,7 @@ class NetworkManager {
return nil return nil
} }
/// IANA `String.Encoding`
private static func encoding(fromIANACharset charset: String) -> String.Encoding? { private static func encoding(fromIANACharset charset: String) -> String.Encoding? {
let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString) let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString)
guard cfEncoding != kCFStringEncodingInvalidId else { guard cfEncoding != kCFStringEncodingInvalidId else {
@@ -124,12 +136,14 @@ class NetworkManager {
} }
} }
///
enum NetworkError: LocalizedError { enum NetworkError: LocalizedError {
case invalidURL(String) case invalidURL(String)
case invalidResponse case invalidResponse
case httpError(Int) case httpError(Int)
case decodingError(String) case decodingError(String)
/// UI/
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidURL(let url): return "无效的URL: \(url)" case .invalidURL(let url): return "无效的URL: \(url)"
+33
View File
@@ -2,9 +2,13 @@ import Foundation
import SwiftUI import SwiftUI
struct PlaybackQualityOption: Identifiable, Hashable { struct PlaybackQualityOption: Identifiable, Hashable {
///
static let autoIdentifier = "auto" static let autoIdentifier = "auto"
/// 使 auto id
let id: String let id: String
/// UI 1080p / 720p /
let name: String let name: String
///
let url: String let url: String
var isAuto: Bool { var isAuto: Bool {
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
/// ViewModel /// ViewModel
@MainActor @MainActor
class DetailViewModel: ObservableObject { class DetailViewModel: ObservableObject {
///
@Published var vodInfo: VodInfo? @Published var vodInfo: VodInfo?
///
@Published var isLoading = false @Published var isLoading = false
///
@Published var errorMessage: String? @Published var errorMessage: String?
/// 线
@Published var selectedFlag: String = "" @Published var selectedFlag: String = ""
///
@Published var selectedEpisodeIndex: Int = 0 @Published var selectedEpisodeIndex: Int = 0
///
@Published var isPlaying = false @Published var isPlaying = false
///
@Published var playUrl: String? @Published var playUrl: String?
///
@Published var resumeSeconds: Double = 0 @Published var resumeSeconds: Double = 0
///
@Published var qualityOptions: [PlaybackQualityOption] = [] @Published var qualityOptions: [PlaybackQualityOption] = []
/// id
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier @Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
/// UI
private var realtimeProgressSeconds: Double = 0 private var realtimeProgressSeconds: Double = 0
///
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
private let network = NetworkManager.shared private let network = NetworkManager.shared
///
private var qualityBaseEpisodeURL: String = "" private var qualityBaseEpisodeURL: String = ""
/// key URL
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:] private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
///
private var qualityResolveTask: Task<Void, Never>? private var qualityResolveTask: Task<Void, Never>?
///
private var qualityResolveToken = UUID() private var qualityResolveToken = UUID()
/// ///
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
realtimeProgressSeconds = 0 realtimeProgressSeconds = 0
if let episode = vodInfo?.currentEpisode { if let episode = vodInfo?.currentEpisode {
// URL
let shouldResetQuality = qualityBaseEpisodeURL != episode.url let shouldResetQuality = qualityBaseEpisodeURL != episode.url
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality) updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
playUrl = selectedPlayableURL(fallback: episode.url) playUrl = selectedPlayableURL(fallback: episode.url)
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
selectedQualityId = option.id selectedQualityId = option.id
guard isPlaying else { return } guard isPlaying else { return }
// 使使
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
guard !targetURL.isEmpty, playUrl != targetURL else { return } guard !targetURL.isEmpty, playUrl != targetURL else { return }
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
} }
private func selectedPlayableURL(fallback: String) -> String { private func selectedPlayableURL(fallback: String) -> String {
// URL使退
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
if let selected, !selected.isEmpty { if let selected, !selected.isEmpty {
return selected return selected
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
return fallback return fallback
} }
///
private func resetQualityState() { private func resetQualityState() {
qualityResolveTask?.cancel() qualityResolveTask?.cancel()
qualityResolveTask = nil qualityResolveTask = nil
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
return return
} }
//
qualityResolveTask?.cancel() qualityResolveTask?.cancel()
qualityResolveTask = nil qualityResolveTask = nil
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
qualityOptions = [autoOption] qualityOptions = [autoOption]
if let cached = qualityOptionCache[trimmedEpisodeURL] { if let cached = qualityOptionCache[trimmedEpisodeURL] {
//
qualityOptions = cached qualityOptions = cached
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) { if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
selectedQualityId = PlaybackQualityOption.autoIdentifier selectedQualityId = PlaybackQualityOption.autoIdentifier
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
} }
} }
/// HLS
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] { private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] } guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
guard let playlist = try? await network.getString(from: episodeURL) else { return [] } guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
return Self.parseMasterPlaylist(playlist, masterURL: url) return Self.parseMasterPlaylist(playlist, masterURL: url)
} }
/// HLS
private struct HLSVariant { private struct HLSVariant {
let url: String let url: String
let name: String? let name: String?
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
let bandwidth: Int? let bandwidth: Int?
} }
/// URL HLS
private static func looksLikeHLSURL(_ url: URL) -> Bool { private static func looksLikeHLSURL(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased() let lowercased = url.absoluteString.lowercased()
if lowercased.contains(".m3u8") { return true } if lowercased.contains(".m3u8") { return true }
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
return ext == "m3u8" || ext == "m3u" return ext == "m3u8" || ext == "m3u"
} }
/// HLS
/// 2
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] { private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] } guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
return merged return merged
} }
/// `EXT-X-STREAM-INF`
private static func parseAttributeMap(_ raw: String) -> [String: String] { private static func parseAttributeMap(_ raw: String) -> [String: String] {
var result: [String: String] = [:] var result: [String: String] = [:]
let pairs = splitAttributes(raw) let pairs = splitAttributes(raw)
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
return result return result
} }
///
private static func splitAttributes(_ raw: String) -> [String] { private static func splitAttributes(_ raw: String) -> [String] {
var parts: [String] = [] var parts: [String] = []
var buffer = "" var buffer = ""
+14 -1
View File
@@ -4,15 +4,24 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class HomeViewModel: ObservableObject { class HomeViewModel: ObservableObject {
///
@Published var sorts: [MovieSort.SortData] = [] @Published var sorts: [MovieSort.SortData] = []
///
@Published var selectedSort: MovieSort.SortData? @Published var selectedSort: MovieSort.SortData?
///
@Published var homeVideos: [Movie.Video] = [] @Published var homeVideos: [Movie.Video] = []
///
@Published var categoryVideos: [Movie.Video] = [] @Published var categoryVideos: [Movie.Video] = []
///
@Published var isLoading = false @Published var isLoading = false
///
@Published var currentPage = 1 @Published var currentPage = 1
///
@Published var hasMore = true @Published var hasMore = true
///
@Published var errorMessage: String? @Published var errorMessage: String?
/// 访
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
/// ///
@@ -24,7 +33,7 @@ class HomeViewModel: ObservableObject {
do { do {
let result = try await sourceService.getSort(sourceBean: source) let result = try await sourceService.getSort(sourceBean: source)
// "" // UI Android
var allSorts = [MovieSort.SortData.home()] var allSorts = [MovieSort.SortData.home()]
allSorts.append(contentsOf: result.sorts) allSorts.append(contentsOf: result.sorts)
@@ -43,6 +52,7 @@ class HomeViewModel: ObservableObject {
/// ///
func selectSort(_ sort: MovieSort.SortData) { func selectSort(_ sort: MovieSort.SortData) {
//
selectedSort = sort selectedSort = sort
errorMessage = nil errorMessage = nil
categoryVideos = [] categoryVideos = []
@@ -62,6 +72,7 @@ class HomeViewModel: ObservableObject {
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async { private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
guard sort.id != "home" else { return } guard sort.id != "home" else { return }
guard let source = ApiConfig.shared.homeSourceBean else { return } guard let source = ApiConfig.shared.homeSourceBean else { return }
//
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
@@ -78,6 +89,7 @@ class HomeViewModel: ObservableObject {
} else { } else {
categoryVideos.append(contentsOf: videos) categoryVideos.append(contentsOf: videos)
} }
//
currentPage = page currentPage = page
hasMore = !videos.isEmpty hasMore = !videos.isEmpty
} catch { } catch {
@@ -105,6 +117,7 @@ class HomeViewModel: ObservableObject {
/// ///
func refresh() async { func refresh() async {
//
currentPage = 1 currentPage = 1
hasMore = true hasMore = true
categoryVideos = [] categoryVideos = []
+11 -1
View File
@@ -4,12 +4,19 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class LiveViewModel: ObservableObject { class LiveViewModel: ObservableObject {
///
@Published var channelGroups: [LiveChannelGroup] = [] @Published var channelGroups: [LiveChannelGroup] = []
///
@Published var selectedGroupIndex: Int = 0 @Published var selectedGroupIndex: Int = 0
///
@Published var selectedChannelIndex: Int = 0 @Published var selectedChannelIndex: Int = 0
///
@Published var currentChannel: LiveChannelItem? @Published var currentChannel: LiveChannelItem?
///
@Published var epgList: [Epginfo] = [] @Published var epgList: [Epginfo] = []
/// 便 EPG
@Published var isLoading = false @Published var isLoading = false
/// TV
@Published var showChannelList = false @Published var showChannelList = false
/// ///
@@ -67,6 +74,7 @@ class LiveViewModel: ObservableObject {
/// 线 /// 线
func switchSource() { func switchSource() {
// `currentChannel` mutating @Published
currentChannel?.nextSource() currentChannel?.nextSource()
} }
@@ -78,13 +86,15 @@ class LiveViewModel: ObservableObject {
/// EPG /// EPG
private func loadEPG(for channel: LiveChannelItem) { private func loadEPG(for channel: LiveChannelItem) {
// EPG - // / ID EPG
//
epgList = [] epgList = []
} }
} }
// 访 // 访
extension Collection { extension Collection {
/// `nil`
subscript(safe index: Index) -> Element? { subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
} }
+13 -1
View File
@@ -4,14 +4,21 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class SearchViewModel: ObservableObject { class SearchViewModel: ObservableObject {
///
@Published var keyword: String = "" @Published var keyword: String = ""
///
@Published var results: [Movie.Video] = [] @Published var results: [Movie.Video] = []
///
@Published var isSearching = false @Published var isSearching = false
///
@Published var searchHistory: [String] = [] @Published var searchHistory: [String] = []
///
@Published var errorMessage: String? @Published var errorMessage: String?
///
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
///
init() { init() {
loadSearchHistory() loadSearchHistory()
} }
@@ -25,9 +32,10 @@ class SearchViewModel: ObservableObject {
errorMessage = nil errorMessage = nil
results = [] results = []
// //
addToHistory(trimmed) addToHistory(trimmed)
//
let videos = await sourceService.searchAll(keyword: trimmed) let videos = await sourceService.searchAll(keyword: trimmed)
self.results = videos self.results = videos
@@ -57,10 +65,12 @@ class SearchViewModel: ObservableObject {
// MARK: - // MARK: -
///
private func loadSearchHistory() { private func loadSearchHistory() {
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? [] searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
} }
/// 20
private func addToHistory(_ keyword: String) { private func addToHistory(_ keyword: String) {
searchHistory.removeAll { $0 == keyword } searchHistory.removeAll { $0 == keyword }
searchHistory.insert(keyword, at: 0) searchHistory.insert(keyword, at: 0)
@@ -70,11 +80,13 @@ class SearchViewModel: ObservableObject {
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
} }
///
func clearHistory() { func clearHistory() {
searchHistory = [] searchHistory = []
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
} }
///
func removeFromHistory(_ keyword: String) { func removeFromHistory(_ keyword: String) {
searchHistory.removeAll { $0 == keyword } searchHistory.removeAll { $0 == keyword }
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
+38
View File
@@ -4,7 +4,9 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class SettingsViewModel: ObservableObject { class SettingsViewModel: ObservableObject {
///
struct PendingMultiRepoSelection: Identifiable { struct PendingMultiRepoSelection: Identifiable {
///
enum Target { enum Target {
case vod case vod
case live case live
@@ -18,30 +20,54 @@ class SettingsViewModel: ObservableObject {
} }
let id = UUID() let id = UUID()
///
let target: Target let target: Target
/// live
let sourceUrl: String let sourceUrl: String
///
let options: [ApiConfig.MultiRepoOption] let options: [ApiConfig.MultiRepoOption]
} }
///
@Published var vodApiUrl: String = "" @Published var vodApiUrl: String = ""
///
@Published var liveApiUrl: String = "" @Published var liveApiUrl: String = ""
///
@Published var isLoadingConfig = false @Published var isLoadingConfig = false
///
@Published var configError: String? @Published var configError: String?
/// UI /
@Published var configSuccess = false @Published var configSuccess = false
/// nil
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection? @Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
/// API
@Published var apiHistory: [String] = [] @Published var apiHistory: [String] = []
///
@Published var vodPlayerEngine: PlayerEngine = .system @Published var vodPlayerEngine: PlayerEngine = .system
///
@Published var livePlayerEngine: PlayerEngine = .system @Published var livePlayerEngine: PlayerEngine = .system
///
@Published var decodeMode: VideoDecodeMode = .auto @Published var decodeMode: VideoDecodeMode = .auto
/// VLC
@Published var vlcBufferMode: VLCBufferMode = .defaultMode @Published var vlcBufferMode: VLCBufferMode = .defaultMode
/// /退
@Published var playTimeStep: Int = 10 @Published var playTimeStep: Int = 10
///
@Published var cacheSizeString: String = "0 KB" @Published var cacheSizeString: String = "0 KB"
///
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60] let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
///
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
///
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
/// VLC
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
///
/// 1)
/// 2)
/// 3) /
init() { init() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? "" let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
@@ -99,6 +125,7 @@ class SettingsViewModel: ObservableObject {
do { do {
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
//
if let pending = try await detectPendingMultiRepoSelection( if let pending = try await detectPendingMultiRepoSelection(
vodUrl: trimmedVod, vodUrl: trimmedVod,
liveUrl: resolvedLive liveUrl: resolvedLive
@@ -109,6 +136,7 @@ class SettingsViewModel: ObservableObject {
} }
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
// live
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL) UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL) UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
vodApiUrl = trimmedVod vodApiUrl = trimmedVod
@@ -123,6 +151,7 @@ class SettingsViewModel: ObservableObject {
isLoadingConfig = false isLoadingConfig = false
} }
///
func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async { func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async {
guard let pending = pendingMultiRepoSelection else { return } guard let pending = pendingMultiRepoSelection else { return }
let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl) let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl)
@@ -130,6 +159,7 @@ class SettingsViewModel: ObservableObject {
switch pending.target { switch pending.target {
case .vod: case .vod:
let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl) let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl)
// live vod
let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& normalizedLive == normalizedSource && normalizedLive == normalizedSource
vodApiUrl = option.url vodApiUrl = option.url
@@ -144,11 +174,14 @@ class SettingsViewModel: ObservableObject {
await loadConfig() await loadConfig()
} }
///
func cancelPendingMultiRepoSelection() { func cancelPendingMultiRepoSelection() {
pendingMultiRepoSelection = nil pendingMultiRepoSelection = nil
isLoadingConfig = false isLoadingConfig = false
} }
///
/// - Returns: `nil`
private func detectPendingMultiRepoSelection( private func detectPendingMultiRepoSelection(
vodUrl: String, vodUrl: String,
liveUrl: String liveUrl: String
@@ -186,10 +219,12 @@ class SettingsViewModel: ObservableObject {
// MARK: - API // MARK: - API
/// API
private func loadApiHistory() { private func loadApiHistory() {
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? [] apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
} }
/// 10
private func addToApiHistory(_ url: String) { private func addToApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url } apiHistory.removeAll { $0 == url }
apiHistory.insert(url, at: 0) apiHistory.insert(url, at: 0)
@@ -199,6 +234,7 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(apiHistory, forKey: "api_history") UserDefaults.standard.set(apiHistory, forKey: "api_history")
} }
/// API
func removeApiHistory(_ url: String) { func removeApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url } apiHistory.removeAll { $0 == url }
UserDefaults.standard.set(apiHistory, forKey: "api_history") UserDefaults.standard.set(apiHistory, forKey: "api_history")
@@ -247,12 +283,14 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
} }
/// +
private func refreshCacheSize() { private func refreshCacheSize() {
let sharedDisk = URLCache.shared.currentDiskUsage let sharedDisk = URLCache.shared.currentDiskUsage
let imageDisk = ImageLoader.shared.cacheUsage.disk let imageDisk = ImageLoader.shared.cacheUsage.disk
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk) cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
} }
///
private static func formatSize(bytes: Int) -> String { private static func formatSize(bytes: Int) -> String {
let size = max(0, bytes) let size = max(0, bytes)
if size < 1024 * 1024 { if size < 1024 * 1024 {
+7
View File
@@ -1,8 +1,13 @@
import SwiftUI import SwiftUI
///
///
struct EmptyStateView: View { struct EmptyStateView: View {
/// SF Symbol
let icon: String let icon: String
///
let title: String let title: String
///
var message: String? = nil var message: String? = nil
var body: some View { var body: some View {
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
} }
.padding(.bottom, 8) .padding(.bottom, 8)
//
Text(title) Text(title)
.font(.title3.bold()) .font(.title3.bold())
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
.tracking(1) .tracking(1)
//
if let message = message { if let message = message {
Text(message) Text(message)
.font(.callout) .font(.callout)
+11
View File
@@ -2,15 +2,21 @@ import SwiftUI
/// - Android HomeActivity TabView /// - Android HomeActivity TabView
struct ContentView: View { struct ContentView: View {
/// 使
private enum ApiInputTarget { private enum ApiInputTarget {
case vod case vod
case live case live
} }
///
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
/// ViewModel
@StateObject private var settingsVM = SettingsViewModel() @StateObject private var settingsVM = SettingsViewModel()
///
@State private var selectedTab = 0 @State private var selectedTab = 0
/// `appState.isConfigLoaded`
@State private var showSetup = false @State private var showSetup = false
///
@State private var setupInputTarget: ApiInputTarget = .vod @State private var setupInputTarget: ApiInputTarget = .vod
var body: some View { var body: some View {
@@ -29,6 +35,7 @@ struct ContentView: View {
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? "" let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? "" let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
if !savedVodUrl.isEmpty { if !savedVodUrl.isEmpty {
//
Task { Task {
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl) await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
} }
@@ -38,6 +45,7 @@ struct ContentView: View {
@ViewBuilder @ViewBuilder
private var multiRepoSelectionOverlay: some View { private var multiRepoSelectionOverlay: some View {
//
if let pending = settingsVM.pendingMultiRepoSelection { if let pending = settingsVM.pendingMultiRepoSelection {
SelectionModal( SelectionModal(
title: "选择\(pending.target.title)仓库", title: "选择\(pending.target.title)仓库",
@@ -62,6 +70,7 @@ struct ContentView: View {
// MARK: - // MARK: -
/// iOS 使 TabViewmacOS 使 NavigationSplitView
private var mainTabView: some View { private var mainTabView: some View {
#if os(iOS) #if os(iOS)
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -130,6 +139,7 @@ struct ContentView: View {
// MARK: - // MARK: -
///
private var setupView: some View { private var setupView: some View {
ZStack { ZStack {
// //
@@ -346,6 +356,7 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string UIPasteboard.general.string
#else #else
// macOS NSPasteboard
NSPasteboard.general.string(forType: .string) NSPasteboard.general.string(forType: .string)
#endif #endif
} }
+2 -1
View File
@@ -1 +1,2 @@
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets. // `tvbox/Utils/Extensions.swift`
//
+8
View File
@@ -2,17 +2,24 @@ import SwiftUI
/// - Android SeriesAdapter /// - Android SeriesAdapter
struct EpisodeListView: View { struct EpisodeListView: View {
/// 线
let episodes: [VodInfo.Episode] let episodes: [VodInfo.Episode]
///
let selectedIndex: Int let selectedIndex: Int
///
let onSelect: (Int) -> Void let onSelect: (Int) -> Void
/// 50
@State private var currentGroup = 0 @State private var currentGroup = 0
///
private let groupSize = 50 private let groupSize = 50
/// 1 0
private var groupCount: Int { private var groupCount: Int {
max(1, (episodes.count + groupSize - 1) / groupSize) max(1, (episodes.count + groupSize - 1) / groupSize)
} }
///
private var currentEpisodes: [VodInfo.Episode] { private var currentEpisodes: [VodInfo.Episode] {
let start = currentGroup * groupSize let start = currentGroup * groupSize
let end = min(start + groupSize, episodes.count) let end = min(start + groupSize, episodes.count)
@@ -62,6 +69,7 @@ struct EpisodeListView: View {
GridItem(.fixed(44)), GridItem(.fixed(44)),
GridItem(.fixed(44)) GridItem(.fixed(44))
], spacing: 10) { ], spacing: 10) {
// 使 + 便
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
let actualIndex = currentGroup * groupSize + index let actualIndex = currentGroup * groupSize + index
Button { Button {
+10
View File
@@ -3,15 +3,19 @@ import SwiftData
/// - Android CollectActivity /// - Android CollectActivity
struct FavoritesView: View { struct FavoritesView: View {
/// /
@Query(sort: \VodCollect.updateTime, order: .reverse) @Query(sort: \VodCollect.updateTime, order: .reverse)
private var favorites: [VodCollect] private var favorites: [VodCollect]
/// SwiftData
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
@@ -25,6 +29,7 @@ struct FavoritesView: View {
} else { } else {
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) {
//
ForEach(favorites) { item in ForEach(favorites) { item in
NavigationLink(value: movieVideo(from: item)) { NavigationLink(value: movieVideo(from: item)) {
favoriteCard(item) favoriteCard(item)
@@ -50,12 +55,14 @@ struct FavoritesView: View {
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
// Movie.Video /
.navigationDestination(for: Movie.Video.self) { video in .navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video) DetailView(video: video)
} }
} }
} }
///
private var emptyState: some View { private var emptyState: some View {
EmptyStateView( EmptyStateView(
icon: "heart.text.square", icon: "heart.text.square",
@@ -65,6 +72,8 @@ struct FavoritesView: View {
.padding(40) .padding(40)
} }
///
///
private func favoriteCard(_ item: VodCollect) -> some View { private func favoriteCard(_ item: VodCollect) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
@@ -83,6 +92,7 @@ struct FavoritesView: View {
} }
} }
///
private func movieVideo(from item: VodCollect) -> Movie.Video { private func movieVideo(from item: VodCollect) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
} }
+11
View File
@@ -3,15 +3,19 @@ import SwiftData
/// - Android HistoryActivity /// - Android HistoryActivity
struct HistoryView: View { struct HistoryView: View {
///
@Query(sort: \VodRecord.updateTime, order: .reverse) @Query(sort: \VodRecord.updateTime, order: .reverse)
private var records: [VodRecord] private var records: [VodRecord]
/// SwiftData
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
@@ -25,6 +29,7 @@ struct HistoryView: View {
} else { } else {
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) {
//
ForEach(records) { item in ForEach(records) { item in
NavigationLink(value: movieVideo(from: item)) { NavigationLink(value: movieVideo(from: item)) {
recordCard(item) recordCard(item)
@@ -53,6 +58,7 @@ struct HistoryView: View {
.toolbar { .toolbar {
if !records.isEmpty { if !records.isEmpty {
ToolbarItem(placement: .automatic) { ToolbarItem(placement: .automatic) {
// 使
Button { Button {
Task { Task {
CacheStore.shared.clearHistory(context: modelContext) CacheStore.shared.clearHistory(context: modelContext)
@@ -64,12 +70,14 @@ struct HistoryView: View {
} }
} }
} }
// //
.navigationDestination(for: Movie.Video.self) { video in .navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video) DetailView(video: video)
} }
} }
} }
///
private var emptyState: some View { private var emptyState: some View {
EmptyStateView( EmptyStateView(
icon: "clock.arrow.circlepath", icon: "clock.arrow.circlepath",
@@ -79,6 +87,8 @@ struct HistoryView: View {
.padding(40) .padding(40)
} }
///
/// 便
private func recordCard(_ item: VodRecord) -> some View { private func recordCard(_ item: VodRecord) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
@@ -115,6 +125,7 @@ struct HistoryView: View {
} }
} }
///
private func movieVideo(from item: VodRecord) -> Movie.Video { private func movieVideo(from item: VodRecord) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
} }
+4
View File
@@ -2,7 +2,9 @@ import SwiftUI
/// ///
struct VodCardView: View { struct VodCardView: View {
///
let video: Movie.Video let video: Movie.Video
/// macOS
@State private var isHovered = false @State private var isHovered = false
var body: some View { var body: some View {
@@ -44,6 +46,7 @@ struct VodCardView: View {
.padding(8) .padding(8)
} }
} }
//
.scaleEffect(isHovered ? 1.05 : 1.0) .scaleEffect(isHovered ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in .onHover { hovering in
@@ -67,6 +70,7 @@ struct VodCardView: View {
} }
} }
///
private var placeholderImage: some View { private var placeholderImage: some View {
RoundedRectangle(cornerRadius: AppTheme.cardRadius) RoundedRectangle(cornerRadius: AppTheme.cardRadius)
.fill(Color.white.opacity(0.05)) .fill(Color.white.opacity(0.05))
+27
View File
@@ -6,24 +6,43 @@ import AppKit
/// - Android LivePlayActivity /// - Android LivePlayActivity
struct LiveView: View { struct LiveView: View {
///
@StateObject private var viewModel = LiveViewModel() @StateObject private var viewModel = LiveViewModel()
/// macOS
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
/// 使
@State private var avPlayer: AVPlayer? @State private var avPlayer: AVPlayer?
///
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1 @AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
///
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue @AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
///
@State private var showChannelDrawer = true @State private var showChannelDrawer = true
///
@State private var isWindowFullScreen = false @State private var isWindowFullScreen = false
///
private let currentChannelInfoMaxWidth: CGFloat = 600 private let currentChannelInfoMaxWidth: CGFloat = 600
/// AVPlayer
@State private var itemStatusObserver: NSKeyValueObservation? @State private var itemStatusObserver: NSKeyValueObservation?
///
@State private var playbackFailedObserver: NSObjectProtocol? @State private var playbackFailedObserver: NSObjectProtocol?
///
@State private var playbackStalledObserver: NSObjectProtocol? @State private var playbackStalledObserver: NSObjectProtocol?
/// 线线
@State private var failedSourceIndices: Set<Int> = [] @State private var failedSourceIndices: Set<Int> = []
/// ID
@State private var trackedChannelId: String = "" @State private var trackedChannelId: String = ""
///
@State private var showCurrentChannelInfo = true @State private var showCurrentChannelInfo = true
///
@State private var channelInfoTimer: Timer? @State private var channelInfoTimer: Timer?
///
private let channelInfoAutoHideDelay: TimeInterval = 3.0 private let channelInfoAutoHideDelay: TimeInterval = 3.0
/// VLC
@State private var vlcInteractionToken = 0 @State private var vlcInteractionToken = 0
///
/// 退使
private var selectedEngine: PlayerEngine { private var selectedEngine: PlayerEngine {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let rawValue: Int let rawValue: Int
@@ -80,6 +99,7 @@ struct LiveView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.onAppear { .onAppear {
//
viewModel.loadChannels() viewModel.loadChannels()
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
@@ -92,6 +112,7 @@ struct LiveView: View {
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
.onChange(of: viewModel.currentChannel?.id) { _, _ in .onChange(of: viewModel.currentChannel?.id) { _, _ in
// 线
resetFailureTracking(for: viewModel.currentChannel) resetFailureTracking(for: viewModel.currentChannel)
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
@@ -364,6 +385,7 @@ struct LiveView: View {
} }
private func reportUserActivity() { private func reportUserActivity() {
// VLC
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
vlcInteractionToken &+= 1 vlcInteractionToken &+= 1
} }
@@ -459,6 +481,7 @@ struct LiveView: View {
} }
private func cleanupPlayer() { private func cleanupPlayer() {
//
if let observer = playbackFailedObserver { if let observer = playbackFailedObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
playbackFailedObserver = nil playbackFailedObserver = nil
@@ -475,6 +498,7 @@ struct LiveView: View {
} }
private func observePlaybackFailure(for item: AVPlayerItem) { private func observePlaybackFailure(for item: AVPlayerItem) {
// KVO item
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed { if observedItem.status == .failed {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -483,6 +507,7 @@ struct LiveView: View {
} }
} }
//
playbackFailedObserver = NotificationCenter.default.addObserver( playbackFailedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime, forName: .AVPlayerItemFailedToPlayToEndTime,
object: item, object: item,
@@ -491,6 +516,7 @@ struct LiveView: View {
handlePlaybackFailure(trigger: "item_failed") handlePlaybackFailure(trigger: "item_failed")
} }
//
playbackStalledObserver = NotificationCenter.default.addObserver( playbackStalledObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled, forName: .AVPlayerItemPlaybackStalled,
object: item, object: item,
@@ -526,6 +552,7 @@ struct LiveView: View {
private func switchToNextAvailableSource(totalSources: Int) -> Bool { private func switchToNextAvailableSource(totalSources: Int) -> Bool {
guard failedSourceIndices.count < totalSources else { return false } guard failedSourceIndices.count < totalSources else { return false }
// `totalSources` 线
for _ in 0..<totalSources { for _ in 0..<totalSources {
viewModel.switchSource() viewModel.switchSource()
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false } guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
+11
View File
@@ -2,13 +2,16 @@ import SwiftUI
/// - Android SearchActivity /// - Android SearchActivity
struct SearchView: View { struct SearchView: View {
///
@StateObject private var viewModel = SearchViewModel() @StateObject private var viewModel = SearchViewModel()
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
@@ -29,6 +32,7 @@ struct SearchView: View {
} else if !viewModel.results.isEmpty { } else if !viewModel.results.isEmpty {
searchResults searchResults
} else if viewModel.keyword.isEmpty { } else if viewModel.keyword.isEmpty {
//
searchHistorySection searchHistorySection
} else if let error = viewModel.errorMessage { } else if let error = viewModel.errorMessage {
Spacer() Spacer()
@@ -53,6 +57,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchBar: some View { private var searchBar: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -114,6 +119,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchResults: some View { private var searchResults: some View {
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) {
@@ -134,6 +140,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchHistorySection: some View { private var searchHistorySection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
if !viewModel.searchHistory.isEmpty { if !viewModel.searchHistory.isEmpty {
@@ -183,13 +190,16 @@ struct SearchView: View {
/// ///
struct FlowLayout: Layout { struct FlowLayout: Layout {
///
var spacing: CGFloat = 8 var spacing: CGFloat = 8
///
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangement(proposal: proposal, subviews: subviews) let result = arrangement(proposal: proposal, subviews: subviews)
return result.size return result.size
} }
///
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews) let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
for (index, position) in result.positions.enumerated() { for (index, position) in result.positions.enumerated() {
@@ -197,6 +207,7 @@ struct FlowLayout: Layout {
} }
} }
///
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) { private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = [] var positions: [CGPoint] = []
+22
View File
@@ -1,10 +1,15 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
///
/// SwiftData `AppState`
@main @main
struct tvboxApp: App { struct tvboxApp: App {
///
@StateObject private var appState = AppState() @StateObject private var appState = AppState()
/// SwiftData
/// Schema//使
var sharedModelContainer: ModelContainer = { var sharedModelContainer: ModelContainer = {
let schema = Schema([ let schema = Schema([
VodCollect.self, VodCollect.self,
@@ -19,6 +24,7 @@ struct tvboxApp: App {
} }
}() }()
///
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
@@ -31,20 +37,32 @@ struct tvboxApp: App {
} }
} }
///
///
@MainActor @MainActor
class AppState: ObservableObject { class AppState: ObservableObject {
/// ViewModel 使
@Published var apiConfig = ApiConfig.shared @Published var apiConfig = ApiConfig.shared
/// `ContentView`
@Published var isConfigLoaded = false @Published var isConfigLoaded = false
/// key
@Published var currentSourceKey: String = "" @Published var currentSourceKey: String = ""
#if os(macOS) #if os(macOS)
/// macOS //
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all @Published var splitViewVisibility: NavigationSplitViewVisibility = .all
/// 退
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility? private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
#endif #endif
///
func loadConfig(url: String) async { func loadConfig(url: String) async {
await loadConfig(vodUrl: url, liveUrl: nil) await loadConfig(vodUrl: url, liveUrl: nil)
} }
///
/// - Parameters:
/// - vodUrl:
/// - liveUrl: 退
func loadConfig(vodUrl: String, liveUrl: String?) async { func loadConfig(vodUrl: String, liveUrl: String?) async {
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -59,12 +77,15 @@ class AppState: ObservableObject {
} }
} }
///
///
func applyLoadedConfigState() { func applyLoadedConfigState() {
isConfigLoaded = true isConfigLoaded = true
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
} }
#if os(macOS) #if os(macOS)
///
func enterPlayerFullScreen() { func enterPlayerFullScreen() {
if splitViewVisibilityBeforePlayerFullScreen == nil { if splitViewVisibilityBeforePlayerFullScreen == nil {
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
@@ -72,6 +93,7 @@ class AppState: ObservableObject {
splitViewVisibility = .detailOnly splitViewVisibility = .detailOnly
} }
/// 退
func exitPlayerFullScreen() { func exitPlayerFullScreen() {
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return } guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
splitViewVisibility = previous splitViewVisibility = previous